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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 12 additions & 7 deletions docs/authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion skills/create-supabase-middleware/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
2 changes: 2 additions & 0 deletions skills/supabase-middleware/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -51,10 +52,10 @@ Inside a wrapped handler, `ctx` is a flat intersection of middleware contributio
| `ctx.<key>` (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:

Expand Down
Loading