From 3d42d1e3cbf72f6acd100b3b191cfadd84276c9f Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 4 Jun 2026 15:21:48 -0700 Subject: [PATCH 1/2] feat: add TanStack Start adapter --- README.md | 13 +- docs/adapters/tanstack-start.md | 125 ++++++++++++++++++ jsr.json | 3 +- package.json | 21 ++- pnpm-lock.yaml | 90 +++++++++++++ src/adapters/README.md | 13 +- src/adapters/tanstack-start/index.ts | 7 + .../tanstack-start/middleware.test.ts | 117 ++++++++++++++++ src/adapters/tanstack-start/middleware.ts | 61 +++++++++ tsdown.config.ts | 11 +- 10 files changed, 444 insertions(+), 17 deletions(-) create mode 100644 docs/adapters/tanstack-start.md create mode 100644 src/adapters/tanstack-start/index.ts create mode 100644 src/adapters/tanstack-start/middleware.test.ts create mode 100644 src/adapters/tanstack-start/middleware.ts diff --git a/README.md b/README.md index 5a7b8ca..fa57077 100644 --- a/README.md +++ b/README.md @@ -266,12 +266,13 @@ Adapters wrap `withSupabase` for a specific framework's middleware contract. The > **Adapters are a community-driven initiative.** They're developed, maintained, and evolved by contributors — including responding to upstream framework changes. See [`src/adapters/README.md`](src/adapters/README.md) for the contribution requirements (tests, types, docs, build wiring) if you'd like to add or help maintain one. -| Framework | Import | Framework version | Docs | -| --------- | ---------------------------------- | ---------------------- | -------------------------------------------------- | -| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](docs/adapters/hono.md) | -| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](docs/adapters/h3.md) | -| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](docs/adapters/elysia.md) | -| NestJS | `@supabase/server/adapters/nestjs` | `^10.0.0 \|\| ^11.0.0` | [docs/adapters/nestjs.md](docs/adapters/nestjs.md) | +| Framework | Import | Framework version | Docs | +| -------------- | ------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------------ | +| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](docs/adapters/hono.md) | +| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](docs/adapters/h3.md) | +| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](docs/adapters/elysia.md) | +| NestJS | `@supabase/server/adapters/nestjs` | `^10.0.0 \|\| ^11.0.0` | [docs/adapters/nestjs.md](docs/adapters/nestjs.md) | +| TanStack Start | `@supabase/server/adapters/tanstack-start` | `@tanstack/start-{client,server}-core ^1.170.0` / `^1.169.0` | [docs/adapters/tanstack-start.md](docs/adapters/tanstack-start.md) | See the per-adapter docs above for setup, per-route auth, CORS, error handling, and other patterns. diff --git a/docs/adapters/tanstack-start.md b/docs/adapters/tanstack-start.md new file mode 100644 index 0000000..99bbb2e --- /dev/null +++ b/docs/adapters/tanstack-start.md @@ -0,0 +1,125 @@ +# TanStack Start Adapter + +The adapter exposes `withSupabase` as a TanStack Start **function middleware**. Attach it to a server function and the `SupabaseContext` is available (and typed) as `context.supabaseContext` in the handler. + +It is framework-agnostic: it imports from `@tanstack/start-client-core` and `@tanstack/start-server-core`, which every `@tanstack/{react,solid,vue}-start` package re-exports. So the same adapter works for React, Solid, and Vue Start. + +## Setup + +You don't install the core packages directly; they come in with your framework's Start package, which you already have: + +```bash +pnpm add @tanstack/react-start # or @tanstack/solid-start, @tanstack/vue-start +``` + +## Basic server function with auth + +```ts +import { createServerFn } from '@tanstack/react-start' +import { withSupabase } from '@supabase/server/adapters/tanstack-start' + +export const getTodos = createServerFn() + .middleware([withSupabase({ auth: 'user' })]) + .handler(async ({ context }) => { + const { data } = await context.supabaseContext.supabase + .from('todos') + .select() + return data + }) +``` + +The context is available as `context.supabaseContext` and contains the same `SupabaseContext` fields as the main `withSupabase` wrapper: `supabase`, `supabaseAdmin`, `userClaims`, `jwtClaims`, and `authMode`. + +## Per-function auth + +Because middleware is attached per server function, different functions can require different auth modes: just give each its own `withSupabase(...)`: + +```ts +import { createServerFn } from '@tanstack/react-start' +import { withSupabase } from '@supabase/server/adapters/tanstack-start' + +// User-authenticated +export const listTodos = createServerFn() + .middleware([withSupabase({ auth: 'user' })]) + .handler(async ({ context }) => { + const { data } = await context.supabaseContext.supabase + .from('todos') + .select() + return data + }) + +// Secret-key-protected (e.g. a trusted server-to-server call) +export const syncAuditLog = createServerFn({ method: 'POST' }) + .middleware([withSupabase({ auth: 'secret' })]) + .handler(async ({ context }) => { + const { data } = await context.supabaseContext.supabaseAdmin + .from('audit_log') + .insert({ action: 'sync' }) + return data + }) +``` + +> **Mixed auth modes:** attach `withSupabase` **per function** as shown above rather than registering it as global `functionMiddleware`. Global function middleware runs before per-function middleware, so an app-wide `withSupabase({ auth: 'user' })` would reject a request that only carries a secret key before the function's own stricter/looser middleware ever runs. Register globally only when every server function shares one auth mode. + +## Error handling + +When auth fails, the middleware throws the package's `AuthError`, which carries an HTTP `status` (`401` for invalid credentials, `500` for server-side auth failures) and a machine-readable `code`. + +The most robust place to handle it is **server-side** (a route's `beforeLoad` or loader), where the thrown value is reliably an `AuthError` instance: + +```ts +import { createFileRoute, redirect } from '@tanstack/react-router' +import { AuthError } from '@supabase/server' +import { getTodos } from './todos.server' + +export const Route = createFileRoute('/todos')({ + loader: async () => { + try { + return { todos: await getTodos() } + } catch (error) { + if (error instanceof AuthError && error.status === 401) { + throw redirect({ to: '/login' }) + } + throw error + } + }, +}) +``` + +> **Client-invoked server functions:** TanStack Start serializes thrown errors across the RPC boundary. When a server function is called from a client component (e.g. via `useServerFn`), the `AuthError` message survives but the prototype and custom fields (`instanceof AuthError`, `.status`, `.code`) may not be reconstructed on the client. Catch the error in a server-side `beforeLoad`/loader, where fidelity is guaranteed, and map it to a `redirect()` or your own response shape there. + +## Environment overrides + +Pass `env` to override auto-detected environment variables, same as the main wrapper: + +```ts +createServerFn() + .middleware([ + withSupabase({ auth: 'user', env: { url: 'http://localhost:54321' } }), + ]) + .handler(async ({ context }) => context.supabaseContext.userClaims) +``` + +## Supabase client options + +Forward options to the underlying `createClient()` calls: + +```ts +createServerFn() + .middleware([ + withSupabase({ + auth: 'user', + supabaseOptions: { db: { schema: 'api' } }, + }), + ]) + .handler(async ({ context }) => { + const { data } = await context.supabaseContext.supabase + .from('todos') + .select() + return data + }) +``` + +## CORS + +The adapter does not handle CORS: the `cors` option is excluded from its config type. TanStack Start server functions are same-origin RPC calls; if you expose server routes that need CORS, set the headers in those route handlers. diff --git a/jsr.json b/jsr.json index 8a4dca0..282453f 100644 --- a/jsr.json +++ b/jsr.json @@ -7,7 +7,8 @@ "./adapters/hono": "./src/adapters/hono/index.ts", "./adapters/h3": "./src/adapters/h3/index.ts", "./adapters/elysia": "./src/adapters/elysia/index.ts", - "./adapters/nestjs": "./src/adapters/nestjs/index.ts" + "./adapters/nestjs": "./src/adapters/nestjs/index.ts", + "./adapters/tanstack-start": "./src/adapters/tanstack-start/index.ts" }, "publish": { "include": ["src/**/*.ts", "README.md", "LICENSE"], diff --git a/package.json b/package.json index d68c177..dccd2b1 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,11 @@ "import": "./dist/adapters/nestjs/index.mjs", "require": "./dist/adapters/nestjs/index.cjs" }, + "./adapters/tanstack-start": { + "types": "./dist/adapters/tanstack-start/index.d.mts", + "import": "./dist/adapters/tanstack-start/index.mjs", + "require": "./dist/adapters/tanstack-start/index.cjs" + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", @@ -82,14 +87,22 @@ "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@supabase/supabase-js": "^2.0.0", + "@tanstack/start-client-core": "^1.170.0", + "@tanstack/start-server-core": "^1.169.0", + "elysia": "^1.4.0", "h3": "^2.0.0", - "hono": "^4.0.0", - "elysia": "^1.4.0" + "hono": "^4.0.0" }, "peerDependenciesMeta": { "@nestjs/common": { "optional": true }, + "@tanstack/start-client-core": { + "optional": true + }, + "@tanstack/start-server-core": { + "optional": true + }, "h3": { "optional": true }, @@ -110,9 +123,11 @@ "@nestjs/testing": "^11.1.19", "@supabase/supabase-js": "^2.105.4", "@swc/core": "^1.15.33", + "@tanstack/start-client-core": "^1.170.4", + "@tanstack/start-server-core": "^1.169.4", "@types/supertest": "^7.2.0", - "eslint": "^10.0.2", "elysia": "^1.4.0", + "eslint": "^10.0.2", "h3": "2.0.1-rc.20", "hono": "^4.12.5", "prettier": "3.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1144c0..5c69fa1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,12 @@ importers: '@swc/core': specifier: ^1.15.33 version: 1.15.33 + '@tanstack/start-client-core': + specifier: ^1.170.4 + version: 1.170.4 + '@tanstack/start-server-core': + specifier: ^1.169.4 + version: 1.169.4 '@types/supertest': specifier: ^7.2.0 version: 7.2.0 @@ -918,6 +924,30 @@ packages: '@swc/types@0.1.26': resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==} + '@tanstack/history@1.162.0': + resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} + engines: {node: '>=20.19'} + + '@tanstack/router-core@1.171.6': + resolution: {integrity: sha512-Ol6DQ+j6rf/rPVELIzo8LHwOQV2KL+zry3b+39kL/GKrt7YId52WJRAFMzuseY4XceSW+PU7sG/Cc1QkwJr0hg==} + engines: {node: '>=20.19'} + + '@tanstack/start-client-core@1.170.4': + resolution: {integrity: sha512-j/Deupf0zR7P5QObN38xTHufCRZkWTb6a/7aauu8eBmzOzDVggvuEdYHRZWiwJ9HRKbR2/SIJASVKeTtj1OcWw==} + engines: {node: '>=22.12.0'} + + '@tanstack/start-fn-stubs@1.162.0': + resolution: {integrity: sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ==} + engines: {node: '>=22.12.0'} + + '@tanstack/start-server-core@1.169.4': + resolution: {integrity: sha512-iM3HamWRQPROuAb+22frV/+GkqG2a3rL0X14N+Y0Dt5OajrIumPuprOn9ldUXsbdg89RTBf1KoJNDPeYGOqH4g==} + engines: {node: '>=22.12.0'} + + '@tanstack/start-storage-context@1.167.8': + resolution: {integrity: sha512-y9T+bIIp1ihLAXyS2+r+UovSupfu4KydSXpnoeRsw/14/E0huJsX7xB/n6XXOdmDYAaJ2WGOrG9wYjzeIDuBAw==} + engines: {node: '>=22.12.0'} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -1224,6 +1254,9 @@ packages: engines: {node: '>=18'} hasBin: true + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -1500,6 +1533,9 @@ packages: picomatch: optional: true + fetchdts@0.1.7: + resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2123,6 +2159,16 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -3165,6 +3211,40 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@tanstack/history@1.162.0': {} + + '@tanstack/router-core@1.171.6': + dependencies: + '@tanstack/history': 1.162.0 + cookie-es: 3.1.1 + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + + '@tanstack/start-client-core@1.170.4': + dependencies: + '@tanstack/router-core': 1.171.6 + '@tanstack/start-fn-stubs': 1.162.0 + '@tanstack/start-storage-context': 1.167.8 + seroval: 1.5.4 + + '@tanstack/start-fn-stubs@1.162.0': {} + + '@tanstack/start-server-core@1.169.4': + dependencies: + '@tanstack/history': 1.162.0 + '@tanstack/router-core': 1.171.6 + '@tanstack/start-client-core': 1.170.4 + '@tanstack/start-storage-context': 1.167.8 + fetchdts: 0.1.7 + h3-v2: h3@2.0.1-rc.20 + seroval: 1.5.4 + transitivePeerDependencies: + - crossws + + '@tanstack/start-storage-context@1.167.8': + dependencies: + '@tanstack/router-core': 1.171.6 + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -3508,6 +3588,8 @@ snapshots: dependencies: meow: 13.2.0 + cookie-es@3.1.1: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -3820,6 +3902,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fetchdts@0.1.7: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -4417,6 +4501,12 @@ snapshots: transitivePeerDependencies: - supports-color + seroval-plugins@1.5.4(seroval@1.5.4): + dependencies: + seroval: 1.5.4 + + seroval@1.5.4: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 diff --git a/src/adapters/README.md b/src/adapters/README.md index 31edc07..08b9dbe 100644 --- a/src/adapters/README.md +++ b/src/adapters/README.md @@ -4,12 +4,13 @@ You're in the adapter source folder. Framework adapters wrap `withSupabase` and ## Available adapters -| Framework | Import | Framework version | Docs | -| --------- | ---------------------------------- | ---------------------- | -------------------------------------------------------- | -| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](../../docs/adapters/hono.md) | -| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](../../docs/adapters/h3.md) | -| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](../../docs/adapters/elysia.md) | -| NestJS | `@supabase/server/adapters/nestjs` | `^10.0.0 \|\| ^11.0.0` | [docs/adapters/nestjs.md](../../docs/adapters/nestjs.md) | +| Framework | Import | Framework version | Docs | +| -------------- | ------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | +| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](../../docs/adapters/hono.md) | +| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](../../docs/adapters/h3.md) | +| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](../../docs/adapters/elysia.md) | +| NestJS | `@supabase/server/adapters/nestjs` | `^10.0.0 \|\| ^11.0.0` | [docs/adapters/nestjs.md](../../docs/adapters/nestjs.md) | +| TanStack Start | `@supabase/server/adapters/tanstack-start` | `@tanstack/start-client-core ^1.170.0`, `@tanstack/start-server-core ^1.169.0` | [docs/adapters/tanstack-start.md](../../docs/adapters/tanstack-start.md) | The framework version reflects what the adapter is tested against. It must match the corresponding entry in [`package.json#peerDependencies`](../../package.json) — if you bump the peer-dep range, update this table too. diff --git a/src/adapters/tanstack-start/index.ts b/src/adapters/tanstack-start/index.ts new file mode 100644 index 0000000..e1d1026 --- /dev/null +++ b/src/adapters/tanstack-start/index.ts @@ -0,0 +1,7 @@ +/** + * TanStack Start framework adapter for `@supabase/server`. + * + * @packageDocumentation + */ + +export { withSupabase } from './middleware.js' diff --git a/src/adapters/tanstack-start/middleware.test.ts b/src/adapters/tanstack-start/middleware.test.ts new file mode 100644 index 0000000..7b9c110 --- /dev/null +++ b/src/adapters/tanstack-start/middleware.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from 'vitest' + +import { AuthError } from '../../errors.js' +import type { SupabaseContext } from '../../types.js' + +// getRequest() reads the active request from start-server-core's module-level +// context, which only exists inside a running server. Mock it so we can drive +// the middleware in isolation with a crafted Request. +const { getRequestMock } = vi.hoisted(() => ({ + getRequestMock: vi.fn<() => Request>(), +})) +vi.mock('@tanstack/start-server-core', () => ({ getRequest: getRequestMock })) + +import { withSupabase } from './middleware.js' + +const env = { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: { default: 'sb_secret_abc' }, + jwks: null, +} + +/** + * Invokes the middleware's server handler with the given request and returns + * the context that was passed to `next()`. Rejects if the middleware throws. + */ +async function run( + middleware: ReturnType, + request: Request, +): Promise<{ supabaseContext: SupabaseContext }> { + getRequestMock.mockReturnValue(request) + const server = middleware.options.server as (opts: { + next: (ctx?: { context?: unknown }) => Promise + }) => Promise + + let captured: { supabaseContext: SupabaseContext } | undefined + await server({ + next: async (ctx) => { + captured = ctx?.context as { supabaseContext: SupabaseContext } + return { context: ctx?.context } + }, + }) + return captured! +} + +describe('tanstack-start supabase middleware', () => { + it('builds supabase context on successful auth (none)', async () => { + const ctx = await run( + withSupabase({ auth: 'none', env }), + new Request('http://localhost/'), + ) + expect(ctx.supabaseContext.authMode).toBe('none') + expect(ctx.supabaseContext.supabase).toBeTruthy() + expect(ctx.supabaseContext.supabaseAdmin).toBeTruthy() + }) + + it('accepts a valid publishable key', async () => { + const ctx = await run( + withSupabase({ auth: 'publishable', env }), + new Request('http://localhost/', { + headers: { apikey: 'sb_publishable_xyz' }, + }), + ) + expect(ctx.supabaseContext.authMode).toBe('publishable') + }) + + it('accepts a valid secret key', async () => { + const ctx = await run( + withSupabase({ auth: 'secret', env }), + new Request('http://localhost/', { + headers: { apikey: 'sb_secret_abc' }, + }), + ) + expect(ctx.supabaseContext.authMode).toBe('secret') + }) + + it('supports the array form of auth modes (first match wins)', async () => { + const ctx = await run( + withSupabase({ auth: ['secret', 'publishable'], env }), + new Request('http://localhost/', { + headers: { apikey: 'sb_publishable_xyz' }, + }), + ) + expect(ctx.supabaseContext.authMode).toBe('publishable') + }) + + it('throws AuthError when user auth has no token', async () => { + await expect( + run( + withSupabase({ auth: 'user', env }), + new Request('http://localhost/'), + ), + ).rejects.toMatchObject({ + name: 'AuthError', + status: 401, + code: 'INVALID_CREDENTIALS', + }) + }) + + it('throws an AuthError instance carrying status and code', async () => { + const error = await run( + withSupabase({ auth: 'publishable', env }), + new Request('http://localhost/'), + ).catch((e: unknown) => e) + expect(error).toBeInstanceOf(AuthError) + expect((error as AuthError).status).toBe(401) + }) + + it('throws when publishable auth is missing the apikey header', async () => { + await expect( + run( + withSupabase({ auth: 'publishable', env }), + new Request('http://localhost/'), + ), + ).rejects.toBeInstanceOf(AuthError) + }) +}) diff --git a/src/adapters/tanstack-start/middleware.ts b/src/adapters/tanstack-start/middleware.ts new file mode 100644 index 0000000..8e04a80 --- /dev/null +++ b/src/adapters/tanstack-start/middleware.ts @@ -0,0 +1,61 @@ +import { createMiddleware } from '@tanstack/start-client-core' +import type { FunctionMiddlewareAfterServer } from '@tanstack/start-client-core' +import { getRequest } from '@tanstack/start-server-core' + +import { createSupabaseContext } from '../../create-supabase-context.js' +import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' + +// Explicit return type so JSR's "slow types" check stays happy: the inferred +// builder type is otherwise too deep to serialize. The generic slots mirror +// `createMiddleware({ type: 'function' }).server(...)` with no upstream +// middleware, validator, or client/send context: only the server context the +// `.server()` handler adds (`supabaseContext`) is populated. `{}` matches the +// `TRegister` default baked into `createMiddleware`. +/* eslint-disable @typescript-eslint/no-empty-object-type */ +type SupabaseFunctionMiddleware = FunctionMiddlewareAfterServer< + {}, + unknown, + undefined, + { supabaseContext: SupabaseContext }, + undefined, + undefined, + undefined +> +/* eslint-enable @typescript-eslint/no-empty-object-type */ + +/** + * TanStack Start function middleware that creates a {@link SupabaseContext} and + * exposes it as `context.supabaseContext` in server function handlers. + * + * Attach it to a server function with `.middleware([withSupabase(...)])`; the + * context is then available (and typed) in the `.handler`. Throws the + * package's {@link AuthError} on auth failure (carrying `.status` and `.code`), + * which is reliably an `AuthError` instance when caught server-side (route + * `beforeLoad`/loaders); across a client-invoked server function, TanStack's + * RPC error serialization may flatten it to a plain error. + * + * @param config - Auth modes and optional environment overrides. CORS is excluded. + * @returns A TanStack Start function middleware. + * + * @example + * ```ts + * import { createServerFn } from '@tanstack/react-start' + * import { withSupabase } from '@supabase/server/adapters/tanstack-start' + * + * export const getProfile = createServerFn() + * .middleware([withSupabase({ auth: 'user' })]) + * .handler(async ({ context }) => { + * const { data } = await context.supabaseContext.supabase.rpc('get_profile') + * return data + * }) + * ``` + */ +export function withSupabase( + config?: Omit, +): SupabaseFunctionMiddleware { + return createMiddleware({ type: 'function' }).server(async ({ next }) => { + const { data, error } = await createSupabaseContext(getRequest(), config) + if (error) throw error + return next({ context: { supabaseContext: data } }) + }) +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 4adae59..cfead7c 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -8,8 +8,17 @@ export default defineConfig({ 'src/adapters/h3/index.ts', 'src/adapters/elysia/index.ts', 'src/adapters/nestjs/index.ts', + 'src/adapters/tanstack-start/index.ts', ], format: ['esm', 'cjs'], dts: true, - external: ['@supabase/supabase-js', 'hono', 'h3', 'elysia', '@nestjs/common'], + external: [ + '@supabase/supabase-js', + 'hono', + 'h3', + 'elysia', + '@nestjs/common', + '@tanstack/start-client-core', + '@tanstack/start-server-core', + ], }) From 42031d4d12bca4702bcefbf46ee35b7088d1ad68 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 4 Jun 2026 16:08:51 -0700 Subject: [PATCH 2/2] chore: switch to request middleware first --- README.md | 14 ++-- docs/adapters/tanstack-start.md | 46 ++++++++--- package.json | 5 -- pnpm-lock.yaml | 38 +-------- src/adapters/README.md | 14 ++-- .../tanstack-start/middleware.test.ts | 39 ++++++---- src/adapters/tanstack-start/middleware.ts | 77 ++++++++++++------- tsdown.config.ts | 1 - 8 files changed, 128 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index fa57077..74f2d94 100644 --- a/README.md +++ b/README.md @@ -266,13 +266,13 @@ Adapters wrap `withSupabase` for a specific framework's middleware contract. The > **Adapters are a community-driven initiative.** They're developed, maintained, and evolved by contributors — including responding to upstream framework changes. See [`src/adapters/README.md`](src/adapters/README.md) for the contribution requirements (tests, types, docs, build wiring) if you'd like to add or help maintain one. -| Framework | Import | Framework version | Docs | -| -------------- | ------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------------ | -| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](docs/adapters/hono.md) | -| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](docs/adapters/h3.md) | -| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](docs/adapters/elysia.md) | -| NestJS | `@supabase/server/adapters/nestjs` | `^10.0.0 \|\| ^11.0.0` | [docs/adapters/nestjs.md](docs/adapters/nestjs.md) | -| TanStack Start | `@supabase/server/adapters/tanstack-start` | `@tanstack/start-{client,server}-core ^1.170.0` / `^1.169.0` | [docs/adapters/tanstack-start.md](docs/adapters/tanstack-start.md) | +| Framework | Import | Framework version | Docs | +| -------------- | ------------------------------------------ | -------------------------------------- | ------------------------------------------------------------------ | +| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](docs/adapters/hono.md) | +| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](docs/adapters/h3.md) | +| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](docs/adapters/elysia.md) | +| NestJS | `@supabase/server/adapters/nestjs` | `^10.0.0 \|\| ^11.0.0` | [docs/adapters/nestjs.md](docs/adapters/nestjs.md) | +| TanStack Start | `@supabase/server/adapters/tanstack-start` | `@tanstack/start-client-core ^1.170.0` | [docs/adapters/tanstack-start.md](docs/adapters/tanstack-start.md) | See the per-adapter docs above for setup, per-route auth, CORS, error handling, and other patterns. diff --git a/docs/adapters/tanstack-start.md b/docs/adapters/tanstack-start.md index 99bbb2e..4938a05 100644 --- a/docs/adapters/tanstack-start.md +++ b/docs/adapters/tanstack-start.md @@ -1,18 +1,18 @@ # TanStack Start Adapter -The adapter exposes `withSupabase` as a TanStack Start **function middleware**. Attach it to a server function and the `SupabaseContext` is available (and typed) as `context.supabaseContext` in the handler. +The adapter exposes `withSupabase` as a TanStack Start **request middleware**. Because request middleware runs on every server request, the same middleware works for both server functions and server routes; in either, the `SupabaseContext` is available (and typed) as `context.supabaseContext` in the handler. -It is framework-agnostic: it imports from `@tanstack/start-client-core` and `@tanstack/start-server-core`, which every `@tanstack/{react,solid,vue}-start` package re-exports. So the same adapter works for React, Solid, and Vue Start. +It is framework-agnostic: it imports from `@tanstack/start-client-core`, which every `@tanstack/{react,solid,vue}-start` package re-exports. So the same adapter works for React, Solid, and Vue Start. ## Setup -You don't install the core packages directly; they come in with your framework's Start package, which you already have: +You don't install the core package directly; it comes in with your framework's Start package, which you already have: ```bash pnpm add @tanstack/react-start # or @tanstack/solid-start, @tanstack/vue-start ``` -## Basic server function with auth +## Server function ```ts import { createServerFn } from '@tanstack/react-start' @@ -30,14 +30,34 @@ export const getTodos = createServerFn() The context is available as `context.supabaseContext` and contains the same `SupabaseContext` fields as the main `withSupabase` wrapper: `supabase`, `supabaseAdmin`, `userClaims`, `jwtClaims`, and `authMode`. -## Per-function auth +## Server route -Because middleware is attached per server function, different functions can require different auth modes: just give each its own `withSupabase(...)`: +The same middleware attaches to a server route's `server.middleware`: ```ts -import { createServerFn } from '@tanstack/react-start' +import { createFileRoute } from '@tanstack/react-router' import { withSupabase } from '@supabase/server/adapters/tanstack-start' +export const Route = createFileRoute('/api/todos')({ + server: { + middleware: [withSupabase({ auth: 'user' })], + handlers: { + GET: async ({ context }) => { + const { data } = await context.supabaseContext.supabase + .from('todos') + .select() + return Response.json(data) + }, + }, + }, +}) +``` + +## Per-route auth + +Because middleware is attached per server function or per route, each one can require a different auth mode: just give it its own `withSupabase(...)`. + +```ts // User-authenticated export const listTodos = createServerFn() .middleware([withSupabase({ auth: 'user' })]) @@ -59,13 +79,17 @@ export const syncAuditLog = createServerFn({ method: 'POST' }) }) ``` -> **Mixed auth modes:** attach `withSupabase` **per function** as shown above rather than registering it as global `functionMiddleware`. Global function middleware runs before per-function middleware, so an app-wide `withSupabase({ auth: 'user' })` would reject a request that only carries a secret key before the function's own stricter/looser middleware ever runs. Register globally only when every server function shares one auth mode. +## Skip behavior + +If a previous middleware already resolved `context.supabaseContext`, subsequent `withSupabase` calls skip auth and preserve the established context. The first middleware to run wins, matching the Hono and H3 adapters. + +Request middleware runs outer-to-inner: a globally registered `withSupabase` (via `createStart`'s `requestMiddleware`) runs before a per-function or per-route one. So if you need different auth modes for different routes, attach `withSupabase` per route rather than globally; reserve global registration for when every request shares one auth mode. ## Error handling -When auth fails, the middleware throws the package's `AuthError`, which carries an HTTP `status` (`401` for invalid credentials, `500` for server-side auth failures) and a machine-readable `code`. +When auth fails, the middleware throws the package's `AuthError`, which carries an HTTP `status` (`401` for invalid credentials, `500` for server-side auth failures) and a machine-readable `code`. The handler only runs on successful auth. -The most robust place to handle it is **server-side** (a route's `beforeLoad` or loader), where the thrown value is reliably an `AuthError` instance: +The most robust place to handle it is server-side (a route's `beforeLoad` or loader), where the thrown value is reliably an `AuthError` instance: ```ts import { createFileRoute, redirect } from '@tanstack/react-router' @@ -122,4 +146,4 @@ createServerFn() ## CORS -The adapter does not handle CORS: the `cors` option is excluded from its config type. TanStack Start server functions are same-origin RPC calls; if you expose server routes that need CORS, set the headers in those route handlers. +The adapter does not handle CORS: the `cors` option is excluded from its config type. Set any required CORS headers in your server route handlers (server functions are same-origin RPC calls and don't need it). diff --git a/package.json b/package.json index dccd2b1..79a51ae 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,6 @@ "@nestjs/common": "^10.0.0 || ^11.0.0", "@supabase/supabase-js": "^2.0.0", "@tanstack/start-client-core": "^1.170.0", - "@tanstack/start-server-core": "^1.169.0", "elysia": "^1.4.0", "h3": "^2.0.0", "hono": "^4.0.0" @@ -100,9 +99,6 @@ "@tanstack/start-client-core": { "optional": true }, - "@tanstack/start-server-core": { - "optional": true - }, "h3": { "optional": true }, @@ -124,7 +120,6 @@ "@supabase/supabase-js": "^2.105.4", "@swc/core": "^1.15.33", "@tanstack/start-client-core": "^1.170.4", - "@tanstack/start-server-core": "^1.169.4", "@types/supertest": "^7.2.0", "elysia": "^1.4.0", "eslint": "^10.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c69fa1..b6628c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,9 +42,6 @@ importers: '@tanstack/start-client-core': specifier: ^1.170.4 version: 1.170.4 - '@tanstack/start-server-core': - specifier: ^1.169.4 - version: 1.169.4 '@types/supertest': specifier: ^7.2.0 version: 7.2.0 @@ -940,10 +937,6 @@ packages: resolution: {integrity: sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ==} engines: {node: '>=22.12.0'} - '@tanstack/start-server-core@1.169.4': - resolution: {integrity: sha512-iM3HamWRQPROuAb+22frV/+GkqG2a3rL0X14N+Y0Dt5OajrIumPuprOn9ldUXsbdg89RTBf1KoJNDPeYGOqH4g==} - engines: {node: '>=22.12.0'} - '@tanstack/start-storage-context@1.167.8': resolution: {integrity: sha512-y9T+bIIp1ihLAXyS2+r+UovSupfu4KydSXpnoeRsw/14/E0huJsX7xB/n6XXOdmDYAaJ2WGOrG9wYjzeIDuBAw==} engines: {node: '>=22.12.0'} @@ -1533,9 +1526,6 @@ packages: picomatch: optional: true - fetchdts@0.1.7: - resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==} - file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1883,10 +1873,6 @@ packages: engines: {node: '>=4.0.0'} hasBin: true - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} - engines: {node: 18 || 20 || >=22} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -2786,7 +2772,7 @@ snapshots: dependencies: '@eslint/object-schema': 3.0.2 debug: 4.4.3 - minimatch: 10.2.4 + minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -3229,18 +3215,6 @@ snapshots: '@tanstack/start-fn-stubs@1.162.0': {} - '@tanstack/start-server-core@1.169.4': - dependencies: - '@tanstack/history': 1.162.0 - '@tanstack/router-core': 1.171.6 - '@tanstack/start-client-core': 1.170.4 - '@tanstack/start-storage-context': 1.167.8 - fetchdts: 0.1.7 - h3-v2: h3@2.0.1-rc.20 - seroval: 1.5.4 - transitivePeerDependencies: - - crossws - '@tanstack/start-storage-context@1.167.8': dependencies: '@tanstack/router-core': 1.171.6 @@ -3367,7 +3341,7 @@ snapshots: '@typescript-eslint/types': 8.56.1 '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3 - minimatch: 10.2.4 + minimatch: 10.2.5 semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -3778,7 +3752,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.4 + minimatch: 10.2.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -3902,8 +3876,6 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - fetchdts@0.1.7: {} - file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -4201,10 +4173,6 @@ snapshots: mime@2.6.0: {} - minimatch@10.2.4: - dependencies: - brace-expansion: 5.0.5 - minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 diff --git a/src/adapters/README.md b/src/adapters/README.md index 08b9dbe..a646139 100644 --- a/src/adapters/README.md +++ b/src/adapters/README.md @@ -4,13 +4,13 @@ You're in the adapter source folder. Framework adapters wrap `withSupabase` and ## Available adapters -| Framework | Import | Framework version | Docs | -| -------------- | ------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | -| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](../../docs/adapters/hono.md) | -| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](../../docs/adapters/h3.md) | -| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](../../docs/adapters/elysia.md) | -| NestJS | `@supabase/server/adapters/nestjs` | `^10.0.0 \|\| ^11.0.0` | [docs/adapters/nestjs.md](../../docs/adapters/nestjs.md) | -| TanStack Start | `@supabase/server/adapters/tanstack-start` | `@tanstack/start-client-core ^1.170.0`, `@tanstack/start-server-core ^1.169.0` | [docs/adapters/tanstack-start.md](../../docs/adapters/tanstack-start.md) | +| Framework | Import | Framework version | Docs | +| -------------- | ------------------------------------------ | -------------------------------------- | ------------------------------------------------------------------------ | +| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](../../docs/adapters/hono.md) | +| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](../../docs/adapters/h3.md) | +| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](../../docs/adapters/elysia.md) | +| NestJS | `@supabase/server/adapters/nestjs` | `^10.0.0 \|\| ^11.0.0` | [docs/adapters/nestjs.md](../../docs/adapters/nestjs.md) | +| TanStack Start | `@supabase/server/adapters/tanstack-start` | `@tanstack/start-client-core ^1.170.0` | [docs/adapters/tanstack-start.md](../../docs/adapters/tanstack-start.md) | The framework version reflects what the adapter is tested against. It must match the corresponding entry in [`package.json#peerDependencies`](../../package.json) — if you bump the peer-dep range, update this table too. diff --git a/src/adapters/tanstack-start/middleware.test.ts b/src/adapters/tanstack-start/middleware.test.ts index 7b9c110..80a39f5 100644 --- a/src/adapters/tanstack-start/middleware.test.ts +++ b/src/adapters/tanstack-start/middleware.test.ts @@ -1,16 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { AuthError } from '../../errors.js' import type { SupabaseContext } from '../../types.js' - -// getRequest() reads the active request from start-server-core's module-level -// context, which only exists inside a running server. Mock it so we can drive -// the middleware in isolation with a crafted Request. -const { getRequestMock } = vi.hoisted(() => ({ - getRequestMock: vi.fn<() => Request>(), -})) -vi.mock('@tanstack/start-server-core', () => ({ getRequest: getRequestMock })) - import { withSupabase } from './middleware.js' const env = { @@ -21,20 +12,30 @@ const env = { } /** - * Invokes the middleware's server handler with the given request and returns - * the context that was passed to `next()`. Rejects if the middleware throws. + * Invokes the request middleware's server handler with the given request (and + * optional pre-existing context) and returns the context passed to `next()`. + * Rejects if the middleware throws. Request middleware receives the `Request` + * directly in its handler args, so no module-level mocking is required. */ async function run( middleware: ReturnType, request: Request, + context: Record = {}, ): Promise<{ supabaseContext: SupabaseContext }> { - getRequestMock.mockReturnValue(request) const server = middleware.options.server as (opts: { + request: Request + pathname: string + context: unknown + handlerType: 'serverFn' | 'router' next: (ctx?: { context?: unknown }) => Promise }) => Promise let captured: { supabaseContext: SupabaseContext } | undefined await server({ + request, + pathname: new URL(request.url).pathname, + context, + handlerType: 'serverFn', next: async (ctx) => { captured = ctx?.context as { supabaseContext: SupabaseContext } return { context: ctx?.context } @@ -114,4 +115,16 @@ describe('tanstack-start supabase middleware', () => { ), ).rejects.toBeInstanceOf(AuthError) }) + + it('skips auth when a prior middleware already set the context', async () => { + const existing = { authMode: 'none' } as unknown as SupabaseContext + // `secret` would fail without an apikey header, but the skip means the + // auth flow never runs and the already-established context is preserved. + const ctx = await run( + withSupabase({ auth: 'secret', env }), + new Request('http://localhost/'), + { supabaseContext: existing }, + ) + expect(ctx.supabaseContext).toBe(existing) + }) }) diff --git a/src/adapters/tanstack-start/middleware.ts b/src/adapters/tanstack-start/middleware.ts index 8e04a80..2585363 100644 --- a/src/adapters/tanstack-start/middleware.ts +++ b/src/adapters/tanstack-start/middleware.ts @@ -1,43 +1,37 @@ import { createMiddleware } from '@tanstack/start-client-core' -import type { FunctionMiddlewareAfterServer } from '@tanstack/start-client-core' -import { getRequest } from '@tanstack/start-server-core' +import type { RequestMiddlewareAfterServer } from '@tanstack/start-client-core' import { createSupabaseContext } from '../../create-supabase-context.js' import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' // Explicit return type so JSR's "slow types" check stays happy: the inferred -// builder type is otherwise too deep to serialize. The generic slots mirror -// `createMiddleware({ type: 'function' }).server(...)` with no upstream -// middleware, validator, or client/send context: only the server context the -// `.server()` handler adds (`supabaseContext`) is populated. `{}` matches the +// builder type is otherwise too deep to serialize. The slots mirror +// `createMiddleware().server(...)` with no upstream middleware: only the +// server context the handler adds (`supabaseContext`) is populated. `{}` is the // `TRegister` default baked into `createMiddleware`. -/* eslint-disable @typescript-eslint/no-empty-object-type */ -type SupabaseFunctionMiddleware = FunctionMiddlewareAfterServer< +type SupabaseRequestMiddleware = RequestMiddlewareAfterServer< + // eslint-disable-next-line @typescript-eslint/no-empty-object-type {}, - unknown, undefined, - { supabaseContext: SupabaseContext }, - undefined, - undefined, - undefined + { supabaseContext: SupabaseContext } > -/* eslint-enable @typescript-eslint/no-empty-object-type */ /** - * TanStack Start function middleware that creates a {@link SupabaseContext} and - * exposes it as `context.supabaseContext` in server function handlers. + * TanStack Start request middleware that creates a {@link SupabaseContext} and + * exposes it as `context.supabaseContext`. * - * Attach it to a server function with `.middleware([withSupabase(...)])`; the - * context is then available (and typed) in the `.handler`. Throws the - * package's {@link AuthError} on auth failure (carrying `.status` and `.code`), - * which is reliably an `AuthError` instance when caught server-side (route - * `beforeLoad`/loaders); across a client-invoked server function, TanStack's - * RPC error serialization may flatten it to a plain error. + * Request middleware runs on every server request (server functions, server + * routes, and SSR), so the same middleware covers both server functions + * (`createServerFn().middleware([withSupabase(...)])`) and server routes + * (`server: {{ middleware: [withSupabase(...)] }}`); in both, the context is + * typed in the handler. Skips if a previous middleware already set the context, + * enabling chaining without redundant auth. Throws the package's + * {@link AuthError} on failure (carrying `.status` and `.code`). * * @param config - Auth modes and optional environment overrides. CORS is excluded. - * @returns A TanStack Start function middleware. + * @returns A TanStack Start request middleware. * - * @example + * @example Server function * ```ts * import { createServerFn } from '@tanstack/react-start' * import { withSupabase } from '@supabase/server/adapters/tanstack-start' @@ -49,13 +43,42 @@ type SupabaseFunctionMiddleware = FunctionMiddlewareAfterServer< * return data * }) * ``` + * + * @example Server route + * ```ts + * import { createFileRoute } from '@tanstack/react-router' + * import { withSupabase } from '@supabase/server/adapters/tanstack-start' + * + * export const Route = createFileRoute('/api/todos')({ + * server: { + * middleware: [withSupabase({ auth: 'user' })], + * handlers: { + * GET: async ({ context }) => { + * const { data } = await context.supabaseContext.supabase + * .from('todos') + * .select() + * return Response.json(data) + * }, + * }, + * }, + * }) + * ``` */ export function withSupabase( config?: Omit, -): SupabaseFunctionMiddleware { - return createMiddleware({ type: 'function' }).server(async ({ next }) => { - const { data, error } = await createSupabaseContext(getRequest(), config) +): SupabaseRequestMiddleware { + return createMiddleware().server(async ({ request, context, next }) => { + // Skip if a previous middleware already resolved the context. This enables + // chaining without re-running auth, and keeps the first-established context + // (the first middleware to run wins, matching the Hono/H3 adapters). + const existing = ( + context as unknown as { supabaseContext?: SupabaseContext } + ).supabaseContext + if (existing) return next({ context: { supabaseContext: existing } }) + + const { data, error } = await createSupabaseContext(request, config) if (error) throw error + return next({ context: { supabaseContext: data } }) }) } diff --git a/tsdown.config.ts b/tsdown.config.ts index cfead7c..92d1b82 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -19,6 +19,5 @@ export default defineConfig({ 'elysia', '@nestjs/common', '@tanstack/start-client-core', - '@tanstack/start-server-core', ], })