diff --git a/README.md b/README.md index c67c13a..9f967b4 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,26 @@ Composable, type-safe middleware for Web Fetch handlers. -A **middleware** is a `(config, handler)` wrapper — `withFoo(config, handler)` — that runs against the inbound `Request`, contributes a typed key to `ctx`, and either short-circuits with a `Response` or falls through to the inner handler. Stack them by direct nesting; the innermost handler sees a flat `ctx` aggregated from every wrapper around it. No registry, no `app.use()`, no separate composer. - -Each `withFoo(config, handler)` produces a single `(req, ctx) => Response` function, and **the outermost one is the `fetch` handler directly** — no wrapper. When the runtime invokes it, the middleware detects that the host's second argument is a platform value (Deno's connection info, a Workers `env`) rather than an upstream context and seeds `ctx._runtime` itself, so platform arguments never leak into `ctx`. The runtime is detected once, at module load. Because everything is plain Web Fetch, the same stack runs unchanged across Deno, Cloudflare Workers, Bun, and Node. +A **middleware** is a `withFoo` function. Call it with just the config — `withFoo(config)` — to get an **`Entry`**: a typed placeholder that carries the middleware's key, prerequisites, and contribution as phantom types. Pass a flat array of entries to `pipeline` with a final handler; `pipeline` folds the array into nested calls at runtime and every entry's contribution lands on `ctx` in order. No registry, no `app.use()`, no nesting. ```ts +import { pipeline } from '@supabase/web-middleware' +import { withCors } from '@supabase/web-middleware/cors' import { withFeatureFlag } from '@supabase/web-middleware/feature-flag' export default { - fetch: withFeatureFlag( - { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, - async (_req, ctx) => Response.json({ variant: ctx.featureFlag.variant }), + fetch: pipeline( + [ + withCors({}), + withFeatureFlag({ name: 'beta', evaluate: (req) => req.headers.has('x-beta') }), + ], + async (_req, ctx) => Response.json({ flag: ctx.featureFlag.name }), ), } ``` +`pipeline` returns the outermost `(req, ctx) => Response` — **that is the `fetch` handler directly**, no wrapper. When the runtime invokes it, the framework detects a platform argument (Deno's connection info, a Workers `env`) and seeds `ctx._runtime` itself, so platform values never leak into `ctx`. The runtime is detected once at module load. Because everything is plain Web Fetch, the same stack runs unchanged across Deno, Cloudflare Workers, Bun, and Node. + ## Install Not yet published to npm/JSR — install from git. The package builds itself on install via a `prepare` script: @@ -34,28 +39,23 @@ allowBuilds: ## What's in the box -| Import | What it does | -| --------------------------------------- | ------------------------------------------------------------------------------------------------ | -| `@supabase/web-middleware` | The `defineMiddleware` primitive + `Runtime` / `FetchHandler` / `Middleware` / `Conflict` types. | -| `@supabase/web-middleware/feature-flag` | Provider-agnostic feature flag — admit or short-circuit per request. | -| `@supabase/web-middleware/cors` | CORS — answers preflight and stamps response headers (the worked example of the response seam). | +| Import | What it does | +| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `@supabase/web-middleware` | `pipeline`, `defineMiddleware`, and the core types: `Entry`, `FetchHandler`, `Middleware`, `Conflict`, `Runtime`, `BaseContext`. | +| `@supabase/web-middleware/feature-flag` | Provider-agnostic feature flag — admit or short-circuit per request. | +| `@supabase/web-middleware/cors` | CORS — answers preflight and stamps response headers (the worked example of the response seam). | ## How it composes -Each middleware contributes one typed key to `ctx`. Nest them; the inner handler sees the union. Add `satisfies FetchHandler` on the outermost handler to anchor the types so the innermost handler sees **every** upstream key ambiently: +Each middleware contributes one typed key to `ctx`. Pass entries as a flat array to `pipeline` — first in the array runs first on the request. Add `satisfies FetchHandler` on the `pipeline` call to anchor the types so the handler sees **every** upstream key ambiently: ```ts -import { defineMiddleware } from '@supabase/web-middleware' +import { pipeline, defineMiddleware } from '@supabase/web-middleware' import type { FetchHandler } from '@supabase/web-middleware' import { withFeatureFlag } from '@supabase/web-middleware/feature-flag' // A middleware is just a `defineMiddleware` call — bundled or your own. -const withRequestId = defineMiddleware< - 'requestId', - void, - Record, - string ->({ +const withRequestId = defineMiddleware<'requestId', void, Record, string>({ key: 'requestId', run: () => async (req) => ({ requestId: req.headers.get('x-request-id') ?? crypto.randomUUID(), @@ -63,16 +63,17 @@ const withRequestId = defineMiddleware< }) export default { - fetch: withRequestId( - withFeatureFlag( - { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, - async (_req, ctx) => { - ctx.requestId // from withRequestId - ctx.featureFlag // from withFeatureFlag - ctx._runtime // seeded automatically — ctx._runtime.getEnv('…'), ctx._runtime.name - return new Response(null, { status: 200 }) - }, - ), + fetch: pipeline( + [ + withRequestId(), // no config — still returns an Entry + withFeatureFlag({ name: 'beta', evaluate: (req) => req.headers.has('x-beta') }), + ], + async (_req, ctx) => { + ctx.requestId // from withRequestId + ctx.featureFlag // from withFeatureFlag + ctx._runtime // seeded automatically — ctx._runtime.getEnv('…'), ctx._runtime.name + return new Response(null, { status: 200 }) + }, ) satisfies FetchHandler, } ``` diff --git a/src/core/README.md b/src/core/README.md index 808a48b..04c3cd7 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -14,17 +14,29 @@ The package root exports: ## Quick start (consumer) +Pass an array of entries to `pipeline` — first runs first on the request. +`ctx` is inferred from the array; no manual annotation is needed. + ```ts +import { pipeline } from '@supabase/web-middleware' +import { withCors } from '@supabase/web-middleware/cors' import { withFeatureFlag } from '@supabase/web-middleware/feature-flag' export default { - fetch: withFeatureFlag( - { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, - async (req, ctx) => Response.json({ variant: ctx.featureFlag.variant }), + fetch: pipeline( + [ + withCors({}), + withFeatureFlag({ name: 'beta', evaluate: (req) => req.headers.has('x-beta') }), + ], + async (req, ctx) => Response.json({ flag: ctx.featureFlag.name }), ), } ``` +Under the hood, `pipeline` folds the array into the same nested calls as +hand-writing `withCors({}, withFeatureFlag({…}, handler))` — there is no new +runtime behavior, just a flat readable form. + ## The `ctx` shape Inside a wrapped handler, `ctx` is a flat intersection — the framework seeds the one reserved `_runtime` facet, and each middleware contributes a typed key: @@ -74,22 +86,30 @@ run: (config) => This is the one place the request-side default is relaxed, and `function*` is the visible signal that a middleware reaches into the response. [`cors/`](../middleware/cors/) is the worked example — preflight before the `yield`, header stamping after. -## Threading state through nested middleware +## Threading state through the stack -When a middleware is wrapped by another, the outer's keys land on `Base` for the inner. TypeScript infers `Base` through the nested single-signature handlers — anchored at the top by `satisfies FetchHandler` — so the handler sees the full accumulated `ctx`: +Each middleware's contribution lands on `ctx` for every middleware and handler +inside it. With `pipeline`, this accumulation is typed from the array — add +`satisfies FetchHandler` on the outermost call to anchor ambient accumulation +and collision detection: ```ts +import { pipeline } from '@supabase/web-middleware' import type { FetchHandler } from '@supabase/web-middleware' +import { withFeatureFlag } from '@supabase/web-middleware/feature-flag' export default { - fetch: withFeatureFlag( - { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, - withMyMiddleware({ ... }, async (_req, ctx) => { + fetch: pipeline( + [ + withFeatureFlag({ name: 'beta', evaluate: (req) => req.headers.has('x-beta') }), + withMyMiddleware({ ... }), + ], + async (_req, ctx) => { ctx._runtime // seeded at the entry call ctx.featureFlag // from withFeatureFlag ctx.myMiddleware // from withMyMiddleware return Response.json({ ok: true }) - }), + }, ) satisfies FetchHandler, } ``` @@ -98,6 +118,8 @@ export default { | Export | Description | | ------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `pipeline(entries, handler)` | Compose a flat array of entries around a handler. Returns a `FetchHandler`. | +| `Entry` | Type produced by `mw(config)`. Carries phantom types for `pipeline`'s accumulation. | | `defineMiddleware(spec)` | Author helper: declare a middleware. Returns a `(config, handler)` callable. | | `FetchHandler` | Type-only anchor (`… satisfies FetchHandler`) for ambient accumulation + collision detection. | | `Conflict` | Sentinel string a middleware's `ctx` resolves to when it would shadow an upstream key. | diff --git a/src/core/define-middleware.test.ts b/src/core/define-middleware.test.ts index 1b1fc49..85c96fb 100644 --- a/src/core/define-middleware.test.ts +++ b/src/core/define-middleware.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from 'vitest' import { withFeatureFlag } from '../middleware/feature-flag/with-feature-flag.js' import { defineMiddleware } from './define-middleware.js' +import { pipeline } from './pipeline.js' import type { BaseContext, FetchHandler } from './runtime.js' +import type { Entry } from './types.js' const innerOk = async () => Response.json({ ok: true }) @@ -487,6 +489,106 @@ describe('defineMiddleware — generator (response seam)', () => { }) }) +describe('auto-curry: mw(config) returns an Entry', () => { + it('mw(config) returns an entry that pipelines correctly', async () => { + const withGreeting = defineMiddleware< + 'greeting', + { who: string }, + Record, + { hello: string } + >({ + key: 'greeting', + run: (config) => async () => ({ greeting: { hello: config.who } }), + }) + + const handler = pipeline( + [withGreeting({ who: 'world' })], + async (_req, ctx) => Response.json({ msg: ctx.greeting.hello }), + ) + + const res = await handler(new Request('http://localhost/')) + expect(await res.json()).toEqual({ msg: 'world' }) + }) + + it('mw(config) in pipeline produces the same result as direct nesting', async () => { + const withGreeting = defineMiddleware< + 'greeting', + { who: string }, + Record, + { hello: string } + >({ + key: 'greeting', + run: (config) => async () => ({ greeting: { hello: config.who } }), + }) + + const nested = withGreeting( + { who: 'world' }, + async (_req, ctx) => Response.json({ msg: ctx.greeting.hello }), + ) + const flat = pipeline( + [withGreeting({ who: 'world' })], + async (_req, ctx) => Response.json({ msg: ctx.greeting.hello }), + ) + + const [nestedRes, flatRes] = await Promise.all([ + nested(new Request('http://localhost/')), + flat(new Request('http://localhost/')), + ]) + expect(await nestedRes.json()).toEqual(await flatRes.json()) + }) + + it('mw() (no config) works for config-less middleware', async () => { + const withTag = passing('tag', { v: 'ok' }) + const handler = pipeline([withTag()], async (_req, ctx) => + Response.json({ v: ctx.tag.v }), + ) + const res = await handler(new Request('http://localhost/')) + expect(await res.json()).toEqual({ v: 'ok' }) + }) + + it('mw() (no config) in pipeline produces the same result as direct nesting', async () => { + const withTag = passing('tag', { v: 'ok' }) + + const nested = withTag(async (_req, ctx) => Response.json({ v: ctx.tag.v })) + const flat = pipeline( + [withTag()], + async (_req, ctx) => Response.json({ v: ctx.tag.v }), + ) + + const [nestedRes, flatRes] = await Promise.all([ + nested(new Request('http://localhost/')), + flat(new Request('http://localhost/')), + ]) + expect(await nestedRes.json()).toEqual(await flatRes.json()) + }) + + it('type guarantee: mw(config) satisfies Entry', () => { + const withGreeting = defineMiddleware< + 'greeting', + { who: string }, + Record, + { hello: string } + >({ + key: 'greeting', + run: (config) => async () => ({ greeting: { hello: config.who } }), + }) + + const _entry = withGreeting({ who: 'world' }) satisfies Entry< + 'greeting', + Record, + { hello: string } + > + void _entry + }) + + it('type guarantee: mw() satisfies Entry for config-less middleware', () => { + const withTag = passing('tag', { v: 'ok' }) + + const _entry = withTag() satisfies Entry<'tag', Record, { v: string }> + void _entry + }) +}) + // --------------------------------------------------------------------------- // Compile-time guarantee tests, verified by `tsc --noEmit` (the `typecheck` // script) — a regression is a type error or an unused-directive error. A plain diff --git a/src/core/define-middleware.ts b/src/core/define-middleware.ts index 63fb016..d1a31b4 100644 --- a/src/core/define-middleware.ts +++ b/src/core/define-middleware.ts @@ -1,4 +1,4 @@ -import type { Conflict } from './types.js' +import type { Conflict, ConfigArgs, Entry } from './types.js' import type { BaseContext } from './runtime.js' import { bufferRequest, isContext, seedContext } from './runtime.js' @@ -114,15 +114,22 @@ export function defineMiddleware< > }): Middleware { const callable = (...args: unknown[]) => { - // `config` is optional at the call site for config-less middleware - // (`withFoo(handler)`); a lone argument is the handler. Two arguments are - // always `(config, handler)` — so passing `config: undefined` explicitly - // still works, but is never required. + const lastArg = args[args.length - 1] + + // Config-only call — no args, or the last argument is not a function. + // Returns an Entry whose call carries the config into the handler so it + // can be passed to `pipeline` directly: `pipeline([withFoo(cfg)], handler)`. + if (args.length === 0 || typeof lastArg !== 'function') { + const config = (args.length > 0 ? args[0] : undefined) as Config + const wrap = (handler: (req: Request, ctx: object) => Promise) => + callable(config, handler) as unknown as (req: Request, ctx: object) => Promise + return wrap as Entry + } + + // Handler call — last arg is a function. + // For config-less middleware `withFoo(handler)` config stays undefined. const config = (args.length >= 2 ? args[0] : undefined) as Config - const handler = (args.length >= 2 ? args[1] : args[0]) as ( - req: Request, - ctx: object, - ) => Promise + const handler = lastArg as (req: Request, ctx: object) => Promise const inner = spec.run(config) return async (req: Request, maybeCtx?: object, ...rest: unknown[]) => { // A parent middleware passes a real context; the host passes a platform @@ -272,23 +279,38 @@ type MiddlewareArgs = undefined extends Config : [config: Config, handler: Handler] /** - * The shape of a middleware — a `(config, handler) => handler` callable that - * {@link defineMiddleware} produces (config-less middleware may call it as - * `(handler)`; see {@link MiddlewareArgs}). `Base` is constrained to - * `In & BaseContext & NoConflict` and defaults to `In & BaseContext`, - * which self-anchors the outermost handler without an entry wrapper. + * The shape of a middleware produced by {@link defineMiddleware}. + * + * Two call signatures: + * - **Config-only** — `mw(config)` (or `mw()` for config-less) returns an + * {@link Entry} for use in a {@link pipeline} array. + * - **Handler** — `mw(config, handler)` (or `mw(handler)`) returns the produced + * fetch handler directly, with `Base` inferred from the handler's `ctx` type. + * `Base` is constrained to `In & BaseContext & NoConflict` so both + * prerequisite enforcement and collision detection surface at the call site. */ -export type Middleware< +export interface Middleware< Key extends string, Config, In extends object, Contribution, -> = >( - ...args: MiddlewareArgs< - Config, - ( - req: Request, - ctx: Base & { [K in Key]: Contribution }, - ) => Promise - > -) => Produced +> { + // Handler call — listed first so TypeScript's bidirectional generic inference + // works correctly for nested calls (`withA(withB(handler))`). This is the + // same signature as the original type alias, so accumulation and collision + // detection are preserved unchanged. + >( + ...args: MiddlewareArgs< + Config, + ( + req: Request, + ctx: Base & { [K in Key]: Contribution }, + ) => Promise + > + ): Produced + // Config-only call — `mw(config)` (or `mw()` for config-less) returns an + // Entry for use in a `pipeline` array. Falls through from the handler overload + // because config-only calls either have the wrong arity (required-config mw) + // or pass a non-function (which doesn't match MiddlewareArgs' Handler slot). + (...args: ConfigArgs): Entry +} diff --git a/src/core/index.ts b/src/core/index.ts index 3443954..f435c55 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -15,6 +15,7 @@ export { defineMiddleware } from './define-middleware.js' export type { IsAny, Middleware, NoConflict } from './define-middleware.js' +export { pipeline } from './pipeline.js' export type { BaseContext, FetchHandler, @@ -22,4 +23,4 @@ export type { Runtime, RuntimeName, } from './runtime.js' -export type { Conflict } from './types.js' +export type { Conflict, Entry } from './types.js' diff --git a/src/core/pipeline.test.ts b/src/core/pipeline.test.ts new file mode 100644 index 0000000..e98cc41 --- /dev/null +++ b/src/core/pipeline.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it, vi } from 'vitest' + +import { defineMiddleware } from './define-middleware.js' +import type { BaseContext, FetchHandler } from './runtime.js' +import { pipeline } from './pipeline.js' + +const innerOk = async () => Response.json({ ok: true }) + +const runtime: BaseContext['_runtime'] = { name: 'node', getEnv: () => undefined } +void runtime + +const passing = (key: Key, contribution: C) => + defineMiddleware, C>({ + key, + run: () => async () => ({ [key]: contribution }) as { [K in Key]: C }, + }) + +const rejecting = (key: Key, status = 401) => + defineMiddleware, Record>({ + key, + run: () => async () => new Response(`rejected by ${key}`, { status }), + }) + +describe('pipeline', () => { + it('composes entries in order, contributes keys, self-seeds ctx._runtime', async () => { + const withA = passing('alpha', { v: 1 }) + const withB = passing('beta', { v: 2 }) + + const handler = pipeline( + [withA(), withB()], + async (_req, ctx) => + Response.json({ alpha: ctx.alpha.v, beta: ctx.beta.v, host: ctx._runtime.name }), + ) + + const res = await handler(new Request('http://localhost/')) + expect(await res.json()).toEqual({ alpha: 1, beta: 2, host: 'node' }) + }) + + it('pre-applies required config', async () => { + const withGreeting = defineMiddleware< + 'greeting', + { who: string }, + Record, + string + >({ + key: 'greeting', + run: (config) => async () => ({ greeting: config.who }), + }) + + const handler = pipeline( + [withGreeting({ who: 'world' })], + async (_req, ctx) => Response.json({ msg: ctx.greeting }), + ) + + const res = await handler(new Request('http://localhost/')) + expect(await res.json()).toEqual({ msg: 'world' }) + }) + + it('is equivalent to hand-nesting (same response)', async () => { + const withA = passing('alpha', { v: 1 }) + const withB = passing('beta', { v: 2 }) + + const nestedHandler = withA( + withB(async (_req, ctx) => + Response.json({ alpha: ctx.alpha.v, beta: ctx.beta.v }), + ), + ) satisfies FetchHandler + + const flatHandler = pipeline( + [withA(), withB()], + async (_req, ctx) => Response.json({ alpha: ctx.alpha.v, beta: ctx.beta.v }), + ) + + const [nestedRes, flatRes] = await Promise.all([ + nestedHandler(new Request('http://localhost/')), + flatHandler(new Request('http://localhost/')), + ]) + expect(await nestedRes.json()).toEqual(await flatRes.json()) + }) + + it('short-circuits on reject without calling the inner handler', async () => { + const inner = vi.fn(innerOk) + const handler = pipeline([rejecting('blocker', 402)()], inner) + + const res = await handler(new Request('http://localhost/')) + expect(res.status).toBe(402) + expect(await res.text()).toBe('rejected by blocker') + expect(inner).not.toHaveBeenCalled() + }) + + it('uses middleware whose prerequisites are provided by an earlier entry', async () => { + const withAuth = passing('auth', { userId: 'u1' }) + const withProfile = defineMiddleware< + 'profile', + void, + { auth: { userId: string } }, + { displayName: string } + >({ + key: 'profile', + run: () => async (_req, ctx) => ({ + profile: { displayName: `user:${ctx.auth.userId}` }, + }), + }) + + const handler = pipeline( + [withAuth(), withProfile()], + async (_req, ctx) => Response.json({ name: ctx.profile.displayName }), + ) + + const res = await handler(new Request('http://localhost/')) + expect(await res.json()).toEqual({ name: 'user:u1' }) + }) + + it('still drives a generator entry through the response seam', async () => { + // A regression guard for the flat syntax: an `async function*` middleware + // placed in a pipeline array must still observe and shape the downstream + // Response, exactly as it would hand-nested. + const withStamp = defineMiddleware< + 'stamp', + { header: string }, + Record, + { at: string } + >({ + key: 'stamp', + run: (config) => + async function* () { + const response = yield { stamp: { at: 'before' } } + response.headers.set(config.header, 'seen') + return response + }, + }) + + const handler = pipeline( + [withStamp({ header: 'x-stamp' })], + async (_req, ctx) => Response.json({ at: ctx.stamp.at }), + ) + + const res = await handler(new Request('http://localhost/')) + expect(res.headers.get('x-stamp')).toBe('seen') + expect(await res.json()).toEqual({ at: 'before' }) + }) +}) + +// --------------------------------------------------------------------------- +// Compile-time guarantees — verified by `tsc --noEmit`. A regression is a +// type error or an unused-directive error. A plain vitest run cannot see these. +// --------------------------------------------------------------------------- +describe('type guarantees (tsc-verified)', () => { + it('ctx accumulation: all contributed keys are typed in the handler', () => { + const withA = passing('alpha', { v: 1 }) + const withB = passing('beta', { v: 2 }) + + const _app = pipeline( + [withA(), withB()], + async (_req, ctx) => { + const a: number = ctx.alpha.v + const b: number = ctx.beta.v + const host: string = ctx._runtime.name + void a + void b + void host + return Response.json({ ok: true }) + }, + ) satisfies FetchHandler + void _app + }) + + it('collision: duplicate key in pipeline fails to compile', () => { + const withFoo = passing('foo', { v: 1 }) + + const _bad = pipeline( + [withFoo(), withFoo()], + // @ts-expect-error — duplicate key 'foo': Validate fires Conflict<'foo'> + async () => Response.json({ ok: true }), + ) + void _bad + }) + + it('prerequisite: wrong ordering (prereq not yet provided) fails to compile', () => { + const withNeedsAuth = defineMiddleware< + 'profile', + void, + { auth: { userId: string } }, + { displayName: string } + >({ + key: 'profile', + run: () => async () => ({ profile: { displayName: 'x' } }), + }) + const withAuth = passing('auth', { userId: 'u1' }) + + const _bad = pipeline( + [withNeedsAuth(), withAuth()], // profile before auth — wrong order + // @ts-expect-error — prereq 'auth' is not yet on the context + async () => Response.json({ ok: true }), + ) + void _bad + }) + + it('does not let a host-supplied env (arg 2) leak into ctx', async () => { + const withA = passing('a', { v: 1 }) + const handler = pipeline([withA()], async (_req, ctx) => + Response.json({ keys: Object.keys(ctx) }), + ) + + const res = await ( + handler as (req: Request, ...a: unknown[]) => Promise + )(new Request('http://localhost/'), { SECRET: 's' }) + expect(await res.json()).toEqual({ keys: ['_runtime', 'a'] }) + }) +}) diff --git a/src/core/pipeline.ts b/src/core/pipeline.ts new file mode 100644 index 0000000..328f62c --- /dev/null +++ b/src/core/pipeline.ts @@ -0,0 +1,97 @@ +/** + * Flat-array composition — the recommended consumer API for stacking middleware. + * + * Instead of nesting (`withA(config, withB(config, withC(handler)))`), write: + * + * ```ts + * pipeline( + * [withA(config), withB(config), withC()], + * async (req, ctx) => { … }, // ctx.a, ctx.b, ctx.c all inferred + * ) + * ``` + * + * At runtime `pipeline` folds the array back into the same nested calls, so + * behavior is identical to hand-nesting. The type-level benefits over nesting: + * `ctx` is accumulated across the array (no manual annotation), and duplicate + * keys / out-of-order prerequisites fail to compile with a descriptive message. + * + * @packageDocumentation + */ + +import type { IsAny } from './define-middleware.js' +import type { BaseContext, FetchHandler } from './runtime.js' +import type { Conflict, Entry } from './types.js' + +type AnyHandler = (req: Request, ctx: object) => Promise +type AnyEntry = Entry + +/** Fold a tuple of entries onto `Ctx`, accumulating each contribution in order. */ +type Accumulate = Entries extends readonly [ + Entry, + ...infer Rest, +] + ? Rest extends readonly AnyEntry[] + ? Accumulate + : Ctx + : Ctx + +/** + * Validate a tuple of entries in order: each entry's prerequisites must be + * present on the accumulated context, and its key must not already be there. + * Returns `true` when the whole chain is valid, or a descriptive error string + * naming the offending key. + * + * Applied to the **handler** parameter (not `entries`), so it never disrupts + * `const Entries` tuple inference. + */ +type Validate = + Entries extends readonly [Entry, ...infer Rest] + ? IsAny extends true + ? Validate + : Key extends keyof Ctx + ? Conflict + : keyof In extends keyof Ctx + ? Validate + : `middleware-prereq: key '${Extract, string>}' is not yet on the context (check ordering)` + : true + +/** + * Compose a flat list of middleware entries around a handler (first = outermost, + * runs first on the request). Returns a {@link FetchHandler} ready for + * `export default { fetch: … }`. + * + * The handler's `ctx` is inferred as {@link BaseContext} plus every entry's + * contribution — no manual annotation. Ordering and collision errors surface on + * the **handler** argument so they don't break tuple inference on `entries`. + * + * Under the hood, `pipeline` folds the array into the same nested calls as + * hand-nesting — there is no new runtime behavior. + * + * @example + * ```ts + * import { pipeline } from '@supabase/web-middleware' + * import { withCors } from '@supabase/web-middleware/cors' + * import { withFeatureFlag } from '@supabase/web-middleware/feature-flag' + * + * export default { + * fetch: pipeline( + * [ + * withCors({}), + * withFeatureFlag({ name: 'beta', evaluate: (req) => req.headers.has('x-beta') }), + * ], + * async (req, ctx) => Response.json({ flag: ctx.featureFlag.name }), + * ), + * } + * ``` + */ +export function pipeline( + entries: Entries, + handler: [Validate] extends [true] + ? (req: Request, ctx: Accumulate) => Promise + : Validate, +): FetchHandler { + return (entries as readonly AnyEntry[]).reduceRight( + (h, entry) => entry(h), + handler as AnyHandler, + ) as unknown as FetchHandler +} diff --git a/src/core/types.ts b/src/core/types.ts index 2bf8c6a..18a7507 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -13,3 +13,27 @@ */ export type Conflict = `middleware-conflict: key '${Key}' is already present on the upstream context` + +/** + * Config arg is optional exactly when the middleware's Config admits `undefined`. + * Used by both `defineMiddleware` (to type the config-only overload) and + * `pipeline` (internally). + */ +export type ConfigArgs = undefined extends Config ? [config?: Config] : [config: Config] + +type AnyFetchHandler = (req: Request, ctx: object) => Promise + +/** + * A middleware with its config pre-applied, ready to be passed to {@link pipeline}. + * Carries phantom type parameters so `pipeline` can accumulate each entry's + * contribution onto the handler's `ctx` without requiring a manual annotation. + * + * Produced by calling a middleware with config only — `withFoo(config)` — or + * with no args for config-less middleware — `withFoo()`. + */ +export interface Entry { + (handler: AnyFetchHandler): AnyFetchHandler + readonly __key?: Key + readonly __in?: In + readonly __contribution?: Contribution +} diff --git a/src/exports.test.ts b/src/exports.test.ts index 0f71d79..91b6172 100644 --- a/src/exports.test.ts +++ b/src/exports.test.ts @@ -12,11 +12,11 @@ import * as featureFlag from './middleware/feature-flag/index.js' */ describe('public API surface', () => { it('package root', () => { - expect(Object.keys(root).sort()).toEqual(['defineMiddleware']) + expect(Object.keys(root).sort()).toEqual(['defineMiddleware', 'pipeline']) }) it('core subpath', () => { - expect(Object.keys(core).sort()).toEqual(['defineMiddleware']) + expect(Object.keys(core).sort()).toEqual(['defineMiddleware', 'pipeline']) }) it('middleware subpaths', () => { diff --git a/src/index.ts b/src/index.ts index e71697e..019a8a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export { defineMiddleware } from './core/define-middleware.js' export type { IsAny, Middleware, NoConflict } from './core/define-middleware.js' +export { pipeline } from './core/pipeline.js' export type { BaseContext, FetchHandler, @@ -19,4 +20,4 @@ export type { Runtime, RuntimeName, } from './core/runtime.js' -export type { Conflict } from './core/types.js' +export type { Conflict, Entry } from './core/types.js'