Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 30 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -34,45 +39,41 @@ 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<never, never>,
string
>({
const withRequestId = defineMiddleware<'requestId', void, Record<never, never>, string>({
key: 'requestId',
run: () => async (req) => ({
requestId: req.headers.get('x-request-id') ?? crypto.randomUUID(),
}),
})

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,
}
```
Expand Down
40 changes: 31 additions & 9 deletions src/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
}
```
Expand All @@ -98,6 +118,8 @@ export default {

| Export | Description |
| ------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `pipeline(entries, handler)` | Compose a flat array of entries around a handler. Returns a `FetchHandler`. |
| `Entry<Key, In, Contribution>` | 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<Key>` | Sentinel string a middleware's `ctx` resolves to when it would shadow an upstream key. |
Expand Down
102 changes: 102 additions & 0 deletions src/core/define-middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand Down Expand Up @@ -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<never, never>,
{ 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<never, never>,
{ 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<never, never>,
{ hello: string }
>({
key: 'greeting',
run: (config) => async () => ({ greeting: { hello: config.who } }),
})

const _entry = withGreeting({ who: 'world' }) satisfies Entry<
'greeting',
Record<never, never>,
{ 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<never, never>, { 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
Expand Down
70 changes: 46 additions & 24 deletions src/core/define-middleware.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -114,15 +114,22 @@ export function defineMiddleware<
>
}): Middleware<Key, Config, In, Contribution> {
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<Response>) =>
callable(config, handler) as unknown as (req: Request, ctx: object) => Promise<Response>
return wrap as Entry<Key, In, Contribution>
}

// 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<Response>
const handler = lastArg as (req: Request, ctx: object) => Promise<Response>
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
Expand Down Expand Up @@ -272,23 +279,38 @@ type MiddlewareArgs<Config, Handler> = 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<Key, Base>` 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<Key, Base>` 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,
> = <Base extends In & BaseContext & NoConflict<Key, Base>>(
...args: MiddlewareArgs<
Config,
(
req: Request,
ctx: Base & { [K in Key]: Contribution },
) => Promise<Response>
>
) => Produced<Base, In>
> {
// 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.
<Base extends In & BaseContext & NoConflict<Key, Base>>(
...args: MiddlewareArgs<
Config,
(
req: Request,
ctx: Base & { [K in Key]: Contribution },
) => Promise<Response>
>
): Produced<Base, In>
// 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<Config>): Entry<Key, In, Contribution>
}
3 changes: 2 additions & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@

export { defineMiddleware } from './define-middleware.js'
export type { IsAny, Middleware, NoConflict } from './define-middleware.js'
export { pipeline } from './pipeline.js'
export type {
BaseContext,
FetchHandler,
Handler,
Runtime,
RuntimeName,
} from './runtime.js'
export type { Conflict } from './types.js'
export type { Conflict, Entry } from './types.js'
Loading
Loading