mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 12:25:05 +03:00
Adds `compat/next/api.ts` exposing `toWebHandler(nextHandler)`: an
adapter that builds a NextApiRequest-shaped object from a Web
`Request` (lowercased headers, parsed cookies, merged query + route
params, JSON/urlencoded body parsing) and a NextApiResponse proxy
capturing `status` / `setHeader` / `json` / `send` / `write` / `end`
/ `redirect`. Handler output is buffered then returned as a Web
`Response`. Handlers that already return a `Response` (edge-runtime
style) pass through unchanged.
Each ported route in `routes/api/**` is a 9-line re-export: import
the default export from `pages/api/...`, wrap with `toWebHandler`,
register under `server.handlers.{GET,POST,...}`. `apiWrapper` and
`apiAuthenticate` stay untouched — they run inside the shim seeing
the shaped `req`/`res`.
Coverage: 88 of the 90 `pages/api/**` handlers. The two exceptions
need bespoke rewrites and are tracked in TANSTACK_MIGRATION.md:
- `v1/projects/[ref]/functions/[slug]/body.ts` — pipes
`fs.createReadStream` into `res`; needs a Web-stream rewrite.
- `mcp/index.ts` — the MCP SDK transport expects Node req/res; need
to check for a Web-fetch transport option.
Also adds `query` (params + search) to the existing `next/router`
shim to unblock components like FeedbackDropdown that read
`router.query.ref`.
204 lines
6.4 KiB
TypeScript
204 lines
6.4 KiB
TypeScript
import type { NextApiRequest, NextApiResponse } from 'next'
|
|
|
|
// Adapter that lets Next.js pages-router API handlers run under TanStack
|
|
// Start server routes without rewriting them. The adapter builds a
|
|
// NextApiRequest-shaped object from the Web `Request`, plus a
|
|
// NextApiResponse proxy that captures `status` / `setHeader` / `json` /
|
|
// `send` / `write` / `end` / `redirect` and produces a Web `Response`.
|
|
//
|
|
// Limitations (acceptable for the bulk of studio API routes):
|
|
// - Body is buffered: streaming handlers that want to flush to the client
|
|
// before finishing will still buffer in memory. Studio has 2 such routes
|
|
// (functions body.ts, mcp/index.ts) that need bespoke handling.
|
|
// - Body parsing covers JSON and urlencoded. Multipart/raw binary inbound
|
|
// is not implemented — no studio route reads multipart in.
|
|
|
|
type NextHandler = (req: NextApiRequest, res: NextApiResponse) => unknown | Promise<unknown>
|
|
|
|
interface RouteCtx {
|
|
request: Request
|
|
params?: Record<string, string | undefined>
|
|
}
|
|
|
|
export function toWebHandler(handler: NextHandler) {
|
|
return async ({ request, params = {} }: RouteCtx): Promise<Response> => {
|
|
const req = await buildRequest(request, params)
|
|
const { res, finalize } = buildResponse()
|
|
|
|
const result = await handler(req, res)
|
|
|
|
// Handlers like /api/ai/docs.ts already return a Web Response directly
|
|
// (they were written for the edge runtime). Pass it through.
|
|
if (result instanceof Response) return result
|
|
|
|
return finalize()
|
|
}
|
|
}
|
|
|
|
async function buildRequest(
|
|
request: Request,
|
|
params: Record<string, string | undefined>
|
|
): Promise<NextApiRequest> {
|
|
const url = new URL(request.url)
|
|
const method = request.method.toUpperCase()
|
|
|
|
const headers: Record<string, string | string[]> = {}
|
|
request.headers.forEach((value, key) => {
|
|
const k = key.toLowerCase()
|
|
const existing = headers[k]
|
|
if (existing === undefined) {
|
|
headers[k] = value
|
|
} else if (Array.isArray(existing)) {
|
|
existing.push(value)
|
|
} else {
|
|
headers[k] = [existing, value]
|
|
}
|
|
})
|
|
|
|
const cookies: Record<string, string> = {}
|
|
const cookieHeader = request.headers.get('cookie')
|
|
if (cookieHeader) {
|
|
for (const part of cookieHeader.split(';')) {
|
|
const trimmed = part.trim()
|
|
if (!trimmed) continue
|
|
const eq = trimmed.indexOf('=')
|
|
const name = eq >= 0 ? trimmed.slice(0, eq) : trimmed
|
|
const value = eq >= 0 ? trimmed.slice(eq + 1) : ''
|
|
cookies[name] = decodeURIComponent(value)
|
|
}
|
|
}
|
|
|
|
// Next merges route params and URL search params into `req.query`.
|
|
// Duplicate keys become arrays (URLSearchParams.getAll).
|
|
const query: Record<string, string | string[]> = {}
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v !== undefined) query[k] = v
|
|
}
|
|
for (const key of new Set(url.searchParams.keys())) {
|
|
const values = url.searchParams.getAll(key)
|
|
query[key] = values.length === 1 ? values[0] : values
|
|
}
|
|
|
|
let body: unknown = undefined
|
|
if (method !== 'GET' && method !== 'HEAD' && request.body) {
|
|
const contentType = request.headers.get('content-type') ?? ''
|
|
if (contentType.includes('application/json')) {
|
|
const text = await request.text()
|
|
body = text ? JSON.parse(text) : undefined
|
|
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
const text = await request.text()
|
|
body = Object.fromEntries(new URLSearchParams(text))
|
|
} else {
|
|
body = await request.text()
|
|
}
|
|
}
|
|
|
|
return {
|
|
method,
|
|
url: url.pathname + url.search,
|
|
headers,
|
|
query,
|
|
body,
|
|
cookies,
|
|
} as unknown as NextApiRequest
|
|
}
|
|
|
|
function buildResponse() {
|
|
let statusCode = 200
|
|
const responseHeaders = new Headers()
|
|
const chunks: Uint8Array[] = []
|
|
const encoder = new TextEncoder()
|
|
|
|
const encode = (data: unknown): Uint8Array => {
|
|
if (data instanceof Uint8Array) return data
|
|
if (typeof data === 'string') return encoder.encode(data)
|
|
if (data === undefined || data === null) return new Uint8Array(0)
|
|
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) {
|
|
return new Uint8Array(data)
|
|
}
|
|
return encoder.encode(String(data))
|
|
}
|
|
|
|
const res = {} as NextApiResponse & Record<string, unknown>
|
|
|
|
res.statusCode = 200
|
|
res.status = (code: number) => {
|
|
statusCode = code
|
|
res.statusCode = code
|
|
return res
|
|
}
|
|
res.setHeader = (name: string, value: number | string | readonly string[]) => {
|
|
const key = name.toLowerCase()
|
|
if (Array.isArray(value)) {
|
|
responseHeaders.delete(key)
|
|
for (const v of value) responseHeaders.append(key, String(v))
|
|
} else {
|
|
responseHeaders.set(key, String(value))
|
|
}
|
|
return res
|
|
}
|
|
res.getHeader = (name: string) => responseHeaders.get(name.toLowerCase()) ?? undefined
|
|
res.getHeaders = () => {
|
|
const out: Record<string, string> = {}
|
|
responseHeaders.forEach((v, k) => {
|
|
out[k] = v
|
|
})
|
|
return out
|
|
}
|
|
res.hasHeader = (name: string) => responseHeaders.has(name.toLowerCase())
|
|
res.removeHeader = (name: string) => {
|
|
responseHeaders.delete(name.toLowerCase())
|
|
}
|
|
res.json = (data: unknown) => {
|
|
if (!responseHeaders.has('content-type')) {
|
|
responseHeaders.set('content-type', 'application/json')
|
|
}
|
|
chunks.push(encode(JSON.stringify(data)))
|
|
return res
|
|
}
|
|
res.send = (data: unknown) => {
|
|
if (data === undefined || data === null) return res
|
|
if (
|
|
typeof data === 'object' &&
|
|
!(data instanceof Uint8Array) &&
|
|
!(typeof Buffer !== 'undefined' && Buffer.isBuffer(data))
|
|
) {
|
|
return res.json(data)
|
|
}
|
|
chunks.push(encode(data))
|
|
return res
|
|
}
|
|
res.write = (chunk: unknown) => {
|
|
chunks.push(encode(chunk))
|
|
return true
|
|
}
|
|
res.end = (data?: unknown) => {
|
|
if (data !== undefined) chunks.push(encode(data))
|
|
return res
|
|
}
|
|
res.redirect = (...args: unknown[]) => {
|
|
const [status, location] =
|
|
typeof args[0] === 'number' ? [args[0], args[1] as string] : [302, args[0] as string]
|
|
statusCode = status
|
|
res.statusCode = status
|
|
responseHeaders.set('location', location)
|
|
return res
|
|
}
|
|
|
|
const finalize = (): Response => {
|
|
const totalLen = chunks.reduce((n, c) => n + c.length, 0)
|
|
const payload = new Uint8Array(totalLen)
|
|
let offset = 0
|
|
for (const c of chunks) {
|
|
payload.set(c, offset)
|
|
offset += c.length
|
|
}
|
|
return new Response(payload.length === 0 ? null : payload, {
|
|
status: statusCode,
|
|
headers: responseHeaders,
|
|
})
|
|
}
|
|
|
|
return { res, finalize }
|
|
}
|