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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ jobs:

- run: pnpm typecheck

- run: pnpm typecheck:examples

- run: pnpm lint

- name: Check every published import is declared (JSR resolvability)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,8 @@ Adapters wrap `withSupabase` for a specific framework's middleware contract. The

See the per-adapter docs above for setup, per-route auth, CORS, error handling, and other patterns.

To run `@supabase/middleware` entries inside Hono, H3, Elysia, NestJS, or TanStack Start without an adapter, copy the bridge for your framework from [`examples/frameworks/`](examples/frameworks/). The [Frameworks guide](https://supabase.com/docs/reference/server/frameworks) explains the bridges and how to move off the adapters.

### Elysia

```ts
Expand Down
22 changes: 22 additions & 0 deletions examples/frameworks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Framework bridges

Each folder holds two files for one framework:

- `supabase-middleware.ts` (`supabase.guard.ts` for NestJS) is the bridge. It runs a `@supabase/middleware` entry array inside the framework's own middleware slot. Copy it into your project as is, comments included.
- `app.ts` is a minimal app that uses the bridge with `withRequiredClaims` and `withSupabaseClient`.

| Framework | Bridge | Usage |
| -------------- | -------------------------------------------------------------------------------- | ------------------------------------------------ |
| Hono | [`hono/supabase-middleware.ts`](hono/supabase-middleware.ts) | [`hono/app.ts`](hono/app.ts) |
| H3 / Nuxt | [`h3/supabase-middleware.ts`](h3/supabase-middleware.ts) | [`h3/app.ts`](h3/app.ts) |
| Elysia | [`elysia/supabase-middleware.ts`](elysia/supabase-middleware.ts) | [`elysia/app.ts`](elysia/app.ts) |
| NestJS | [`nestjs/supabase.guard.ts`](nestjs/supabase.guard.ts) | [`nestjs/app.ts`](nestjs/app.ts) |
| TanStack Start | [`tanstack-start/supabase-middleware.ts`](tanstack-start/supabase-middleware.ts) | [`tanstack-start/app.ts`](tanstack-start/app.ts) |

The Elysia bridge is two functions that work only as a pair. `wrapElysia` runs the entries around the app, and `supabaseCtx` hands their values to the routes. An app served without `wrapElysia` throws on every route.

The NestJS guard file works without a decorator transform, but the controller in `nestjs/app.ts` does not: Nest is built on decorators, so running it needs swc, ts-node, or a build step. Node's built-in type stripping rejects the `@Controller()` line.

The guide that explains the bridges, the auth trap, and how to move off the framework adapters is on supabase.com: [Frameworks](https://supabase.com/docs/reference/server/frameworks).

These files typecheck in CI through `pnpm typecheck:examples`. They are not part of the published package. `examples/package.json` is a private workspace package that holds the dependencies only the examples need, such as `@tanstack/react-start`; the root package does not list them.
27 changes: 27 additions & 0 deletions examples/frameworks/elysia/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Elysia } from 'elysia'
import { withRequiredClaims } from '@supabase/server/middleware/required-claims'
import { withSupabaseClient } from '@supabase/server/middleware/client'

import { supabaseCtx, wrapElysia } from './supabase-middleware.js'

// `withRequiredClaims` answers 401 to any request without a valid user JWT,
// so the routes below only run for signed-in callers. `withClaims` would let
// anonymous requests through with `jwtClaims: null`.
const entries = [withRequiredClaims(), withSupabaseClient()] as const

const app = new Elysia()
.use(supabaseCtx<typeof entries>())
.get('/todos', async (c) => {
const { data, error } = await c.supabase.from('todos').select()
if (error) throw error
return data
})
.get('/me', (c) => ({ id: c.jwtClaims.sub }))

// `wrapElysia` runs the entries around the whole app, and `supabaseCtx` above
// hands their contributions to the routes. The two work only as a pair:
// serving `app` directly, with `app.listen()` or `export default app`, skips
// the entries and every route throws.
export default {
fetch: wrapElysia(entries, (req) => app.handle(req)),
}
84 changes: 84 additions & 0 deletions examples/frameworks/elysia/supabase-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Elysia } from 'elysia'
import { pipeline } from '@supabase/middleware'
import type {
AnyEntry,
Contributions,
FetchHandler,
ValidateEntries,
} from '@supabase/middleware'

/**
* The `handle` parameter type when the entries compose, or the engine's error
* string when they do not. Placing the check on the parameter surfaces the
* `middleware-conflict` or `middleware-prereq` message at the `wrapElysia`
* call, the same way `pipeline` reports it on its handler argument.
*/
type Handle<Entries extends readonly AnyEntry[]> = [
ValidateEntries<Entries>,
] extends [true]
? (req: Request) => Response | Promise<Response>
: ValidateEntries<Entries>

/** Contributions for an in-flight request, keyed by the Request Elysia sees. */
const HANDOFF = new WeakMap<Request, Record<string, unknown>>()

/**
* Elysia plugin that exposes the entries' contributions on the route context.
*
* `supabaseCtx` and `wrapElysia` are one unit. The wrapper runs the entries
* and stores their contributions for the request; the plugin reads them. When
* the wrapper did not run, the entries did not run either, so the plugin
* throws instead of handing the route an empty context. An app served with
* `app.listen()` or `export default app` therefore fails closed on every
* route.
*
* `Entries` is type-only. Pass the type of the same tuple `wrapElysia`
* receives, so route handlers see the keys that tuple contributes.
*/
export function supabaseCtx<const Entries extends readonly AnyEntry[]>() {
return new Elysia()
.resolve((c) => {
const contributions = HANDOFF.get(c.request)
if (!contributions) {
throw new Error(
'supabaseCtx() ran without wrapElysia(). The entries did not run, ' +
'so this request is not gated. Serve the app through ' +
'`wrapElysia(entries, (req) => app.handle(req))`.',
)
}
return contributions as Contributions<Entries>
})
.as('scoped')
}

/**
* Wraps the whole app so the entries see the real outgoing Response.
*
* Elysia's lifecycle hooks run in the request phase only: a `.resolve()` hook
* has no `next()` and never sees the response. Composing around `app.handle`
* keeps the response phase for entries such as `withCors`. Because the
* entries wrap the whole app, they apply app-wide; scope per route with
* Elysia's own `.group()` and a separately wrapped sub-app.
*
* Pair it with `supabaseCtx`, which reads what this wrapper stores.
*/
export function wrapElysia<const Entries extends readonly AnyEntry[]>(
entries: Entries,
handle: Handle<Entries>,
): FetchHandler {
const next = handle as (req: Request) => Response | Promise<Response>
return pipeline(entries as readonly AnyEntry[], async (req, ctx) => {
const contributions: Record<string, unknown> = {}
for (const [key, value] of Object.entries(ctx)) {
contributions[key] = value
}
HANDOFF.set(req, contributions)
try {
return await next(req)
} finally {
// The WeakMap already permits collection. The delete keeps the entry
// from outliving the request when the Request object is retained.
HANDOFF.delete(req)
}
})
}
28 changes: 28 additions & 0 deletions examples/frameworks/h3/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { H3 } from 'h3'
import type { Contributions } from '@supabase/middleware'
import { withRequiredClaims } from '@supabase/server/middleware/required-claims'
import { withSupabaseClient } from '@supabase/server/middleware/client'

import { toH3 } from './supabase-middleware.js'

// `withRequiredClaims` answers 401 to any request without a valid user JWT,
// so the routes below only run for signed-in callers. `withClaims` would let
// anonymous requests through with `jwtClaims: null`.
const entries = [withRequiredClaims(), withSupabaseClient()] as const

const app = new H3()
app.use(toH3(entries))

app.get('/todos', async (event) => {
const { supabase } = event.context as Contributions<typeof entries>
const { data, error } = await supabase.from('todos').select()
if (error) throw error
return data
})

app.get('/me', (event) => {
const { jwtClaims } = event.context as Contributions<typeof entries>
return { id: jwtClaims.sub }
})

export default { fetch: app.fetch }
67 changes: 67 additions & 0 deletions examples/frameworks/h3/supabase-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { defineMiddleware, toResponse } from 'h3'
import type { H3Event, Middleware } from 'h3'
import { bufferRequest, pipeline, seedContext } from '@supabase/middleware'
import type { AnyEntry, ValidateEntries } from '@supabase/middleware'

/**
* The H3 middleware type when the entries compose, or the engine's error
* string when they do not. The string surfaces the `middleware-conflict` or
* `middleware-prereq` message at the `app.use` call.
*/
type Bridge<Entries extends readonly AnyEntry[]> = [
ValidateEntries<Entries>,
] extends [true]
? Middleware
: ValidateEntries<Entries>

/** Per-request handoff from the H3 middleware to the pipeline's terminal. */
const HANDOFF = Symbol('toH3.handoff')
interface Handoff {
event: H3Event
next: () => unknown
}

/**
* Runs an entry array inside H3's middleware slot.
*
* The pipeline folds once, when `toH3` is called, so entries keep their state
* across requests. Each request travels through the fold with its H3 event
* under a symbol key, which the engine's context spreads preserve.
*
* The terminal copies every contributed key onto `event.context`, runs the
* rest of the H3 chain, and returns the downstream response back up through
* the entries. `event.context` is not generic, so read contributions through
* `Contributions<typeof entries>` at the call site.
*/
export function toH3<const Entries extends readonly AnyEntry[]>(
entries: Entries,
): Bridge<Entries> {
const run = pipeline(entries as readonly AnyEntry[], async (_req, ctx) => {
const { event, next } = (ctx as Record<symbol, Handoff>)[HANDOFF]
for (const [key, value] of Object.entries(ctx)) {
event.context[key] = value
}
// H3 handlers may return plain values. Normalizing here means the
// response phase always receives a real Response.
return toResponse(await next(), event)
})

return defineMiddleware((event, next) => {
// The engine buffers a request body only when it seeds the context
// itself. This bridge seeds, so it buffers too. `event.req` is readonly in
// H3's types and a plain property at runtime; the cast puts the proxy on
// the event so an entry and the route read the same cached body.
if (event.req.body) {
;(event as { req: H3Event['req'] }).req = bufferRequest(
event.req,
) as H3Event['req']
}
// On Cloudflare Workers the bindings live on the request's runtime info,
// which is how `getEnv` inside the entries reads `SUPABASE_URL` there. On
// Node the value is undefined and `getEnv` falls back to `process.env`.
return run(event.req, {
...seedContext(event.req.runtime?.cloudflare?.env),
[HANDOFF]: { event, next } satisfies Handoff,
})
}) as never
}
19 changes: 19 additions & 0 deletions examples/frameworks/hono/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Hono } from 'hono'
import { withRequiredClaims } from '@supabase/server/middleware/required-claims'
import { withSupabaseClient } from '@supabase/server/middleware/client'

import { toHono } from './supabase-middleware.js'

// `withRequiredClaims` answers 401 to any request without a valid user JWT,
// so the routes below only run for signed-in callers. `withClaims` would let
// anonymous requests through with `jwtClaims: null`.
const app = new Hono()
.use('*', toHono([withRequiredClaims(), withSupabaseClient()]))
.get('/todos', async (c) => {
const { data, error } = await c.var.supabase.from('todos').select()
if (error) return c.json({ error: error.message }, 500)
return c.json(data)
})
.get('/me', (c) => c.json({ id: c.var.jwtClaims.sub }))

export default { fetch: app.fetch }
81 changes: 81 additions & 0 deletions examples/frameworks/hono/supabase-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { Context, MiddlewareHandler, Next } from 'hono'
import { createMiddleware } from 'hono/factory'
import { bufferRequest, pipeline, seedContext } from '@supabase/middleware'
import type {
AnyEntry,
Contributions,
ValidateEntries,
} from '@supabase/middleware'

/**
* The Hono middleware type when the entries compose, or the engine's error
* string when they do not. The string surfaces the `middleware-conflict` or
* `middleware-prereq` message at the `app.use` call.
*/
type Bridge<Entries extends readonly AnyEntry[]> = [
ValidateEntries<Entries>,
] extends [true]
? MiddlewareHandler<{ Variables: Contributions<Entries> }>
: ValidateEntries<Entries>

/** Per-request handoff from the Hono middleware to the pipeline's terminal. */
const HANDOFF = Symbol('toHono.handoff')
interface Handoff {
c: Context
next: Next
}

/**
* Runs an entry array inside Hono's middleware slot.
*
* The pipeline folds once, when `toHono` is called, so entries keep their
* state across requests. Each request travels through the fold with its Hono
* context under a symbol key, which the engine's context spreads preserve.
*
* The terminal publishes every contributed key onto `c.var`, runs the rest of
* the Hono chain, and returns Hono's real response back up through the
* entries. That return is what lets response-phase entries such as `withCors`
* stamp headers on the way out.
*
* Register the result with `.use()` before the routes it gates. Hono applies
* middleware only to routes registered after it.
*
* Hono carries the contributed keys through the return value of a chained
* call, so `app.use(toHono(a))` on one line and `app.get(...)` on the next
* typecheck the middleware but leave `c.var` untyped. A second array for other
* routes goes in a sub-app mounted with `app.route()`, each sub-app chaining
* its own `.use()` into its routes.
*/
export function toHono<const Entries extends readonly AnyEntry[]>(
entries: Entries,
): Bridge<Entries> {
const run = pipeline(entries as readonly AnyEntry[], async (_req, ctx) => {
const { c, next } = (ctx as Record<symbol, Handoff>)[HANDOFF]
for (const [key, value] of Object.entries(ctx)) {
c.set(key as never, value as never)
}
await next()
return c.res
})

return createMiddleware(async (c, next) => {
// The engine buffers a request body only when it seeds the context
// itself. This bridge seeds, so it buffers too, and puts the proxy on
// Hono's request so an entry and the route read the same cached body.
if (c.req.raw.body) c.req.raw = bufferRequest(c.req.raw)
// `c.env` holds the platform bindings on Cloudflare Workers, which is how
// `getEnv` inside the entries reads `SUPABASE_URL` there. On Node it holds
// the raw request pair and `getEnv` falls back to `process.env`.
const res = await run(c.req.raw, {
...seedContext(c.env),
[HANDOFF]: { c, next } satisfies Handoff,
})
if (res !== c.res) {
// Hono's `res` setter copies the previous response's headers onto the
// new one, which reverts any header the response phase rewrote.
// Clearing first makes the assignment authoritative.
c.res = undefined as never
c.res = res
}
}) as never
}
32 changes: 32 additions & 0 deletions examples/frameworks/nestjs/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common'
import type { Contributions } from '@supabase/middleware'
import { withRequiredClaims } from '@supabase/server/middleware/required-claims'
import { withSupabaseClient } from '@supabase/server/middleware/client'

import { toNestGuard } from './supabase.guard.js'

// `withRequiredClaims` answers 401 to any request without a valid user JWT,
// so the handlers below only run for signed-in callers. `withClaims` would
// let anonymous requests through with `jwtClaims: null`.
const entries = [withRequiredClaims(), withSupabaseClient()] as const

// One guard class for every route. `toNestGuard` folds the pipeline when it
// is called, so a single call keeps entry state shared across requests.
const SupabaseGuard = toNestGuard(entries)

@Controller('todos')
export class TodosController {
@Get()
@UseGuards(SupabaseGuard)
async list(@Req() req: Contributions<typeof entries>) {
const { data, error } = await req.supabase.from('todos').select()
if (error) throw error
return data
}

@Get('me')
@UseGuards(SupabaseGuard)
me(@Req() req: Contributions<typeof entries>) {
return { id: req.jwtClaims.sub }
}
}
Loading
Loading