Skip to content

Commit 0e747ac

Browse files
committed
feat: add plugins option to withSupabase
1 parent 69ec52d commit 0e747ac

5 files changed

Lines changed: 220 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@
134134
"vitest": "^4.1.0"
135135
},
136136
"dependencies": {
137+
"@supabase/web-middleware": "link:../web-middleware",
137138
"jose": "^6.2.0"
138139
}
139140
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ minimumReleaseAgeExclude:
77
- '@esbuild/*'
88
blockExoticSubdeps: true
99
allowBuilds:
10+
'@supabase/web-middleware': true
1011
'@nestjs/core': false
1112
'@swc/core': false
1213
esbuild: false

src/with-supabase.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { defineMiddleware } from '@supabase/web-middleware'
23

34
import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js'
45
import { withSupabase } from './with-supabase.js'
@@ -98,6 +99,127 @@ describe('withSupabase', () => {
9899
expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull()
99100
})
100101

102+
describe('plugins', () => {
103+
it('composes plugins after the Supabase context is established', async () => {
104+
const withFlag = defineMiddleware<
105+
'flag',
106+
void,
107+
Record<never, never>,
108+
boolean
109+
>({
110+
key: 'flag',
111+
run: () => async () => ({ flag: true }),
112+
})
113+
114+
const handler = withSupabase(
115+
{ auth: 'none', env: baseEnv, plugins: [withFlag()] },
116+
async (_req, ctx) =>
117+
Response.json({ authMode: ctx.authMode, flag: ctx.flag }),
118+
)
119+
120+
const res = await handler(new Request('http://localhost'))
121+
const body = await res.json()
122+
expect(body.authMode).toBe('none')
123+
expect(body.flag).toBe(true)
124+
})
125+
126+
it('plugin receives the Supabase context at runtime', async () => {
127+
let capturedHasSupabase = false
128+
129+
const withCapture = defineMiddleware<
130+
'captured',
131+
void,
132+
Record<never, never>,
133+
true
134+
>({
135+
key: 'captured',
136+
run: () => async (_req, ctx) => {
137+
capturedHasSupabase = !!(ctx as { supabase?: unknown }).supabase
138+
return { captured: true as const }
139+
},
140+
})
141+
142+
const handler = withSupabase(
143+
{ auth: 'none', env: baseEnv, plugins: [withCapture()] },
144+
async () => Response.json({ ok: true }),
145+
)
146+
147+
await handler(new Request('http://localhost'))
148+
expect(capturedHasSupabase).toBe(true)
149+
})
150+
151+
it('plugin can short-circuit before the handler', async () => {
152+
const withBlock = defineMiddleware<
153+
'blocked',
154+
void,
155+
Record<never, never>,
156+
true
157+
>({
158+
key: 'blocked',
159+
run: () => async () => new Response('blocked', { status: 403 }),
160+
})
161+
162+
const innerHandler = vi.fn(async () => Response.json({ ok: true }))
163+
164+
const handler = withSupabase(
165+
{ auth: 'none', env: baseEnv, plugins: [withBlock()] },
166+
innerHandler,
167+
)
168+
169+
const res = await handler(new Request('http://localhost'))
170+
expect(res.status).toBe(403)
171+
expect(innerHandler).not.toHaveBeenCalled()
172+
})
173+
174+
it('plugins run in array order (first = outermost, runs first on request)', async () => {
175+
const order: string[] = []
176+
177+
const withA = defineMiddleware<'a', void, Record<never, never>, true>({
178+
key: 'a',
179+
run: () => async () => {
180+
order.push('a')
181+
return { a: true as const }
182+
},
183+
})
184+
const withB = defineMiddleware<'b', void, Record<never, never>, true>({
185+
key: 'b',
186+
run: () => async () => {
187+
order.push('b')
188+
return { b: true as const }
189+
},
190+
})
191+
192+
const handler = withSupabase(
193+
{ auth: 'none', env: baseEnv, plugins: [withA(), withB()] },
194+
async (_req, ctx) => Response.json({ a: ctx.a, b: ctx.b }),
195+
)
196+
197+
const res = await handler(new Request('http://localhost'))
198+
expect(order).toEqual(['a', 'b'])
199+
expect(await res.json()).toEqual({ a: true, b: true })
200+
})
201+
202+
it('CORS headers still apply when plugins are present', async () => {
203+
const withNoop = defineMiddleware<
204+
'noop',
205+
void,
206+
Record<never, never>,
207+
true
208+
>({
209+
key: 'noop',
210+
run: () => async () => ({ noop: true as const }),
211+
})
212+
213+
const handler = withSupabase(
214+
{ auth: 'none', env: baseEnv, plugins: [withNoop()] },
215+
async () => Response.json({ ok: true }),
216+
)
217+
218+
const res = await handler(new Request('http://localhost'))
219+
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*')
220+
})
221+
})
222+
101223
describe('allow → auth deprecation', () => {
102224
beforeEach(() => {
103225
_resetAllowDeprecationWarned()

src/with-supabase.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
import { buildCorsHeaders, addCorsHeaders } from './cors.js'
22
import { createSupabaseContext } from './create-supabase-context.js'
33
import type { SupabaseContext, WithSupabaseConfig } from './types.js'
4+
import type { Entry } from '@supabase/web-middleware'
5+
6+
type AnyEntry = Entry<string, object, unknown>
7+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
8+
type AnyHandler = (req: Request, ctx: any) => Promise<Response>
9+
10+
/**
11+
* Accumulate the ctx contributions of a plugin tuple — same logic as
12+
* `pipeline`'s internal `Accumulate`, seeded from `object` (no `BaseContext`
13+
* or `_runtime` in the visible ctx type; see implementation note below).
14+
*/
15+
type PluginsCtx<Plugins extends readonly AnyEntry[]> =
16+
Plugins extends readonly [
17+
Entry<infer Key extends string, object, infer Contribution>,
18+
...infer Rest,
19+
]
20+
? Rest extends readonly AnyEntry[]
21+
? { [P in Key]: Contribution } & PluginsCtx<Rest>
22+
: { [P in Key]: Contribution }
23+
: object
424

525
/**
626
* Wraps a request handler with Supabase auth, client creation, and CORS handling.
@@ -17,6 +37,7 @@ import type { SupabaseContext, WithSupabaseConfig } from './types.js'
1737
* ```ts
1838
* import { withSupabase } from '@supabase/server'
1939
*
40+
* // Without plugins — existing API, unchanged.
2041
* export default {
2142
* fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
2243
* const { data } = await ctx.supabase.rpc('get_my_profile')
@@ -28,6 +49,55 @@ import type { SupabaseContext, WithSupabaseConfig } from './types.js'
2849
export function withSupabase<Database = unknown>(
2950
config: WithSupabaseConfig,
3051
handler: (req: Request, ctx: SupabaseContext<Database>) => Promise<Response>,
52+
): (req: Request) => Promise<Response>
53+
54+
/**
55+
* Variant that accepts a `plugins` array — each `withFoo(config)` call returns
56+
* an `Entry` from `@supabase/web-middleware`. Plugins run **after** the Supabase
57+
* context is established; they receive `ctx.supabase`, `ctx.userClaims`, etc.
58+
* already present and contribute their own typed keys on top.
59+
*
60+
* @example
61+
* ```ts
62+
* import { withSupabase } from '@supabase/server'
63+
* import { withGuestbook } from '@supabase/plugin-guestbook/server'
64+
* import { withRateLimit } from '@supabase/plugin-rate-limit/server'
65+
*
66+
* export default {
67+
* fetch: withSupabase(
68+
* { auth: 'user', plugins: [withRateLimit({ rpm: 100 }), withGuestbook()] },
69+
* async (req, ctx) => {
70+
* ctx.supabase // from @supabase/server
71+
* ctx.rateLimit // from withRateLimit
72+
* ctx.guestbook // from withGuestbook
73+
* return Response.json(await ctx.guestbook.list())
74+
* },
75+
* ),
76+
* }
77+
* ```
78+
*
79+
* **Type note.** `PluginsCtx<Plugins>` accumulates the key contributions of the
80+
* plugins array. Plugins that declare `In` prerequisites on Supabase-provided
81+
* keys (`supabase`, `userClaims`, …) satisfy those at runtime (the Supabase
82+
* context is merged before plugins run) but not at the type level — a full
83+
* implementation would widen the prerequisite-validation seed to include
84+
* `SupabaseContext`. Ordering and collision checks within the plugins array work
85+
* normally via `web-middleware`'s runtime chain.
86+
*/
87+
export function withSupabase<
88+
Database = unknown,
89+
const Plugins extends readonly AnyEntry[] = readonly AnyEntry[],
90+
>(
91+
config: WithSupabaseConfig & { plugins: Plugins },
92+
handler: (
93+
req: Request,
94+
ctx: SupabaseContext<Database> & PluginsCtx<Plugins>,
95+
) => Promise<Response>,
96+
): (req: Request) => Promise<Response>
97+
98+
export function withSupabase<Database = unknown>(
99+
config: WithSupabaseConfig & { plugins?: readonly AnyEntry[] },
100+
handler: AnyHandler,
31101
): (req: Request) => Promise<Response> {
32102
return async (req: Request) => {
33103
if (config.cors !== false && req.method === 'OPTIONS') {
@@ -51,7 +121,29 @@ export function withSupabase<Database = unknown>(
51121
)
52122
}
53123

54-
const response = await handler(req, ctx)
124+
let response: Response
125+
if (config.plugins?.length) {
126+
// Compose plugins around the handler — same fold as pipeline's reduceRight,
127+
// but without calling pipeline() so we supply the seeded ctx ourselves.
128+
const composed = (
129+
config.plugins as readonly AnyEntry[]
130+
).reduceRight<AnyHandler>((h, entry) => entry(h), handler)
131+
// Seed _runtime so web-middleware entries recognise this as an upstream
132+
// context (isContext() checks for _runtime.getEnv). Falls through to
133+
// process.env; a full implementation would bridge to SupabaseEnv.
134+
const g = globalThis as {
135+
process?: { env?: Record<string, string | undefined> }
136+
}
137+
response = await composed(req, {
138+
...ctx,
139+
_runtime: {
140+
name: 'unknown' as const,
141+
getEnv: (key: string): string | undefined => g.process?.env?.[key],
142+
},
143+
})
144+
} else {
145+
response = await handler(req, ctx as object)
146+
}
55147

56148
if (config.cors !== false) {
57149
return addCorsHeaders(response, config.cors)

0 commit comments

Comments
 (0)