diff --git a/README.md b/README.md index 5a7b8ca..74f2d94 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-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 new file mode 100644 index 0000000..4938a05 --- /dev/null +++ b/docs/adapters/tanstack-start.md @@ -0,0 +1,149 @@ +# TanStack Start Adapter + +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`, 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 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 +``` + +## Server function + +```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`. + +## Server route + +The same middleware attaches to a server route's `server.middleware`: + +```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) + }, + }, + }, +}) +``` + +## 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' })]) + .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 + }) +``` + +## 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`. 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: + +```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. 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/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..79a51ae 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,18 @@ "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@supabase/supabase-js": "^2.0.0", + "@tanstack/start-client-core": "^1.170.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 + }, "h3": { "optional": true }, @@ -110,9 +119,10 @@ "@nestjs/testing": "^11.1.19", "@supabase/supabase-js": "^2.105.4", "@swc/core": "^1.15.33", + "@tanstack/start-client-core": "^1.170.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..b6628c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@swc/core': specifier: ^1.15.33 version: 1.15.33 + '@tanstack/start-client-core': + specifier: ^1.170.4 + version: 1.170.4 '@types/supertest': specifier: ^7.2.0 version: 7.2.0 @@ -918,6 +921,26 @@ 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-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 +1247,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'} @@ -1847,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} @@ -2123,6 +2145,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'} @@ -2740,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 @@ -3165,6 +3197,28 @@ 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-storage-context@1.167.8': + dependencies: + '@tanstack/router-core': 1.171.6 + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -3287,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) @@ -3508,6 +3562,8 @@ snapshots: dependencies: meow: 13.2.0 + cookie-es@3.1.1: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -3696,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: @@ -4117,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 @@ -4417,6 +4469,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..a646139 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` | [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..80a39f5 --- /dev/null +++ b/src/adapters/tanstack-start/middleware.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' + +import { AuthError } from '../../errors.js' +import type { SupabaseContext } from '../../types.js' +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 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 }> { + 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 } + }, + }) + 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) + }) + + 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 new file mode 100644 index 0000000..2585363 --- /dev/null +++ b/src/adapters/tanstack-start/middleware.ts @@ -0,0 +1,84 @@ +import { createMiddleware } from '@tanstack/start-client-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 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`. +type SupabaseRequestMiddleware = RequestMiddlewareAfterServer< + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + {}, + undefined, + { supabaseContext: SupabaseContext } +> + +/** + * TanStack Start request middleware that creates a {@link SupabaseContext} and + * exposes it as `context.supabaseContext`. + * + * 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 request middleware. + * + * @example Server function + * ```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 + * }) + * ``` + * + * @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, +): 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 4adae59..92d1b82 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -8,8 +8,16 @@ 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', + ], })