Files
supabase/apps/studio/vercel.ts
Alaister Young b9c8857394 fix(studio): TanStack route parity fixes from Next comparison audit (#48028)
Audited every TanStack route (~300 files) against its Next.js
pages-router counterpart — layout wrapping, root providers, API routes,
and deploy config — and fixed the divergences found. Same bug class as
#48024, plus a few setup-level gaps.

**Fixed (user-visible):**
- `routes/__root.tsx` was missing `TimezoneProvider` + the
`TimestampInfoProvider` bridge, so the stored timezone preference was
silently ignored app-wide (timestamps always rendered in browser-local
time)
- `routes/_auth.tsx` wrapped all 10 auth pages in `AuthenticationLayout`
(status banners + extra full-screen scroll container); in Next only
`/sign-in` has it via getLayout. The parent is now a passthrough and
sign-in wraps at the leaf
- `routes/project/$ref/integrations.tsx` hardcoded
`ProjectIntegrationsLayout`; the Next pages use
`ProjectIntegrationsLayoutDispatch`, which switches to the Marketplace
layout when that flag is enabled
- `GlobalShortcuts` wasn't mounted, so the shortcuts-reference sheet
(`?`) and its command-menu entry were unreachable
- `routes/join.tsx` added a full-screen wrapper the Next page doesn't
have (double `min-h-screen` around `InterstitialLayout`)

**Fixed (behavior/config):**
- ConfigCat flags lost the `plan` custom attribute, so plan-targeted
flags could evaluate differently
- `vercel.ts`: `api/server.js` had no `maxDuration` (Next sets up to
300s per route — stripe-sync, AI streaming); added the
`/.well-known/vercel/flags` rewrite + JSON content-type (Flags Explorer
endpoint previously fell through to the HTML shell); added
`img`/`favicon` cache-control headers
- `routes/api/v1/.../functions/$slug/body.ts` (bespoke reimplementation)
dropped `apiWrapper`'s global catch — errors now get Sentry capture +
the same 500 `{ error }` body
- Reverted migration drift in `__root.tsx`: tooltip `delayDuration` 0 →
Radix default (matching Next), `og:image` back to `supabase-og.png`
- lodash → lodash-es for the whole SSR module graph (#48029, merged into
this branch): the lodash CJS build's named-export interop yields
non-functions under the Vite SSR module runner, which 500'd every page
once `GlobalShortcuts` (or anything calling lodash during SSR render)
mounted. An `options.ssr`-gated `resolveId` plugin in `vite.config.ts`
serves `lodash-es` (same version, real ESM) to app source, workspace
packages, and deps alike; client bundles untouched. Note: dev servers
need a restart after pulling this (config change)

Also corrected two stale route comments claiming the CLI/Stripe login
pages inline `APIAuthorizationLayout` (they inline
`InterstitialLayout`).

**Not changed (audited, intentionally left):**
- Redirect-only pages briefly flash `DefaultLayout` chrome under
TanStack (normally unreachable — router-level redirects fire first)
- Org pages inherit an inert `AppLayout` div via `routes/_app.tsx`
(visually a no-op; Next org pages don't have it)
- Adapter-level differences: framework 405s instead of Next's
`Allow`-header JSON, `bodyParser.sizeLimit` not enforced on two routes,
narrower favicon non-prod detection (commented as known)
- Known pre-existing dev console error (also on Next master): closing
the shortcuts sheet logs a setState-in-render warning —
`@tanstack/react-hotkeys@0.10.0` calls `setOptions` in the
`useHotkeySequence` render body, notifying `useHotkeyRegistrations`
subscribers mid-render. Worth an upstream report/dep bump as a follow-up

## To test

Verified on the local TanStack dev server via Playwright (all pass):
- Set a timezone in the account dropdown → log timestamps show that
timezone's row in the hover tooltip
- `?` opens the shortcuts sheet; `⌘K` → "Show all keyboard shortcuts"
does too
- `/sign-in` still shows banners/window chrome; `/sign-up`,
`/sign-in-sso`, `/forgot-password`, `/cli/login` render without the
extra wrapper
- `/project/<ref>/integrations` renders (legacy sidebar when marketplace
flag off)
- `/join` renders a single centered interstitial
- `og:image` meta is `supabase-og.png`
- Vercel deploy-button new-project page renders the consolidated #47995
form inside the window chrome
- `vercel.ts` changes are deploy-config only — verify Flags Explorer +
function timeout on a preview deploy

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
  - Added timezone-aware timestamp handling across Studio.
  - Added support for global keyboard shortcuts.
- Updated authentication page layouts for a more consistent sign-in
experience.
  - Refreshed social sharing imagery.

- **Bug Fixes**
- Improved error reporting and responses when loading function source
files fails.
  - Improved handling of integration page layouts.
  - Fixed Vercel routing for feature configuration requests.

- **Performance**
  - Added caching for static images and favicons.
  - Increased server execution time for longer-running requests.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-07-17 16:42:10 +08:00

197 lines
9.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 the TanStack rewrites,
// `framework: null`, and `outputDirectory: 'dist/client'` 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'
// Vite's `base` bakes the prefix into asset URLs but leaves the filesystem
// layout at `dist/client/...`. On Vercel we strip the prefix for file lookups
// and fall through to the SPA shell. When NEXT_PUBLIC_BASE_PATH is empty
// the prefixed rule set is skipped and only the root-level rules fire.
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
// Build the rewrites + headers for a given prefix ('' for root, or a base
// path like '/dashboard'). We run this once for each prefix and concatenate
// the results so we don't hand-duplicate every rule.
//
// Rewrite ordering: API + server-function passthrough first so extensioned
// API paths (/api/foo.json) don't get caught by the asset rule. Asset rule
// next — strips the basePath prefix so `/dashboard/assets/x.js` maps onto the
// `dist/client/assets/x.js` filesystem layout (a no-op identity when
// prefix=''). Shell rule LAST, and it deliberately matches only extensionless
// paths via a negative lookahead.
//
// Why the lookahead matters: a request WITH a file extension that doesn't
// resolve to a real file — e.g. a hashed chunk from an older deployment after
// a redeploy — must fall through to a 404, NOT the HTML shell. A catch-all
// `(.*)` shell swallows those misses and returns `text/html`, so the browser
// gets HTML for a `.js` request and throws "Failed to load module script …
// MIME type text/html" (and, because /assets/* is cached immutable, that HTML
// poisons the edge cache under the asset URL). A clean 404 instead lets skew
// protection's `?dpl=` routing (baked into asset URLs at build time — see
// skewProtectionDpl in vite.config.ts) serve the chunk from the deployment
// that still has it, or the client's `vite:preloadError` backstop recover.
function routesFor(prefix: string) {
return {
rewrites: [
routes.rewrite(`${prefix}/api/(.*)`, '/api/server'),
routes.rewrite(`${prefix}/_serverFn/(.*)`, '/api/server'),
routes.rewrite(`${prefix}/(.*\\.\\w+)`, '/$1'),
routes.rewrite(`${prefix}/((?!.*\\.\\w+$).*)`, '/_shell'),
],
headers: [
// Security headers for every response. The Next build sets these via
// next.config.ts `headers()`; the TanStack build serves a static shell
// with no server to attach them, so they live here. Matches next.config's
// `/(.*?)` block (CSP, X-Frame-Options, HSTS, etc.).
{ source: `${prefix}/(.*)`, headers: getSecurityHeaders() },
// Dynamic function responses must not be cached by any shared cache —
// handlers can still opt in with their own Cache-Control on the
// Response when a response IS safe to cache.
routes.cacheControl(`${prefix}/api/(.*)`, { private: true, noStore: true }),
routes.cacheControl(`${prefix}/_serverFn/(.*)`, { private: true, noStore: true }),
// Hashed bundles under /assets/* are content-addressed — safe to
// cache forever.
routes.cacheControl(`${prefix}/assets/(.*)`, {
public: true,
maxAge: '1year',
immutable: true,
}),
// 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). Prefixed like the other rules so
// `/dashboard/img/x.png` gets the header too when a basePath is set.
routes.cacheControl(`${prefix}/img/(.*)`, { public: true, maxAge: '30days' }),
routes.cacheControl(`${prefix}/favicon/(.*)`, { public: true, maxAge: '1day' }),
],
}
}
// ---------------------------------------------------------------------------
// 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 {
// 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).
const ruleSets = (basePath ? [basePath, ''] : ['']).map(routesFor)
// 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`; the TanStack build has no equivalent, so
// without these the path falls through to the extensionless catch-all shell
// rule and the browser gets HTML. This lives at the domain root (once, NOT
// inside routesFor) because next.config's rewrite sets `basePath: false`, so
// the path is never basePath-prefixed — well-known URLs are by convention at
// the root regardless of the app's basePath. The rewrite must be listed
// BEFORE the routesFor rewrites so it wins over the shell catch-all.
const wellKnownFlags = '/.well-known/vercel/flags'
return {
framework: null,
outputDirectory: 'dist/client',
cleanUrls: true,
redirects: buildRedirects(),
rewrites: [
routes.rewrite(wellKnownFlags, `https://supabase.com${wellKnownFlags}`),
...ruleSets.flatMap((r) => r.rewrites),
],
headers: [
routes.header(wellKnownFlags, [{ key: 'content-type', value: 'application/json' }]),
...ruleSets.flatMap((r) => r.headers),
],
// `api/server.js` imports the TanStack SSR bundle via a computed
// path so Vercel's function bundler doesn't try to statically
// resolve `dist/server/server.js` during the Next.js prod build
// (where `dist/` doesn't exist). `includeFiles` ships the SSR
// output into the function bundle for the TanStack build so the
// runtime import resolves.
functions: {
'api/server.js': {
// Every API + server-function request is rewritten onto this single
// function (see the `/api/*` and `/_serverFn/*` rewrites), so its
// timeout must cover the LONGEST per-route `maxDuration` the Next
// build declared — otherwise long AI streams and Stripe sync hit the
// platform default (~15s) and get killed. Next set these per route via
// `export const maxDuration`: generate-attachment-url (120),
// ai/code/complete (60), ai/onboarding/design (60), ai/sql/generate-v4
// (120), ai/feedback/rate (30), and integrations/stripe-sync (300).
// TanStack collapses them into one function, so we take the max: 300s.
maxDuration: 300,
// Ship the SSR output, plus libpg-query's wasm. libpg-query is
// externalized for SSR and loads its `.wasm` relative to its own
// dir (`__dirname`) at import time; Vercel's function bundler
// doesn't trace that node_modules asset, so co-ship it explicitly
// or the server crashes at boot with ENOENT on libpg-query.wasm.
//
// Target the real pnpm store path (repo-root `.pnpm`, two levels up
// from this Root Directory) rather than `node_modules/libpg-query`,
// which is a pnpm symlink — Vercel rejects packaging files reached
// through symlinked dirs ("invalid deployment package"). The real
// path also matches where Node resolves __dirname at runtime
// (/var/task/node_modules/.pnpm/libpg-query@.../wasm/...).
includeFiles:
'{dist/server/**,../../node_modules/.pnpm/libpg-query@*/node_modules/libpg-query/wasm/libpg-query.wasm}',
},
},
}
}
// 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