Files
supabase/apps/studio/vercel.ts

125 lines
5.8 KiB
TypeScript

import { routes, type Redirect, type VercelConfig } from '@vercel/config/v1'
import {
getMaintenanceRedirects,
PLATFORM_REDIRECTS,
SELF_HOSTED_REDIRECTS,
SHARED_REDIRECTS,
type StudioRedirect,
} from './redirects.shared'
import { getSecurityHeaders } from './security-headers'
// STUDIO_FRAMEWORK gates the TanStack Start deploy. When the env var is
// unset (the default — used by the Next.js prod deploy) this file returns
// an empty `VercelConfig` so Vercel honours the dashboard-configured
// Next.js preset untouched. Vercel reads `vercel.ts` regardless of the
// framework preset (per vercel.com/docs/project-configuration —
// `vercel.ts`'s `framework` field overrides the dashboard preset), so a
// no-op early return is the only way to keep `framework: null` and the
// headers below from clobbering the Next build. Set
// `STUDIO_FRAMEWORK=tanstack` on the TanStack Vercel project to opt in.
const isTanstack = process.env.STUDIO_FRAMEWORK === 'tanstack'
// Routing lives in Nitro's Build Output config (`.vercel/output/config.json`,
// shaped by scripts/vercel-spa-routes.ts). Vercel applies this file's
// `redirects`/`headers` ahead of it, which is also why no `rewrites` belong
// here: a catch-all rewrite at this layer swallows the API routes.
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
// Headers for a given prefix ('' for root, or a base path like '/dashboard').
// Run once per prefix and concatenated so no rule is hand-duplicated.
function headersFor(prefix: string) {
return [
// Security headers for every response. The Next build sets these via
// next.config.ts `headers()`; the TanStack build serves a static shell
// from the CDN, so they live at the edge. Matches next.config's `/(.*?)`
// block (CSP, X-Frame-Options, HSTS, etc.).
{ source: `${prefix}/(.*)`, headers: getSecurityHeaders() },
// Deployment metadata sets its own cache policy based on lookup success.
// All other API paths retain the private default, including nested paths.
routes.cacheControl(`${prefix}/api/((?!get-deployment-commit/?$).*)`, {
private: true,
noStore: true,
}),
routes.cacheControl(`${prefix}/_serverFn/(.*)`, { private: true, noStore: true }),
// Hashed chunks are covered by Nitro (`/_vercel/immutable/*`, immutable).
// Static images and favicons aren't content-hashed, so they can't be
// `immutable`, but they change rarely — mirror next.config's
// `cache-control` for these paths (img: max-age=2592000 = 30 days,
// favicon: max-age=86400 = 1 day).
routes.cacheControl(`${prefix}/img/(.*)`, { public: true, maxAge: '30days' }),
routes.cacheControl(`${prefix}/favicon/(.*)`, { public: true, maxAge: '1day' }),
// Deno declarations are public TypeScript assets. Keep this override
// scoped to /deno so dynamic response types remain handler-owned.
routes.header(`${prefix}/deno/(.*)\\.ts`, [{ key: 'content-type', value: 'text/typescript' }]),
]
}
// ---------------------------------------------------------------------------
// Redirects — entries live in `redirects.shared.ts`, consumed by both
// `next.config.ts` and this file. Next auto-prepends `basePath` to its
// redirects; Vercel doesn't, so we apply it here.
// ---------------------------------------------------------------------------
function applyBasePath(r: StudioRedirect): Redirect {
if (!basePath) return r
const prefix = (path: string) =>
path.startsWith('/') ? (path === '/' ? basePath : `${basePath}${path}`) : path
return { ...r, source: prefix(r.source), destination: prefix(r.destination) }
}
function buildRedirects(): Redirect[] {
const isPlatform = process.env.NEXT_PUBLIC_IS_PLATFORM === 'true'
const maintenance = process.env.MAINTENANCE_MODE === 'true'
const conditional = isPlatform ? PLATFORM_REDIRECTS : SELF_HOSTED_REDIRECTS
// Bare-domain bounce to the basePath when one is configured. Source
// stays literally `/` (NOT prefixed) so the entry-point redirect fires.
const basePathBounce: Redirect[] = basePath
? [{ source: '/', destination: basePath, permanent: false }]
: []
return [
...conditional.map(applyBasePath),
...SHARED_REDIRECTS.map(applyBasePath),
...basePathBounce,
...getMaintenanceRedirects(maintenance).map(applyBasePath),
]
}
function buildTanstackConfig(): VercelConfig {
// Vercel's Flags Explorer probes `/.well-known/vercel/flags` and expects
// JSON. next.config.ts proxies it to supabase.com's endpoint and forces
// `content-type: application/json`; mirror that here. It lives at the domain
// root (once, NOT per prefix) because next.config's rewrite sets
// `basePath: false` — well-known URLs are at the root regardless of the
// app's basePath.
const wellKnownFlags = '/.well-known/vercel/flags'
return {
// Nitro's Build Output directory is what gets deployed; no framework
// preset should route on top of it.
framework: null,
redirects: buildRedirects(),
rewrites: [routes.rewrite(wellKnownFlags, `https://supabase.com${wellKnownFlags}`)],
headers: [
routes.header(wellKnownFlags, [{ key: 'content-type', value: 'application/json' }]),
// When a base path is configured, emit both the prefixed and root rule
// sets (prefixed first so it wins for explicit /dashboard/* hits, root
// as a fallback for bare-domain traffic).
...(basePath ? [basePath, ''] : ['']).flatMap(headersFor),
],
}
}
// Empty config = no overrides; Vercel falls back to the dashboard preset.
const passthrough: VercelConfig = {}
export const config: VercelConfig = isTanstack ? buildTanstackConfig() : passthrough
// Belt-and-braces: local @vercel/config CLI reads module.default, but the
// docs claim Vercel's platform looks for a named `config` export. Export
// both so whichever path runs wins.
// eslint-disable-next-line no-restricted-exports
export default config