From e4d9b0b2fd67a2ef95c5a728eccb56023657f929 Mon Sep 17 00:00:00 2001 From: Tomas Pozo Date: Thu, 6 Aug 2026 21:52:02 -0500 Subject: [PATCH 1/3] docs: agent-first "build your own middleware" authoring guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/authoring-guide.md — the full third-party authoring path, from defineMiddleware through publishing to composing a third-party middleware in the same pipeline array as the first-party entries. Written for coding agents as much as people: every code block is a complete file labeled with its path, so it can be written to disk and compiled with nothing inferred. Closes with a numbered MUST/NEVER block. The running example, withValidatedBody, is deliberately shaped like the shipped withFeatureFlag (validate mirrors evaluate, 400 mirrors 404), so its code follows verified first-party structure while contributing its own key — which keeps the composition and accumulation examples real rather than hypothetical. Two variants cover `In` prerequisites and the async function* response seam. Every example was typechecked and tested against the real package while writing, and the documented failure strings were confirmed by triggering them. Nothing new runs in CI. Splits the two audiences that src/middleware/README.md previously mixed: it now covers only adding a built-in to this repository, and third-party authoring lives in the new guide. Repoints the cross-links that referred to the old location. --- CONTRIBUTING.md | 4 +- README.md | 2 +- docs/authoring-guide.md | 606 ++++++++++++++++++ src/core/README.md | 4 +- src/middleware/README.md | 138 ++-- src/middleware/cors/README.md | 2 +- src/middleware/feature-flag/README.md | 4 +- .../feature-flag/with-feature-flag.ts | 2 +- typedoc.json | 1 + 9 files changed, 658 insertions(+), 105 deletions(-) create mode 100644 docs/authoring-guide.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15842f6..842ff32 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,7 +119,9 @@ root README in the same commit. ## Writing a middleware -The composition primitives (`ctx` shape, conflict & prerequisite enforcement, the response seam) are documented in [`src/core/README.md`](./src/core/README.md). The authoring guide for `defineMiddleware` — request-side and generator forms — is in [`src/middleware/README.md`](./src/middleware/README.md), with [`feature-flag`](./src/middleware/feature-flag/README.md) and [`cors`](./src/middleware/cors/README.md) as worked examples. +The full authoring guide — `defineMiddleware`, request-side and generator forms, tests, publishing, and composing alongside first-party entries — is in [`docs/authoring-guide.md`](./docs/authoring-guide.md). The composition primitives (`ctx` shape, conflict & prerequisite enforcement, the response seam) are documented in [`src/core/README.md`](./src/core/README.md), with [`feature-flag`](./src/middleware/feature-flag/README.md) and [`cors`](./src/middleware/cors/README.md) as worked examples. + +To add a middleware **to this repository** (rather than publish your own package), see [`src/middleware/README.md`](./src/middleware/README.md) for the directory layout and subpath wiring. ## Submitting Changes diff --git a/README.md b/README.md index 2037a55..bc7cdcc 100644 --- a/README.md +++ b/README.md @@ -175,8 +175,8 @@ This is the **one** place the "request-side" guarantee is relaxed, and writing ` ## Docs +- [Authoring guide](./docs/authoring-guide.md) — **build your own middleware**: `defineMiddleware`, tests, publishing, and composing it in the same `pipeline` array as the first-party entries. - [Composition primitives](./src/core/README.md) — `ctx` shape, conflict & prerequisite enforcement, composition rules, the response seam. -- [Authoring guide](./src/middleware/README.md) — write your own middleware with `defineMiddleware` (request-side and generator forms). - Per-middleware: [feature-flag](./src/middleware/feature-flag/README.md) — the request-side worked example · [cors](./src/middleware/cors/README.md) — the response-seam worked example. Full generated API reference: [supabase.github.io/middleware](https://supabase.github.io/middleware/). diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md new file mode 100644 index 0000000..b991f33 --- /dev/null +++ b/docs/authoring-guide.md @@ -0,0 +1,606 @@ +--- +title: Build your own middleware +--- + +# Build your own middleware + +This guide walks the full path: from `defineMiddleware` to publishing your own +package, to composing it in the same `pipeline` array as the first-party +entries. Every code block below is a **complete file** with its path in the +first line — write it to that path and it compiles. Nothing is elided. + +The example is `withValidatedBody`, a middleware that validates a JSON request +body and short-circuits with `400` when it fails. It is deliberately shaped like +the first-party [`withFeatureFlag`](../src/middleware/feature-flag/with-feature-flag.ts), +so anything you read here transfers to the shipped source and back. + +## 0. The destination + +This is where you end up — your middleware sitting alongside first-party ones in +a single flat array, every contribution typed on `ctx`: + +```ts +pipeline( + [withCors({}), withFeatureFlag({ ... }), withValidatedBody({ ... })], + async (_req, ctx) => Response.json({ data: ctx.validatedBody.data }), +) +``` + +There is no registry to join and no plugin interface to implement. A middleware +is a function produced by `defineMiddleware`; first-party and third-party +middleware are the same kind of thing, built with the same primitive. + +### Which form do you need? + +Write a plain `async` `run`. It executes **before** the handler and never sees +the handler's `Response`, which keeps response shape under a single owner. + +Reach for the generator form (`async function*`, covered at the end) only when a +concern is genuinely two-sided — stamping headers on the way out, timing, +request-spanning cleanup. If you are only _producing_ a response, do it in the +handler instead. + +## 1. The middleware + +`defineMiddleware` takes four type parameters and a spec of `{ key, run }`: + +| Parameter | What it is | Here | +| -------------- | ----------------------------------------------- | --------------------------- | +| `Key` | The literal-string slot contributed to `ctx` | `'validatedBody'` | +| `Config` | What the consumer passes to `withValidatedBody` | `WithValidatedBodyConfig` | +| `In` | Upstream keys required before this runs | `Record` | +| `Contribution` | The shape that lands at `ctx[Key]` | `ValidatedBodyContribution` | + +`run` has two stages. The outer `(config) =>` runs **once**, when the consumer +constructs the middleware — initialize clients and computed config there. The +inner `(req, ctx) =>` runs **per request**, and returns either a `Response` +(short-circuit; the handler never runs) or a single-key object +`{ [key]: contribution }` (fall through). + +````ts +// src/with-validated-body.ts +import { defineMiddleware } from '@supabase/middleware' +import type { Middleware } from '@supabase/middleware' + +/** Per-instance configuration for {@link withValidatedBody}. */ +export interface WithValidatedBodyConfig { + /** + * Decide whether the parsed JSON body is acceptable. Return `true`/`false` + * for a plain check, or a {@link ValidationVerdict} to also normalize the + * data or report errors. Async is fine — use any validator you like. + */ + validate: ( + body: unknown, + req: Request, + ) => Promise | boolean | ValidationVerdict + + /** HTTP status when validation fails. @defaultValue `400` */ + rejectStatus?: number + + /** Body when validation fails. @defaultValue `{ error: 'invalid_body', errors }` */ + rejectBody?: unknown +} + +/** Richer return shape `validate` may produce in place of a plain boolean. */ +export interface ValidationVerdict { + /** Whether the body is acceptable. */ + valid: boolean + /** Normalized data to expose downstream. Defaults to the parsed body. */ + data?: unknown + /** Messages included in the default rejection body. */ + errors?: string[] +} + +/** + * Shape contributed at `ctx.validatedBody` after a successful validation. + * + * `valid: true` is encoded in the type — the handler only ever sees this shape + * when validation passed, so `if (!ctx.validatedBody.valid)` is a dead branch + * by construction. + */ +export interface ValidatedBodyContribution { + /** Always `true` — this shape is only produced on success. */ + valid: true + /** The validated body: the verdict's `data`, or the parsed body. */ + data: unknown +} + +/** + * Validate a JSON request body before the handler runs. + * + * @example + * ```ts + * withValidatedBody( + * { validate: (body) => typeof body === 'object' && body !== null }, + * async (_req, ctx) => Response.json({ received: ctx.validatedBody.data }), + * ) + * ``` + */ +export const withValidatedBody: Middleware< + 'validatedBody', + WithValidatedBodyConfig, + Record, + ValidatedBodyContribution +> = defineMiddleware< + // 1. Key — the slot this contributes to `ctx`. Must be unique in a stack. + 'validatedBody', + // 2. Config — what the consumer passes to `withValidatedBody(config, handler)`. + WithValidatedBodyConfig, + // 3. In — upstream prerequisites. `Record` = none, so this can + // be used standalone or anywhere in a stack. + Record, + // 4. Contribution — the shape that lands at `ctx.validatedBody`. + ValidatedBodyContribution +>({ + key: 'validatedBody', + run: (config) => async (req) => { + const reject = (errors: string[]) => + Response.json(config.rejectBody ?? { error: 'invalid_body', errors }, { + status: config.rejectStatus ?? 400, + }) + + // Reading the body here does not consume it: the framework hands every + // layer a buffered request, so the handler can read it again. + let body: unknown + try { + body = await req.json() + } catch { + return reject(['body is not valid JSON']) + } + + const result = await config.validate(body, req) + const verdict: ValidationVerdict = + typeof result === 'boolean' ? { valid: result } : result + + if (!verdict.valid) { + // Short-circuit: return a Response and the handler never runs. + return reject(verdict.errors ?? []) + } + + // Contribute: fall through with this shape on `ctx.validatedBody`. + return { validatedBody: { valid: true, data: verdict.data ?? body } } + }, +}) +```` + +Four things in that file are worth calling out. + +**The body stays readable.** A Fetch `Request` body is normally a single-use +stream, so reading it here would lock out the handler. It does not: the +framework hands every layer a buffered request that caches the body after the +first read, so your middleware and the handler can both read it, in any form +(`text`, `json`, `arrayBuffer`, `bytes`, `blob`, `formData`). The one deliberate +limit is that reading the raw `req.body` **stream** bypasses the cache — to +forward a body onward, reconstruct it from `await req.arrayBuffer()`. + +**The explicit `Middleware<…>` annotation is not optional ceremony.** It is what +lets the package publish to JSR, which rejects inferred public types. + +**`data` is `unknown` on purpose,** because this example accepts any validator. +A middleware written for one domain should make its contribution concrete +instead — that is what the first-party middleware do, and it is what makes +`ctx.yourKey` genuinely useful to a handler without a cast. + +**Explicit reject config beats a thrown error.** Returning a `Response` is not +an error path — it can carry any status. Errors that escape `run` propagate to +the host, so handle what you can describe. + +## 2. Public exports + +```ts +// src/index.ts +export { withValidatedBody } from './with-validated-body.js' +export type { + WithValidatedBodyConfig, + ValidationVerdict, + ValidatedBodyContribution, +} from './with-validated-body.js' + +// Re-exported so consumers can write `satisfies FetchHandler` with one import. +export type { FetchHandler } from '@supabase/middleware' +``` + +Export the config and contribution interfaces alongside the middleware — +consumers need them to type their own wrappers. + +## 3. Tests + +Cover both `run` outcomes, the request passthrough, and the body-reread +guarantee. Use `vi.fn` for the inner handler when you need to assert it was, or +was not, called. + +```ts +// src/with-validated-body.test.ts +import { describe, expect, it, vi } from 'vitest' + +import { withValidatedBody, type FetchHandler } from './index.js' + +const post = (body: unknown) => + new Request('http://localhost/', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + +// Type-level check, verified by `tsc`: the composed stack is a fetch entry. +const _anchored = withValidatedBody( + { validate: () => true }, + async (_req, ctx) => Response.json({ data: ctx.validatedBody.data }), +) satisfies FetchHandler +void _anchored + +describe('withValidatedBody', () => { + it('contributes the validated body when validate passes', async () => { + const inner = vi.fn(async (_req: Request, ctx) => { + expect(ctx.validatedBody).toEqual({ valid: true, data: { name: 'ada' } }) + return Response.json({ ok: true }) + }) + + const handler = withValidatedBody({ validate: () => true }, inner) + + const res = await handler(post({ name: 'ada' })) + expect(res.status).toBe(200) + expect(inner).toHaveBeenCalledOnce() + }) + + it('short-circuits with 400 without calling the handler', async () => { + const inner = vi.fn(async () => Response.json({ ok: true })) + + const handler = withValidatedBody( + { validate: () => ({ valid: false, errors: ['name is required'] }) }, + inner, + ) + + const res = await handler(post({})) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ + error: 'invalid_body', + errors: ['name is required'], + }) + expect(inner).not.toHaveBeenCalled() + }) + + it('rejects a body that is not valid JSON', async () => { + const handler = withValidatedBody({ validate: () => true }, async () => + Response.json({ ok: true }), + ) + + const res = await handler( + new Request('http://localhost/', { method: 'POST', body: 'not json' }), + ) + expect(res.status).toBe(400) + }) + + it('exposes normalized data from a verdict', async () => { + const handler = withValidatedBody( + { validate: () => ({ valid: true, data: { name: 'ADA' } }) }, + async (_req, ctx) => Response.json(ctx.validatedBody.data), + ) + + const res = await handler(post({ name: 'ada' })) + expect(await res.json()).toEqual({ name: 'ADA' }) + }) + + it('leaves the body readable by the handler', async () => { + const handler = withValidatedBody({ validate: () => true }, async (req) => { + // The middleware already read the body; this read still works. + const again = await req.json() + return Response.json({ again }) + }) + + const res = await handler(post({ name: 'ada' })) + expect(await res.json()).toEqual({ again: { name: 'ada' } }) + }) + + it('honors a custom rejectStatus and rejectBody', async () => { + const handler = withValidatedBody( + { + validate: () => false, + rejectStatus: 422, + rejectBody: { code: 'UNPROCESSABLE' }, + }, + async () => Response.json({ ok: true }), + ) + + const res = await handler(post({})) + expect(res.status).toBe(422) + expect(await res.json()).toEqual({ code: 'UNPROCESSABLE' }) + }) + + it('supports async validators', async () => { + const handler = withValidatedBody( + { + validate: async () => { + await new Promise((r) => setTimeout(r, 1)) + return true + }, + }, + async (_req, ctx) => Response.json(ctx.validatedBody.data), + ) + + const res = await handler(post({ name: 'ada' })) + expect(res.status).toBe(200) + }) +}) +``` + +No test harness is needed. A composed middleware is just a +`(req, ctx?) => Promise`, so you call it with a `Request` and assert on +the `Response`. + +## 4. The package + +```json +{ + "name": "@acme/middleware-validated-body", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": ["dist"], + "sideEffects": false, + "engines": { "node": ">=22" }, + "scripts": { + "build": "tsdown", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@supabase/middleware": "^0.1.0" + }, + "devDependencies": { + "tsdown": "^0.20.3", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} +``` + +**Depend on `@supabase/middleware` normally — it does not need to be a peer +dependency.** Contexts are marked with a `Symbol.for` key from the global symbol +registry, so two copies of the package loaded side by side still recognize each +other's contexts. A version skew between your middleware and the consumer's is +not a correctness problem. + +## 5. Compose it with first-party middleware + +```ts +// server.ts +import { pipeline } from '@supabase/middleware' +import type { FetchHandler } from '@supabase/middleware' +import { withCors } from '@supabase/middleware/cors' +import { withFeatureFlag } from '@supabase/middleware/feature-flag' +import { withValidatedBody } from '@acme/middleware-validated-body' + +export default { + fetch: pipeline( + [ + withCors({ origin: ['https://app.example.com'] }), + withFeatureFlag({ + name: 'beta-api', + evaluate: (req) => req.headers.get('x-beta') === '1', + }), + withValidatedBody({ + validate: (body) => + typeof body === 'object' && body !== null && 'name' in body, + }), + ], + async (_req, ctx) => { + ctx.cors // from withCors — first-party + ctx.featureFlag // from withFeatureFlag — first-party + ctx.validatedBody // from withValidatedBody — yours + + return Response.json({ + flag: ctx.featureFlag.name, + data: ctx.validatedBody.data, + }) + }, + ) satisfies FetchHandler, +} +``` + +First in the array runs first on the request. `pipeline` returns the outermost +`(req, ctx) => Response` — that **is** the `fetch` handler, with no wrapper +around it. + +`satisfies FetchHandler` is a type-only anchor that adds no runtime code. It +turns on ambient accumulation, so the handler sees every upstream key, and it +turns on collision detection. Duplicating a key fails to compile with +`middleware-conflict: key '…' is already present on the upstream context`. + +## Variant: requiring an upstream key + +Set `In` when your middleware needs a key another middleware contributes. This +is a compile-time contract, not a runtime check. + +```ts +// src/with-audit-log.ts +import { defineMiddleware } from '@supabase/middleware' +import type { Middleware } from '@supabase/middleware' + +import type { ValidatedBodyContribution } from './with-validated-body.js' + +/** Upstream keys this middleware requires. */ +export interface WithAuditLogIn { + validatedBody: ValidatedBodyContribution +} + +/** Per-instance configuration for {@link withAuditLog}. */ +export interface WithAuditLogConfig { + /** Called once per request with the already-validated body. */ + record: (entry: { url: string; data: unknown }) => Promise | void +} + +/** Shape contributed at `ctx.auditLog`. */ +export interface AuditLogContribution { + /** Whether the entry was recorded. */ + recorded: boolean +} + +/** + * Records an audit entry from the validated body. + * + * Declares `validatedBody` as a prerequisite, so it can only compose after a + * middleware that provides it. Placing it earlier fails to compile. + */ +export const withAuditLog: Middleware< + 'auditLog', + WithAuditLogConfig, + WithAuditLogIn, + AuditLogContribution +> = defineMiddleware< + 'auditLog', + WithAuditLogConfig, + // In — the upstream shape this middleware requires. Not a runtime check: + // composing without `validatedBody` is a type error at the call site. + WithAuditLogIn, + AuditLogContribution +>({ + key: 'auditLog', + run: (config) => async (req, ctx) => { + // `ctx.validatedBody` is typed here because it is declared in `In`. + await config.record({ url: req.url, data: ctx.validatedBody.data }) + return { auditLog: { recorded: true } } + }, +}) +``` + +Composed in the right order it just works, and needs no anchor — +prerequisite-declared keys type on their own: + +```ts +pipeline( + [ + withValidatedBody({ validate: () => true }), + withAuditLog({ record: (entry) => console.log(entry) }), + ], + async (_req, ctx) => Response.json({ recorded: ctx.auditLog.recorded }), +) +``` + +Reverse those two entries and compilation fails with +`middleware-prereq: key 'validatedBody' is not yet on the context (check ordering)`. + +A middleware with prerequisites also cannot stand alone as a `fetch` entry. You +can still construct it, but its `ctx` is required rather than optional, so +`satisfies FetchHandler` fails and calling it with a request alone is an +arity error. The prerequisite can never become a lie at the top level. + +## Variant: the response seam + +When a concern is genuinely two-sided, write `run` as an `async function*`. +`yield` is the seam: code before it is the request phase, the `yield` expression +resolves to the downstream `Response`, and code after it is the response phase. + +```ts +// src/with-timing.ts +import { defineMiddleware } from '@supabase/middleware' +import type { Middleware } from '@supabase/middleware' + +/** Per-instance configuration for {@link withTiming}. */ +export interface WithTimingConfig { + /** Metric name used in the `Server-Timing` header. @defaultValue `'total'` */ + metric?: string +} + +/** Shape contributed at `ctx.timing`. */ +export interface TimingContribution { + /** When the request entered this middleware, from `performance.now()`. */ + startedAt: number +} + +/** + * Times the request and stamps a `Server-Timing` header on the way out. + * + * Genuinely two-sided, so `run` is an `async function*`: code before the + * `yield` is the request phase, the `yield` expression resolves to the + * downstream `Response`, and code after it is the response phase. + */ +export const withTiming: Middleware< + 'timing', + WithTimingConfig | undefined, + Record, + TimingContribution +> = defineMiddleware< + 'timing', + WithTimingConfig | undefined, + Record, + TimingContribution +>({ + key: 'timing', + run: (config) => + async function* () { + const metric = config?.metric ?? 'total' + const startedAt = performance.now() // request phase + + // Contribute, then suspend. The rest of the stack runs. + const response = yield { timing: { startedAt } } + + // Response phase. Copy the headers so an immutable response is handled. + const headers = new Headers(response.headers) + headers.append( + 'Server-Timing', + `${metric};dur=${(performance.now() - startedAt).toFixed(1)}`, + ) + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) + }, +}) +``` + +Typing `Config` as `WithTimingConfig | undefined` is what makes the config +argument optional, so consumers can write `withTiming()` as well as +`withTiming({ metric: 'api' })`. + +Rules for the seam: + +- `yield` the contribution **at most once**. `yield` always means "run + downstream and hand me the response." +- To short-circuit, `return new Response(...)` — the same as the request-side + path. There is then no response phase to reach. +- `try { … yield … } finally { … }` runs cleanup even when something downstream + throws. A `try`/`catch` around the `yield` can turn a downstream throw into a + `Response`. +- Returning nothing passes the downstream response through untouched. + +The runtime picks the path from what the body returns, so the plain `async` case +is unaffected. [`withCors`](../src/middleware/cors/with-cors.ts) is the +first-party worked example: it answers preflight with a `return` before the +`yield`, and stamps headers after. + +## Rules + +1. **MUST** contribute exactly one key. A middleware that wants two slots is + doing too much — split it. +2. **MUST** read configuration through `getEnv` from `@supabase/middleware`. + **NEVER** touch `process.env`, `Deno.env`, or a Workers bindings object + directly — that is what makes the middleware portable across hosts. +3. **MUST** declare upstream requirements in `In`. **NEVER** check for them at + runtime. +4. **NEVER** `yield` more than once in a generator `run`. +5. **NEVER** use the response seam to produce a response the handler could + produce itself. Default to a plain `async` `run`. +6. **MUST** pick a key that is unique in a stack. If a consumer might reasonably + apply your middleware twice, expose a key override in its config. +7. **NEVER** import from `node:*`. Web Fetch APIs only, so the middleware runs + on Deno, Cloudflare Workers, Bun, and Node alike. +8. **MUST** return a `Response` to short-circuit, rather than throwing. A + `Response` is not an error — it can carry any status. + +## See also + +- [Composition primitives](../src/core/README.md) — `ctx` shape, conflict and + prerequisite enforcement, the response seam. +- [`feature-flag`](../src/middleware/feature-flag/README.md) — the first-party + request-side worked example. +- [`cors`](../src/middleware/cors/README.md) — the first-party response-seam + worked example. +- [Adding a middleware to this repository](../src/middleware/README.md) — for + built-ins rather than standalone packages. diff --git a/src/core/README.md b/src/core/README.md index f9af6b6..257e7e8 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -6,7 +6,7 @@ Everything is plain Web Fetch, so the same stack runs unchanged across every run The package root exports: -- **`defineMiddleware`** — for _authors_ writing a new middleware. See the [authoring guide](../middleware/README.md). +- **`defineMiddleware`** — for _authors_ writing a new middleware. See the [authoring guide](../../docs/authoring-guide.md). - **`Middleware`** — the type a `defineMiddleware` call produces. - **`getEnv` / `runtimeName`** — portable environment access and the std-env-detected host name. - **`seedContext`** — mint a marked base context (for hosts embedding the engine). @@ -143,6 +143,6 @@ export default { ## See also -- [Authoring guide](../middleware/README.md) — write your own middleware. +- [Authoring guide](../../docs/authoring-guide.md) — write your own middleware. - [`feature-flag/`](../middleware/feature-flag/) — the worked example (request-side). - [`cors/`](../middleware/cors/) — the worked example of the response seam (`async function*`). diff --git a/src/middleware/README.md b/src/middleware/README.md index 84b1b74..8429d06 100644 --- a/src/middleware/README.md +++ b/src/middleware/README.md @@ -1,83 +1,24 @@ -# Writing a middleware +# Built-in middleware -This directory holds the **middleware** that ship with `@supabase/middleware`. A middleware is a `(config, handler)` fetch-handler wrapper 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. Anyone can publish one as a standalone npm package; the built-ins use the same `defineMiddleware` primitive third-party authors do. +This directory holds the middleware that ship with `@supabase/middleware`. -This README is for **authors**. If you just want to _use_ a middleware, see [`src/core/README.md`](../core/README.md). +| Directory | Key | What it does | +| ---------------------------------- | ----------------- | ------------------------------------------------------------------- | +| [`feature-flag/`](./feature-flag/) | `ctx.featureFlag` | Provider-agnostic feature flag. The request-side worked example. | +| [`cors/`](./cors/) | `ctx.cors` | CORS — preflight in, headers out. The response-seam worked example. | -## The worked example +> **Writing your own middleware?** See the [authoring guide](../../docs/authoring-guide.md). +> It covers the full path — `defineMiddleware`, tests, publishing, and composing +> your middleware in the same `pipeline` array as these built-ins. Nothing in +> this directory uses a private API; the built-ins are built with the same +> `defineMiddleware` primitive third-party authors use. -[`feature-flag/`](./feature-flag/) is the canonical reference. It is short, well-commented, and exercises every piece of the pattern — config, contribution, prerequisites, short-circuit vs fall-through. Read it alongside this guide. +This README covers only what is different about adding a middleware **to this +repository**. -``` -src/middleware/feature-flag/ -├── README.md ← consumer-facing docs -├── index.ts ← public exports -├── with-feature-flag.ts ← implementation -└── with-feature-flag.test.ts ← behavioural tests -``` - -## Anatomy of a middleware - -`defineMiddleware` takes four type parameters and one spec object: - -```ts -defineMiddleware({ key, run }) -``` - -| Parameter | What it is | Example | -| -------------- | ------------------------------------------------------------- | ----------------------------- | -| `Key` | The literal-string slot the middleware contributes to `ctx`. | `'featureFlag'` | -| `Config` | The object the consumer passes to `withFoo(config, handler)`. | `WithFeatureFlagConfig` | -| `In` | Upstream prerequisites — what must already be on `ctx`. | `Record` (none) | -| `Contribution` | The shape that lands at `ctx[Key]` after a successful run. | `FeatureFlagContribution` | - -Pass the four type parameters directly. The exported middleware's type is inferred as `Middleware` — no separate `: Middleware<…>` annotation needed. - -## `run` has two stages - -```ts -run: (config: Config) => (req: Request, ctx: In) => - Promise -``` - -- **Outer `(config) =>`** runs **once** when the consumer constructs the middleware. Initialize per-instance state here: clients, computed config, memoized fetches. -- **Inner `(req, ctx) =>`** runs **per request**. It receives the request and the upstream-supplied `ctx` typed as `In`. - -The inner stage returns one of two shapes: +## Adding a built-in -| Return | Effect | -| ------------------------- | ------------------------------------------------------- | -| `Response` | **Short-circuit.** The inner handler is never invoked. | -| `{ [Key]: Contribution }` | **Fall through.** The contribution lands at `ctx[Key]`. | - -The runtime picks `result[key]` off the contribution object and ignores any other fields, so a single `return { featureFlag: { ... } }` is all the author writes. - -### The response seam (`async function*`) - -The plain inner stage is request-side: it can't see the handler's `Response`. When a middleware genuinely needs the way out — stamp headers, time the request, run `finally` cleanup — write the inner stage as an **`async function*`** instead of `async`, and use `yield` as the seam: - -```ts -run: (config) => - async function* (req, ctx) { - // request phase (before yield) - const response = yield { myKey: contribution } // suspend; inner stack runs - // response phase (after yield) — `response` is the downstream Response - return shape(response) - } -``` - -Rules: **`yield` the contribution at most once** — `yield` means "run downstream and hand me the response," and its expression resolves to the downstream `Response` (typed, no annotation). To short-circuit, `return new Response(...)` (same as the request-side path); `try/finally` around the `yield` gives request-spanning cleanup. Both forms share the one `run` signature — the runtime picks the path by what the body returns, so the 95% plain-`async` case is untouched. [`cors/`](./cors/) is the worked example: `return` answers preflight, `yield` stamps headers on the way out. - -## Authoring rules - -1. **One key per middleware.** A middleware that wants multiple slots is doing too much — split it. -2. **Default to request-side.** A plain `async` middleware doesn't observe the inner handler's response, which keeps each surface small and the response shape under one owner. Reach for the response seam (`async function*`, above) only when a concern is genuinely two-sided — CORS, timing, request-spanning cleanup. If you're only producing a response, do it in the handler. -3. **Declare prerequisites in `In`.** If your middleware needs an upstream key — say `ctx.jwtClaims` from an auth middleware — set `In = { jwtClaims: { sub: string } | null }`. Standalone use then fails to compile — a real error, not a runtime surprise. -4. **Pick a unique key.** If two middleware contribute the same key, composition fails to typecheck (the inner `ctx` resolves to the `Conflict` sentinel) — from the entries array under `pipeline`, and under `satisfies FetchHandler` on the outermost call for nested handlers. Nested handlers with neither will compile and silently overwrite, so don't rely on a consumer catching your key clash for you. If your middleware is one a consumer might legitimately apply more than once (two feature flags, two rate-limit buckets), give it a distinct `Key` per instance — typically by exposing a key override in its own config. - -## Directory layout - -Mirror `feature-flag/`: +Mirror [`feature-flag/`](./feature-flag/): ``` src/middleware// @@ -91,40 +32,43 @@ Conventions: - Directory name is **kebab-case** (`feature-flag`, `rate-limit`). - Function is **`withCamelCase`** (`withFeatureFlag`, `withRateLimit`). -- The key on `ctx` is **camelCase** matching the function name minus the `with` prefix (`ctx.featureFlag`, `ctx.rateLimit`). -- Export the config / contribution interfaces alongside the middleware so consumers can type their own wrappers. +- The key on `ctx` is the function name minus the `with` prefix, camelCased + (`ctx.featureFlag`, `ctx.rateLimit`). +- Export the config and contribution interfaces alongside the middleware. +- Annotate the export with `Middleware<…>` explicitly — JSR rejects inferred + public types. -## Wiring up a new built-in - -To add a middleware to this package, three files change in addition to the new directory: +Then wire up the new subpath in three places: 1. **[`package.json`](../../package.json)** — add an entry to `exports`: + ```json "./": { - "types": "./dist/middleware//index.d.mts", - "import": "./dist/middleware//index.mjs", - "require": "./dist/middleware//index.cjs" + "import": { + "types": "./dist/middleware//index.d.mts", + "default": "./dist/middleware//index.mjs" + }, + "require": { + "types": "./dist/middleware//index.d.cts", + "default": "./dist/middleware//index.cjs" + } } ``` -2. **[`tsdown.config.ts`](../../tsdown.config.ts)** — add `'src/middleware//index.ts'` to `entry`. -3. **[`jsr.json`](../../jsr.json)** — add `"./": "./src/middleware//index.ts"`. - -A third-party middleware published as its own npm package skips all three — it just exports the result of `defineMiddleware` and depends on `@supabase/middleware` for the primitive. -## Testing the run stages +2. **[`tsdown.config.ts`](../../tsdown.config.ts)** — add + `'src/middleware//index.ts'` to `entry`. -The worked example in [`feature-flag/with-feature-flag.test.ts`](./feature-flag/with-feature-flag.test.ts) shows the cases worth covering: +3. **[`jsr.json`](../../jsr.json)** — add + `"./": "./src/middleware//index.ts"` to `exports`. -- Admits and contributes the expected `ctx[Key]` shape. -- Short-circuits with the configured status / body on reject. -- Honors override config (custom status, custom body). -- Passes the `Request` through, so author-supplied evaluators see header / IP / method. -- Supports async work inside `run`. +Add the new entry point to [`typedoc.json`](../../typedoc.json) as well, and +list its README under `projectDocuments`, so it appears in the generated API +reference. -Use `vi.fn` for the inner handler when you need to assert it was (or wasn't) called. +A third-party middleware published as its own package skips all of this — see +the [authoring guide](../../docs/authoring-guide.md). ## See also -- [`src/core/README.md`](../core/README.md) — composition rules, `ctx` shape, conflict and prerequisite enforcement. -- [`feature-flag/`](./feature-flag/) — the worked example referenced throughout this guide. -- [`cors/`](./cors/) — the worked example of the response seam (`async function*`). +- [Authoring guide](../../docs/authoring-guide.md) — build and publish your own middleware. +- [Composition primitives](../core/README.md) — `ctx` shape, conflict and prerequisite enforcement, the response seam. diff --git a/src/middleware/cors/README.md b/src/middleware/cors/README.md index d566e14..eb1d885 100644 --- a/src/middleware/cors/README.md +++ b/src/middleware/cors/README.md @@ -42,4 +42,4 @@ This is a small, practical implementation, not a spec-exhaustive one. If you nee ## See also - [Core README — the response seam](../../core/README.md) -- [Authoring guide](../README.md) +- [Authoring guide](../../../docs/authoring-guide.md) diff --git a/src/middleware/feature-flag/README.md b/src/middleware/feature-flag/README.md index b47b1bf..a2f9266 100644 --- a/src/middleware/feature-flag/README.md +++ b/src/middleware/feature-flag/README.md @@ -2,7 +2,7 @@ Provider-agnostic feature-flag middleware. Pass any `evaluate` function — it's called per request, admits when the flag is on, rejects otherwise. Use it with PostHog, LaunchDarkly, Statsig, an env-var, a header, a database row — anything that can answer "is this flag enabled for this request?". -> This is the worked example for authors. The implementation is short and well-commented — read [`with-feature-flag.ts`](./with-feature-flag.ts) alongside the [authoring guide](../README.md) to see how each piece of `defineMiddleware` lands in practice. +> This is the worked example for authors. The implementation is short and well-commented — read [`with-feature-flag.ts`](./with-feature-flag.ts) alongside the [authoring guide](../../../docs/authoring-guide.md) to see how each piece of `defineMiddleware` lands in practice. ```ts import { withFeatureFlag } from '@supabase/middleware/feature-flag' @@ -58,5 +58,5 @@ The middleware occupies `ctx.featureFlag` — only one `withFeatureFlag` can com ## See also -- [Authoring guide](../README.md) +- [Authoring guide](../../../docs/authoring-guide.md) - [Composition primitives](../../core/README.md) diff --git a/src/middleware/feature-flag/with-feature-flag.ts b/src/middleware/feature-flag/with-feature-flag.ts index 393a760..a509fba 100644 --- a/src/middleware/feature-flag/with-feature-flag.ts +++ b/src/middleware/feature-flag/with-feature-flag.ts @@ -7,7 +7,7 @@ * admits with the verdict at `ctx.featureFlag` or short-circuits with a * configurable response. * - * Read alongside `src/middleware/README.md` and `src/core/README.md` — this + * Read alongside `docs/authoring-guide.md` and `src/core/README.md` — this * file is referenced from both as the worked example of the pattern. */ diff --git a/typedoc.json b/typedoc.json index f97edbd..2a87fca 100644 --- a/typedoc.json +++ b/typedoc.json @@ -17,6 +17,7 @@ "GitHub": "https://github.com/supabase/middleware" }, "projectDocuments": [ + "docs/authoring-guide.md", "src/core/README.md", "src/middleware/README.md", "src/middleware/cors/README.md", From a3b6dc6ed9b68352e679e6bf59493cf529e5d464 Mon Sep 17 00:00:00 2001 From: Tomas Pozo Date: Fri, 7 Aug 2026 11:58:14 -0500 Subject: [PATCH 2/3] docs: Workers getEnv timing caveat + correct satisfies FetchHandler attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a "Client init and getEnv timing" section to §1: on Workers, bindings arrive per request, so getEnv returns undefined in the outer (config) => stage. Documents the lazy first-request init pattern (client ??= …) and gives rule 2 its worked example. - §5: pipeline does accumulation and collision/prereq checking itself and already returns FetchHandler, so satisfies FetchHandler there is inert. Attribute those to pipeline; scope the anchor's effect to the hand-nested form. Both verified against src/core/runtime.ts and src/core/pipeline.ts. --- docs/authoring-guide.md | 120 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 6 deletions(-) diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index b991f33..d7512cd 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -52,11 +52,15 @@ handler instead. | `Contribution` | The shape that lands at `ctx[Key]` | `ValidatedBodyContribution` | `run` has two stages. The outer `(config) =>` runs **once**, when the consumer -constructs the middleware — initialize clients and computed config there. The -inner `(req, ctx) =>` runs **per request**, and returns either a `Response` +constructs the middleware — derive computed config there. The inner +`(req, ctx) =>` runs **per request**, and returns either a `Response` (short-circuit; the handler never runs) or a single-key object `{ [key]: contribution }` (fall through). +Anything that needs an environment value — an API client built from a secret — +does **not** belong in the outer stage. See +[client init and `getEnv` timing](#client-init-and-getenv-timing) below. + ````ts // src/with-validated-body.ts import { defineMiddleware } from '@supabase/middleware' @@ -185,6 +189,101 @@ instead — that is what the first-party middleware do, and it is what makes an error path — it can carry any status. Errors that escape `run` propagate to the host, so handle what you can describe. +### Client init and `getEnv` timing + +Read configuration through `getEnv` (rule 2) — never `process.env`, `Deno.env`, +or a Workers bindings object. That is what keeps a middleware portable. But +`getEnv` has one timing constraint that decides _where_ you can call it. + +On Cloudflare Workers, env bindings are not ambient: they arrive per request as +the second `fetch` argument, and the framework captures them when the host +invokes the outermost handler. **Until the first request lands, `getEnv` returns +`undefined` on Workers** (`src/core/runtime.ts` documents the resolution order). +The outer `(config) =>` stage runs at construction — typically at module top +level — which is before that. So this is portable everywhere except the one +runtime it most needs to be portable on: + +```ts +run: (config) => { + const client = new Client(getEnv('API_KEY')) // undefined on Workers + return async () => ({ myKey: await client.check() }) +} +``` + +Construct on first request instead and cache with `??=`. That runs once per +isolate, not once per request, so it costs a single nullish check thereafter: + +```ts +// src/with-notifier.ts +import { defineMiddleware, getEnv } from '@supabase/middleware' +import type { Middleware } from '@supabase/middleware' + +/** Per-instance configuration for {@link withNotifier}. */ +export interface WithNotifierConfig { + /** Name of the env var holding the API key. @defaultValue `'NOTIFIER_API_KEY'` */ + apiKeyEnv?: string +} + +/** Shape contributed at `ctx.notifier`. */ +export interface NotifierContribution { + /** Send a notification through the provider. */ + notify: (message: string) => Promise +} + +/** Stands in for whatever provider SDK you construct with a secret. */ +class NotifierClient { + constructor(private readonly apiKey: string) {} + notify(message: string): Promise { + return fetch('https://api.example.com/notify', { + method: 'POST', + headers: { + authorization: `Bearer ${this.apiKey}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ message }), + }) + } +} + +function requireEnv(name: string): string { + const value = getEnv(name) + if (!value) throw new Error(`${name} is not set`) + return value +} + +/** Exposes a lazily constructed notification client at `ctx.notifier`. */ +export const withNotifier: Middleware< + 'notifier', + WithNotifierConfig | undefined, + Record, + NotifierContribution +> = defineMiddleware< + 'notifier', + WithNotifierConfig | undefined, + Record, + NotifierContribution +>({ + key: 'notifier', + run: (config) => { + // Outer stage — runs once, at construction. Plain config resolves here. + const apiKeyEnv = config?.apiKeyEnv ?? 'NOTIFIER_API_KEY' + + // Deferred: `getEnv(apiKeyEnv)` would be `undefined` here on Workers. + let client: NotifierClient | undefined + + return async () => { + // First request — bindings have arrived, so `getEnv` resolves. `??=` + // keeps this to one construction for the life of the isolate. + const ready = (client ??= new NotifierClient(requireEnv(apiKeyEnv))) + return { notifier: { notify: (message) => ready.notify(message) } } + } + }, +}) +``` + +The rule of thumb: **the outer stage is for values you already hold; the first +request is for values the host has to give you.** + ## 2. Public exports ```ts @@ -407,10 +506,15 @@ First in the array runs first on the request. `pipeline` returns the outermost `(req, ctx) => Response` — that **is** the `fetch` handler, with no wrapper around it. -`satisfies FetchHandler` is a type-only anchor that adds no runtime code. It -turns on ambient accumulation, so the handler sees every upstream key, and it -turns on collision detection. Duplicating a key fails to compile with -`middleware-conflict: key '…' is already present on the upstream context`. +With `pipeline`, accumulation and collision detection are **built in** — the +handler sees every upstream key on `ctx`, and duplicating a key fails to compile +with `middleware-conflict: key '…' is already present on the upstream context`, +with no anchor anywhere. `pipeline` already returns `FetchHandler`, so the +`satisfies FetchHandler` above is type-only documentation of the export shape. + +Where it does carry weight is the **hand-nested** form — `withCors({}, withFeatureFlag({…}, handler))` +— composed without `pipeline`. There the anchor is what turns on ambient +accumulation and collision detection, which is why §3's test uses it. ## Variant: requiring an upstream key @@ -582,6 +686,10 @@ first-party worked example: it answers preflight with a `return` before the 2. **MUST** read configuration through `getEnv` from `@supabase/middleware`. **NEVER** touch `process.env`, `Deno.env`, or a Workers bindings object directly — that is what makes the middleware portable across hosts. + **NEVER** call `getEnv` in the outer `(config) =>` stage: on Workers it + returns `undefined` before the first request. Construct env-dependent clients + lazily on first request — see + [client init and `getEnv` timing](#client-init-and-getenv-timing). 3. **MUST** declare upstream requirements in `In`. **NEVER** check for them at runtime. 4. **NEVER** `yield` more than once in a generator `run`. From c09fc7d21d7298c8d42bc0a0d21b099efda4cb1a Mon Sep 17 00:00:00 2001 From: Tomas Pozo Date: Mon, 10 Aug 2026 10:23:51 -0500 Subject: [PATCH 3/3] docs: address review nits, align anchor + getEnv claims across PR files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review nits: - Intro: scope the completeness claim to path-labeled blocks; unlabeled ones are fragments that elide with { ... }. - Rule 8: scope it to rejecting *requests*; throwing on misconfiguration is fine, since errors that escape run propagate to the host. - §0: show the destination wired into fetch, and add the nested alternative (no pipeline import; FetchHandler is a type, so a consumer composing only third-party middleware needs no runtime import from the package). - Terminology: first-party -> built-in throughout. Alignment across the files this PR touches: - README.md, src/core/README.md: pipeline accumulates and detects collisions itself; the anchor's effect is scoped to the hand-nested form. - with-feature-flag.ts: drop "initialize clients" from the outer-stage doc comment, point at the getEnv timing constraint. Verified: all 7 path-labeled blocks in the guide extract and compile against the real source, and the guide's test file runs green (7/7). --- CONTRIBUTING.md | 2 +- README.md | 2 +- docs/authoring-guide.md | 71 +++++++++++++------ .../feature-flag/with-feature-flag.ts | 7 +- 4 files changed, 57 insertions(+), 25 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 842ff32..2747a67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,7 +119,7 @@ root README in the same commit. ## Writing a middleware -The full authoring guide — `defineMiddleware`, request-side and generator forms, tests, publishing, and composing alongside first-party entries — is in [`docs/authoring-guide.md`](./docs/authoring-guide.md). The composition primitives (`ctx` shape, conflict & prerequisite enforcement, the response seam) are documented in [`src/core/README.md`](./src/core/README.md), with [`feature-flag`](./src/middleware/feature-flag/README.md) and [`cors`](./src/middleware/cors/README.md) as worked examples. +The full authoring guide — `defineMiddleware`, request-side and generator forms, tests, publishing, and composing alongside the built-in entries — is in [`docs/authoring-guide.md`](./docs/authoring-guide.md). The composition primitives (`ctx` shape, conflict & prerequisite enforcement, the response seam) are documented in [`src/core/README.md`](./src/core/README.md), with [`feature-flag`](./src/middleware/feature-flag/README.md) and [`cors`](./src/middleware/cors/README.md) as worked examples. To add a middleware **to this repository** (rather than publish your own package), see [`src/middleware/README.md`](./src/middleware/README.md) for the directory layout and subpath wiring. diff --git a/README.md b/README.md index bc7cdcc..2acd70b 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ This is the **one** place the "request-side" guarantee is relaxed, and writing ` ## Docs -- [Authoring guide](./docs/authoring-guide.md) — **build your own middleware**: `defineMiddleware`, tests, publishing, and composing it in the same `pipeline` array as the first-party entries. +- [Authoring guide](./docs/authoring-guide.md) — **build your own middleware**: `defineMiddleware`, tests, publishing, and composing it in the same `pipeline` array as the built-in entries. - [Composition primitives](./src/core/README.md) — `ctx` shape, conflict & prerequisite enforcement, composition rules, the response seam. - Per-middleware: [feature-flag](./src/middleware/feature-flag/README.md) — the request-side worked example · [cors](./src/middleware/cors/README.md) — the response-seam worked example. diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index d7512cd..1e1dd27 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -5,30 +5,55 @@ title: Build your own middleware # Build your own middleware This guide walks the full path: from `defineMiddleware` to publishing your own -package, to composing it in the same `pipeline` array as the first-party -entries. Every code block below is a **complete file** with its path in the -first line — write it to that path and it compiles. Nothing is elided. +package, to composing it alongside the built-in entries. Every code block +**labeled with a path** is a complete file — write it to that path and it +compiles. Unlabeled blocks are fragments, and elide with `{ ... }`. The example is `withValidatedBody`, a middleware that validates a JSON request body and short-circuits with `400` when it fails. It is deliberately shaped like -the first-party [`withFeatureFlag`](../src/middleware/feature-flag/with-feature-flag.ts), +the built-in [`withFeatureFlag`](../src/middleware/feature-flag/with-feature-flag.ts), so anything you read here transfers to the shipped source and back. ## 0. The destination -This is where you end up — your middleware sitting alongside first-party ones in -a single flat array, every contribution typed on `ctx`: +This is where you end up — your middleware sitting alongside the built-in ones +in a single flat array, every contribution typed on `ctx`, wired straight into +the runtime's `fetch`: ```ts -pipeline( - [withCors({}), withFeatureFlag({ ... }), withValidatedBody({ ... })], - async (_req, ctx) => Response.json({ data: ctx.validatedBody.data }), -) +export default { + fetch: pipeline( + [withCors({}), withFeatureFlag({ ... }), withValidatedBody({ ... })], + async (_req, ctx) => Response.json({ data: ctx.validatedBody.data }), + ), +} ``` There is no registry to join and no plugin interface to implement. A middleware -is a function produced by `defineMiddleware`; first-party and third-party -middleware are the same kind of thing, built with the same primitive. +is a function produced by `defineMiddleware`; the ones this package ships and +the ones you publish are the same kind of thing, built with the same primitive. + +### `pipeline`, or nesting + +`pipeline` is a convenience, not a requirement. Entries nest directly, and the +result is the same handler: + +```ts +export default { + fetch: withCors({}, withFeatureFlag({ ... }, withValidatedBody({ ... }, + async (_req, ctx) => Response.json({ data: ctx.validatedBody.data }), + ))) satisfies FetchHandler, +} +``` + +Nesting costs you the flat reading order past two or three entries, and it +**requires** the `satisfies FetchHandler` anchor — without it the handler does +not see upstream keys ambiently, and a duplicate key compiles silently. What it +buys you is that `FetchHandler` is a _type_, so a consumer composing only +third-party middleware needs no runtime import from `@supabase/middleware` at +all — which is exactly why §2 re-exports the type from your own package. + +The rest of this guide uses `pipeline`. ### Which form do you need? @@ -182,7 +207,7 @@ lets the package publish to JSR, which rejects inferred public types. **`data` is `unknown` on purpose,** because this example accepts any validator. A middleware written for one domain should make its contribution concrete -instead — that is what the first-party middleware do, and it is what makes +instead — that is what the built-in middleware do, and it is what makes `ctx.yourKey` genuinely useful to a handler without a cast. **Explicit reject config beats a thrown error.** Returning a `Response` is not @@ -295,7 +320,8 @@ export type { ValidatedBodyContribution, } from './with-validated-body.js' -// Re-exported so consumers can write `satisfies FetchHandler` with one import. +// Re-exported so a consumer who hand-nests instead of using `pipeline` can +// write `satisfies FetchHandler` without importing @supabase/middleware. export type { FetchHandler } from '@supabase/middleware' ``` @@ -465,7 +491,7 @@ registry, so two copies of the package loaded side by side still recognize each other's contexts. A version skew between your middleware and the consumer's is not a correctness problem. -## 5. Compose it with first-party middleware +## 5. Compose it with the built-in middleware ```ts // server.ts @@ -489,8 +515,8 @@ export default { }), ], async (_req, ctx) => { - ctx.cors // from withCors — first-party - ctx.featureFlag // from withFeatureFlag — first-party + ctx.cors // from withCors — built-in + ctx.featureFlag // from withFeatureFlag — built-in ctx.validatedBody // from withValidatedBody — yours return Response.json({ @@ -676,7 +702,7 @@ Rules for the seam: The runtime picks the path from what the body returns, so the plain `async` case is unaffected. [`withCors`](../src/middleware/cors/with-cors.ts) is the -first-party worked example: it answers preflight with a `return` before the +built-in worked example: it answers preflight with a `return` before the `yield`, and stamps headers after. ## Rules @@ -700,15 +726,18 @@ first-party worked example: it answers preflight with a `return` before the 7. **NEVER** import from `node:*`. Web Fetch APIs only, so the middleware runs on Deno, Cloudflare Workers, Bun, and Node alike. 8. **MUST** return a `Response` to short-circuit, rather than throwing. A - `Response` is not an error — it can carry any status. + `Response` is not an error — it can carry any status. This is about rejecting + **requests**. Surfacing **misconfiguration** — a missing API key, an + unparseable option — by throwing is fine and often right: there is no request + to blame, and errors that escape `run` propagate to the host. ## See also - [Composition primitives](../src/core/README.md) — `ctx` shape, conflict and prerequisite enforcement, the response seam. -- [`feature-flag`](../src/middleware/feature-flag/README.md) — the first-party +- [`feature-flag`](../src/middleware/feature-flag/README.md) — the built-in request-side worked example. -- [`cors`](../src/middleware/cors/README.md) — the first-party response-seam +- [`cors`](../src/middleware/cors/README.md) — the built-in response-seam worked example. - [Adding a middleware to this repository](../src/middleware/README.md) — for built-ins rather than standalone packages. diff --git a/src/middleware/feature-flag/with-feature-flag.ts b/src/middleware/feature-flag/with-feature-flag.ts index a509fba..5a75858 100644 --- a/src/middleware/feature-flag/with-feature-flag.ts +++ b/src/middleware/feature-flag/with-feature-flag.ts @@ -128,8 +128,11 @@ export const withFeatureFlag: Middleware< key: 'featureFlag', /** * Two-stage function. The outer `(config) =>` runs once when the consumer - * constructs the middleware — initialize per-instance state here (clients, - * computed config). The inner `(req, _ctx) =>` runs per request. + * constructs the middleware — derive computed config here. The inner + * `(req, _ctx) =>` runs per request. Anything built from an environment value + * belongs in the inner stage, constructed lazily on first request: `getEnv` + * returns `undefined` at construction time on Cloudflare Workers, where + * bindings arrive per request (see `docs/authoring-guide.md`). * * Return a `Response` to short-circuit (the inner handler never runs), or a * single-key object `{ [key]: contribution }` to fall through. The runtime