Files
supabase/apps/studio/vercel.ts
Alaister Young 9eab4f8fbf build(studio): Vite/TanStack-Start build pipeline behind flag (stack 1/6, from #46424) (#47107)
**Stack 1/6** of the TanStack Start migration (#46424), split into
reviewable, independently-mergeable PRs.

> [!IMPORTANT]
> **Next stays the default and only active framework after this PR.**
This wires up the Vite/TanStack-Start build pipeline behind the
`STUDIO_FRAMEWORK` flag, but there are no TanStack routes yet — so the
TanStack build isn't functional or tested until later PRs in the stack.
Nothing about the Next build, dev, or deploy changes behaviourally here.

## What's in this PR
- **Dispatch:** `dev`/`build`/`start` now go through
`scripts/dispatch.js`, which runs the Next variant unless
`STUDIO_FRAMEWORK=tanstack`. The original commands are preserved as
`dev:next`/`build:next`/`start:next`.
- **Build pipeline:** `vite.config.ts`, `serve.js`, `smoke-server.mjs`,
vite/tanstack deps, `turbo.jsonc`.
- **`tsconfig.json`:** `jsx: react-jsx`, `moduleResolution: Bundler`,
`target: ES2022`. Because `include` is `**/*.ts(x)`, this re-typechecks
the whole app, so the companion adaptations below land with it.
- **Shared adaptations (companions to the tsconfig change):**
`BufferSource` casts, `packages/ui` unused-`React` import removals, etc.
- **Routing/middleware plumbing:** `next.config.ts` +
`redirects.shared.ts` (redirect rules now shared with `vercel.ts`),
`proxy.ts`/`start.ts` middleware + `hosted-api-allowlist.ts`.

## Verification
Run locally off `master`: frozen install ✓, `studio` typecheck ✓, **Next
build ✓** (compiles + generates all routes), lint ratchet ✓ ("some rules
improved"), prettier ✓.


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

* **New Features**
* Added a hosted API endpoint allowlist to return 404 for non-supported
`/api/*` routes.
* Introduced a TanStack route-migration checklist and expanded TanStack
Start routing support.
* **Improvements**
* Enhanced deployment refresh/detection by tightening cookie handling
for “latest deployment” updates.
* Centralized redirect/maintenance-mode rules for consistent platform vs
self-hosted behavior.
* Improved production serving with a dedicated static + proxy server and
a post-build smoke test.
* **Dependencies**
* Updated TanStack-related packages and React Table/query tooling
versions.
* **Documentation / Chores**
* Updated formatting and tooling config; added shared build environment
parsing utilities.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
2026-06-24 17:55:22 +08:00

156 lines
7.2 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'
// 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 `__vdpl` routing 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: [
// 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,
}),
],
}
}
// ---------------------------------------------------------------------------
// 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)
return {
framework: null,
outputDirectory: 'dist/client',
cleanUrls: true,
redirects: buildRedirects(),
rewrites: ruleSets.flatMap((r) => r.rewrites),
headers: 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': {
// 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