Files
supabase/apps/studio/compat/next/api.ts
T
Alaister Young 387e9b1385 chore(studio): migrate Next API routes to TanStack server routes
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`.
2026-04-21 19:04:59 +08:00

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 }
}