Skip to content

Plugin Conventions ​

The shipped routup plugins follow a small set of conventions. Adopting them in your plugin makes it feel native, integrates cleanly with decorators parameter extractors, and gives users predictable mental models across the ecosystem.

Helper naming ​

Tree-shakeable helpers always take event as the first argument. The function-name prefix tells the caller what the helper does and whether it's cached:

PrefixMeaningCaches in event.store?Async?Examples
get*Pure read, no side effectsNoNogetRequestHeader, getRequestIP
set*Mutate event state (headers, status)NoNosetResponseCookie, setResponseCacheHeaders
send*Build and return a ResponseNoNosendRedirect, sendStream
read*Async I/O readYesYesreadRequestBody, readRequestBodyStream
use*Create / look up a cached objectYesNouseRequestCookies, useTranslator
is*Boolean checkNoNoisRequestCacheable
match*Pattern match checkNoNomatchRequestContentType
append*Add to existing valueNoNoappendResponseHeader
create*Factory, returns a new objectNoNocreateEventStream

If your plugin caches state per request, use* and read* are the right prefixes. Reserve get* for pure reads that always recompute.

event.store keys ​

event.store is a Record<string | symbol, unknown> shared across the whole request. To avoid colliding with other plugins, key your entries with a Symbol.for() namespaced to your package:

typescript
const STORE_KEY = Symbol.for('@example/request-id');

event.store[STORE_KEY] = id;

Symbol.for(name) returns the same symbol for the same name across module instances, which matters when a plugin is loaded from two paths (workspace + npm cache, or duplicated by a bundler). Plain Symbol(name) would create a fresh symbol per import.

Don't expose the key — keep it private to your module and write use* helpers that read from it.

Package shape ​

A typical plugin's package.json:

json
{
    "name": "@example/request-id",
    "version": "1.0.0",
    "type": "module",
    "exports": {
        "./package.json": "./package.json",
        ".": {
            "types": "./dist/index.d.mts",
            "import": "./dist/index.mjs"
        }
    },
    "types": "./dist/index.d.mts",
    "files": ["dist/"],
    "engines": {
        "node": ">=22.0.0"
    },
    "peerDependencies": {
        "routup": "^5.0.0"
    }
}

Conventions worth keeping in lockstep with the rest of the ecosystem:

  • ESM-only, "type": "module". No CJS dual exports — routup core is ESM-only too.
  • peerDependencies for routup so a project can't accidentally pull two versions.
  • engines.node >= 22 — routup's baseline.
  • files: ["dist/"] + a build step that emits dist/index.mjs + dist/index.d.mts.
  • Read the version from package.json and pass it as the plugin's version:
typescript
import { version } from '../package.json' with { type: 'json' };

That keeps Plugin.version in sync with the published version automatically.

The factory pattern ​

Always export a factory function that returns a Plugin, not the Plugin object directly:

typescript
// good: factory accepts options
export function cookie(options: CookieOptions = {}): Plugin {
    return {
        name: 'cookie',
        version,
        install(router) { /* ... */ },
    };
}

Even when your plugin has no options today, the factory shape lets you add them later without breaking the import surface (app.use(plugin()) stays the same).

Failing loudly when a dep helper is called too early ​

Tree-shakeable helpers shouldn't return undefined just because the plugin isn't installed — that masks the problem far from the cause. Throw PluginNotInstalledError:

typescript
import { PluginNotInstalledError } from 'routup';

export function useRequestCookie(event: IAppEvent, name: string): string | undefined {
    if (!event.appOptions.plugins?.has('cookie')) {
        throw new PluginNotInstalledError('cookie', 'useRequestCookie');
    }
    return readCookieFromStore(event, name);
}

The error message tells the caller exactly which app.use(...) line they're missing.

Test skeleton ​

Plugins are easiest to test by registering them on a fresh App and dispatching real Request objects through app.fetch():

typescript
import { describe, expect, it } from 'vitest';
import { App, defineCoreHandler } from 'routup';
import { requestId, useRequestId } from '../src';

describe('request-id', () => {
    it('exposes a generated id when no header is set', async () => {
        const app = new App();
        app.use(requestId());
        app.get('/', defineCoreHandler((event) => useRequestId(event)));

        const response = await app.fetch(new Request('http://localhost/'));
        const id = await response.text();

        expect(id).toMatch(/^[0-9a-f-]{36}$/);
        expect(response.headers.get('x-request-id')).toBe(id);
    });

    it('echoes an incoming x-request-id', async () => {
        const app = new App();
        app.use(requestId());
        app.get('/', defineCoreHandler((event) => useRequestId(event)));

        const response = await app.fetch(new Request('http://localhost/', {
            headers: { 'x-request-id': 'abc' },
        }));

        expect(await response.text()).toBe('abc');
    });
});

No mocks, no test server — app.fetch(request) is the entire surface.

See also ​