diff --git a/README.md b/README.md index 2acd70b..e4dc24b 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,32 @@ Two type-level guarantees, with no runtime cost: - **Collision detection.** Two middleware contributing the same key fail to compile, with an error naming the key on the offending call. `pipeline` checks this from the entries array. Nested handlers need `satisfies FetchHandler` on the outermost call — one annotation covers any depth — and without it the duplicate compiles silently and the inner contribution wins at runtime. - **Prerequisite enforcement.** A middleware can declare upstream keys it needs (e.g. a database middleware that needs `jwtClaims` from an upstream auth middleware). Any layer further out can supply them, at any distance and with no annotation, and the contribution's type has to match — not just the key name. If **nothing** supplies it, the stack keeps a _required_ `ctx`, which fails only where it is checked against `FetchHandler`. A bare `export default { fetch: app }` is no such check, so it compiles and throws `TypeError` on the first request — annotate the outermost call with `satisfies FetchHandler` (or put the stack in any `FetchHandler`-typed position) to catch it at build time. +### Composing by nesting + +`pipeline` is optional. Every middleware also takes the next handler directly, as `withFoo(config, handler)`. Nesting those calls builds the same handler, with the same accumulation and the same prerequisite enforcement, at any depth. + +```ts +import type { FetchHandler } from '@supabase/middleware' +import { withCors } from '@supabase/middleware/cors' +import { withFeatureFlag } from '@supabase/middleware/feature-flag' + +export default { + fetch: withCors( + {}, + withFeatureFlag( + { name: 'beta', evaluate: (req) => req.headers.has('x-beta') }, + async (_req, ctx) => Response.json({ flag: ctx.featureFlag.name }), + ), + ) satisfies FetchHandler, +} +``` + +Nesting asks one thing of you: keep `satisfies FetchHandler` on the outermost call. That anchor turns on collision detection and the build-time prerequisite check, as the bullets above describe. `ctx` accumulation needs no annotation at any depth. + +`FetchHandler` is a type, so importing it adds no runtime code. The [authoring guide](./docs/authoring-guide.md) tells middleware authors to re-export it from their own package. Compose only middleware from packages that do, and your handler file imports nothing from `@supabase/middleware`. Your `package.json` never lists it either. Composition comes free with the middleware themselves. + +Past two or three entries, the flat array is easier to read than the nesting it folds into. That is what `pipeline` is for, and why these docs lead with it. Both forms produce the same stack, so pick whichever fits the file. + ### Runtime & environment Environment access is a plain import — middleware never reach for `Deno.env` / `process.env` / a Workers bindings object directly, and `ctx` carries no reserved framework key: diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index 1e1dd27..d398c8f 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -47,8 +47,9 @@ export default { ``` 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 +wants the `satisfies FetchHandler` anchor on the outermost call: `ctx` +accumulates without it, but a duplicate key compiles silently and a +prerequisite nothing supplies isn't caught until the first request. 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. @@ -475,7 +476,7 @@ the `Response`. "typecheck": "tsc --noEmit" }, "dependencies": { - "@supabase/middleware": "^0.1.0" + "@supabase/middleware": "^0.3.0" }, "devDependencies": { "tsdown": "^0.20.3", @@ -539,8 +540,9 @@ 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. +— composed without `pipeline`. There the anchor turns on collision detection +and asserts the stack can be the `fetch` export. Accumulation is ambient either +way. That is why §3's test uses it. ## Variant: requiring an upstream key @@ -617,8 +619,11 @@ Reverse those two entries and compilation fails with 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. +`satisfies FetchHandler` fails, and calling it with a request alone fails to +compile: it needs the context argument too. Anywhere the stack is checked +against `FetchHandler`, the prerequisite cannot become a lie at the top level. +An untyped `export default { fetch: … }` is no such check, which is why the +anchor matters. ## Variant: the response seam diff --git a/skills/create-supabase-middleware/SKILL.md b/skills/create-supabase-middleware/SKILL.md index c25ce08..f35446c 100644 --- a/skills/create-supabase-middleware/SKILL.md +++ b/skills/create-supabase-middleware/SKILL.md @@ -19,6 +19,6 @@ Then pick the target by reading the nearest `package.json`: Both paths use the same `defineMiddleware` primitive; the difference is only whose package it lives in and whether a subpath export has to be wired up. -**Read the guide before writing code, and follow its `## Rules` section** — eight MUST/NEVER items covering one-key-per-middleware, `getEnv` over `process.env`/`Deno.env`, declaring prerequisites in `In`, `yield`ing at most once, and returning a `Response` to short-circuit rather than throwing. Its code blocks are complete files: write them to disk as given rather than adapting them from memory. +**Read the guide before writing code, and follow its `## Rules` section** — eight MUST/NEVER items covering one-key-per-middleware, `getEnv` over `process.env`/`Deno.env`, declaring prerequisites in `In`, `yield`ing at most once, and returning a `Response` to short-circuit rather than throwing. Its code blocks labeled with a path are complete files: write them to disk as given rather than adapting them from memory. Unlabeled blocks are fragments that elide with `{ ... }`. Never write those verbatim. Paths are repo-relative. When `@supabase/middleware` is installed as a dependency, prefix them with `node_modules/@supabase/middleware/`. diff --git a/skills/supabase-middleware/SKILL.md b/skills/supabase-middleware/SKILL.md index 294109e..321b3b1 100644 --- a/skills/supabase-middleware/SKILL.md +++ b/skills/supabase-middleware/SKILL.md @@ -42,6 +42,8 @@ export default { `withFoo(config)` returns an **`Entry`**. `pipeline` folds a flat array of entries around a handler and returns the `fetch` handler itself — first in the array runs first on the request. Each entry contributes one typed key to `ctx`, and the handler sees every upstream key, typed. +`withFoo(config, handler)` skips `pipeline`: middleware nest directly and produce the same stack. Anchor the outermost call with `satisfies FetchHandler`. The anchor turns on collision detection and the build-time prerequisite check. A file that composes only middleware from other packages needs no import from `@supabase/middleware`, because those packages re-export the type. Both forms are correct. Do not rewrite one into the other unasked. + **No registry, no `app.use()`, no `next()`.** If you are writing any of those, you are using the wrong model — read `src/core/README.md` before continuing. ## Read before writing code diff --git a/src/core/README.md b/src/core/README.md index 257e7e8..a0cdaf3 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -6,6 +6,7 @@ Everything is plain Web Fetch, so the same stack runs unchanged across every run The package root exports: +- **`pipeline` / `Entry`** — flat-array composition for _consumers_: `withFoo(config)` returns an `Entry`, and `pipeline(entries, handler)` folds the array into the nested calls described above. See the quick start below. - **`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. @@ -51,10 +52,10 @@ Inside a wrapped handler, `ctx` is a flat intersection of middleware contributio | `ctx.` (e.g. `ctx.featureFlag`) | the corresponding middleware | read-only by convention | > **Reading the body.** Read it off **`req`** as usual — `req.text()` / `req.json()` / -> `req.arrayBuffer()` / `req.bytes()`. The framework hands every layer a buffered +> `req.arrayBuffer()` / `req.bytes()` / `req.blob()` / `req.formData()`. The framework hands every layer a buffered > request that caches the body after the first read, so a body-verifying middleware > (e.g. a webhook signature check) and your handler can both read it without "Body already consumed". -> (Reading the raw `req.body` stream or `req.formData()` still consumes once.) +> (Reading the raw `req.body` stream still consumes once: it bypasses the cache.) Two type-level guarantees: