From 3bac7165bdac4dd5757a3467dd2f0bf79afad776 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Tue, 15 Sep 2026 21:46:45 +1000 Subject: [PATCH] chore(studio): move the TanStack Start deploy onto Nitro (#50030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the Studio TanStack Start build off the hand-rolled Vercel setup (an `api/server.js` function shim, rewrites in `vercel.ts`, a custom `?dpl=` skew-protection Vite plugin, and `scripts/serve.js` for self-hosted) and onto Nitro, which TanStack Start documents as its deployment path. Documents are served from the static SPA shell on the CDN; only `/api/*` and `/_serverFn/*` invoke the function. **Removed:** - `api/server.js`, `scripts/serve.js`, `scripts/smoke-server.mjs` - The `skewProtectionDpl` Vite plugin, `renderBuiltUrl`, and the `vite:preloadError` reload backstop in `router.tsx` (TanStack Router already reloads once on a failed lazy import) - Rewrites, `functions`, `outputDirectory`, and `cleanUrls` from `vercel.ts` (redirects and headers stay) - `magic-string` and `@jridgewell/remapping` devDependencies, the `preview` script **Added:** - `nitro` plugin in `vite.config.ts`. Preset is auto-detected: `.vercel/output` on Vercel, a self-contained node server in `.output` everywhere else. `vercel.immutableStaticFiles` puts hashed chunks under `/_vercel/immutable/` so tabs opened before a redeploy keep loading their chunks; `functions.maxDuration: 300` carries over the old function timeout - `scripts/vercel-spa-routes.ts`: Nitro module that rewrites the generated Build Output routes (documents -> `_shell.html`, allow-list -> `__server`, missing chunk -> 404, base-path prefixes), with a unit test - `server.ts`: TanStack Start server entry that initializes Sentry before the route tree loads and wraps the handler with `wrapFetchWithSentry` **Changed:** - `start:tanstack` runs `.output/server/index.mjs` directly with Node's `--env-file-if-exists` for the `.env` cascade. Node doesn't expand `$VAR` references, so `scripts/generateLocalEnv.js` now writes literal values into `.env.test` - Dockerfile's TanStack stage copies `.output` instead of running `pnpm deploy`; the `server.js` shim loads `.env` and imports the Nitro server - `NEXT_PUBLIC_BASE_PATH` (the platform's `/dashboard`) only sets the router basepath; Vite's `base` stays at the root so chunks can use the immutable store. The routes module emits prefixed rules for `/dashboard/api/*` and `/dashboard/_serverFn/*` and rewrites `public/` files requested under the prefix back to the root - Self-hosted security headers come from a Nitro `routeRules` entry; on Vercel they stay in `vercel.ts` - `tslib` is inlined for the build only: Nitro's dev runner has no interop for its CJS wrapper - Monaco's worker chunks follow the client assets dir so they land in the immutable store too Verified on the `studio-staging` preview (`STUDIO_FRAMEWORK=tanstack` is scoped to this branch there): documents come back as the static shell, `/dashboard/api/*` hits the function, `public/` files resolve under the prefix, a missing immutable chunk 404s. Across two deployments of this branch, the older deployment's chunks still load from the immutable store and requests carrying its `__vdpl` cookie are answered by that deployment. Self-hosted path covered by the TanStack E2E job and the Docker build job. ## To test - On the `studio-staging` preview: `/dashboard/project/` should show `content-disposition: inline; filename="_shell.html"` and a single-region `x-vercel-id`; `/dashboard/api/get-utc-time` a two-region id - Sign in and click through a few pages, including one that opens Monaco (SQL editor) so the worker chunks load - After the next deploy, a tab left open on the previous one should still navigate (lazy chunks) and call the API without errors - Self-hosted: `STUDIO_FRAMEWORK=tanstack pnpm --filter studio build && pnpm --filter studio start`, then check `/api/platform/profile` and that responses carry the security headers ## Summary by CodeRabbit * **New Features** * Production TanStack deployments now run on Nitro’s self-contained server output. * Vercel routing serves static pages first while directing API and server-function requests appropriately. * Server-function requests can include deployment identification for consistent handling. * Local environment generation now writes resolved configuration values. * **Bug Fixes** * Improved handling of missing static assets and SPA fallback routing. * Server-side error monitoring now captures request errors in the new runtime. * **Refactor** * Replaced the legacy production server and smoke-test workflow with Nitro-based startup. * Removed automatic reload handling for stale client assets. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- apps/studio/.env | 2 + apps/studio/Dockerfile | 48 +-- apps/studio/api/server.js | 50 --- .../data/utils/deployment-commit-query.ts | 10 +- apps/studio/hooks/use-check-latest-deploy.tsx | 5 +- apps/studio/instrument.server.mjs | 15 +- apps/studio/package.json | 9 +- apps/studio/router.tsx | 33 -- .../__tests__/vercel-spa-routes.test.ts | 86 ++++ apps/studio/scripts/dispatch.js | 4 +- apps/studio/scripts/lib/env.js | 16 +- apps/studio/scripts/serve.js | 214 --------- apps/studio/scripts/smoke-server.mjs | 73 ---- apps/studio/scripts/vercel-spa-routes.ts | 97 +++++ apps/studio/security-headers.ts | 9 +- apps/studio/server.ts | 17 + apps/studio/start.ts | 23 +- apps/studio/turbo.jsonc | 24 +- apps/studio/vercel.ts | 157 ++----- apps/studio/vite.config.ts | 278 ++++-------- knip.jsonc | 7 - pnpm-lock.yaml | 405 +++++++++++++++--- scripts/generateLocalEnv.js | 14 +- 23 files changed, 750 insertions(+), 846 deletions(-) delete mode 100644 apps/studio/api/server.js create mode 100644 apps/studio/scripts/__tests__/vercel-spa-routes.test.ts delete mode 100644 apps/studio/scripts/serve.js delete mode 100644 apps/studio/scripts/smoke-server.mjs create mode 100644 apps/studio/scripts/vercel-spa-routes.ts create mode 100644 apps/studio/server.ts diff --git a/apps/studio/.env b/apps/studio/.env index 6a7f9dd8585..89fbeb4f7c9 100644 --- a/apps/studio/.env +++ b/apps/studio/.env @@ -1,3 +1,5 @@ +# Default for the TanStack server (`pnpm start`); shell env and later env files override it. +PORT=8082 STUDIO_PG_META_URL=http://localhost:8000/pg POSTGRES_PASSWORD=your-super-secret-and-long-postgres-password diff --git a/apps/studio/Dockerfile b/apps/studio/Dockerfile index 9922ee29469..02f7f1ac96c 100644 --- a/apps/studio/Dockerfile +++ b/apps/studio/Dockerfile @@ -71,42 +71,20 @@ RUN mkdir -p /srv && \ cp -a apps/studio/.next/static /srv/apps/studio/.next/static && \ cp -a apps/studio/public /srv/apps/studio/public -# Compile TanStack Start (Vite) +# Compile TanStack Start (Vite + Nitro) FROM dev AS build-tanstack -# build:tanstack = vite build --mode production, then a smoke test that -# boots the server bundle so module-scope crashes fail the image build. RUN NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter studio run build:tanstack -# Assemble the runtime tree at /srv. Unlike Next's standalone output, the -# Vite SSR bundle externalizes studio's dependencies and resolves them from -# node_modules at request time, so the tree is a prod-only `pnpm deploy` of -# studio (node_modules + manifest) plus the built dist/ and the runtime -# scripts. `scripts/serve.js` is the HTTP server (the same entry -# start:tanstack uses); the server.js shim gives the production stage a -# single CMD that works for both frameworks. `.env` is kept because -# serve.js loads it as the base of the runtime env cascade (container env -# vars always win over file values). -# -# --ignore-scripts: pnpm 11 hard-errors (ERR_PNPM_IGNORED_BUILDS) on -# dependency build scripts without an allowBuilds entry, and deploy turns -# the workspace packages into file: deps whose `only-allow pnpm` preinstall -# guards trip it. No lifecycle script is needed here anyway: the tree is -# fully prebuilt, and nothing in studio's prod graph is approved to build -# (allowBuilds only permits node-pty and supabase, both dev-only). -RUN pnpm --filter studio deploy --prod --legacy --ignore-scripts /srv/apps/studio && \ - cd /srv/apps/studio && \ - find . -mindepth 1 -maxdepth 1 \ - ! -name node_modules ! -name package.json ! -name scripts \ - ! -name instrument.server.mjs ! -name .env \ - -exec rm -rf {} + && \ - cp -a /app/apps/studio/dist ./dist && \ - printf "import('./scripts/serve.js')\n" > server.js - -# Boot the pruned tree exactly the way the container will run it, so a -# dependency that's runtime-imported but missing from `dependencies` -# (present only in devDependencies) fails the build here instead of -# 500ing the deployed container. -RUN cd /srv/apps/studio && node scripts/smoke-server.mjs +# Assemble the runtime tree at /srv. Nitro's node-server output is +# self-contained (`.output/server` bundles the app with its traced +# dependencies, `.output/public` holds the client assets), so no node_modules +# install is needed. The server.js shim loads `.env` (container env vars win) +# and gives the production stage one CMD for both frameworks; package.json is +# copied so the shim runs as ESM. +RUN mkdir -p /srv/apps/studio && \ + cp -a apps/studio/.output /srv/apps/studio/.output && \ + cp apps/studio/package.json apps/studio/.env /srv/apps/studio/ && \ + printf "process.loadEnvFile(new URL('.env', import.meta.url))\nawait import('./.output/server/index.mjs')\n" > /srv/apps/studio/server.js # Alias whichever framework build was selected so the production stage can # COPY from a single stage name. BuildKit only builds the selected branch. @@ -115,8 +93,8 @@ FROM build-${STUDIO_FRAMEWORK} AS build # Copy only compiled code and dependencies FROM base AS production COPY --from=build /srv ./ -# serve.js (TanStack) defaults to port 8082; pin both servers to the port -# the healthcheck and compose files expect. Next's server.js reads PORT too. +# Both servers read PORT (the TanStack `start` script defaults it to 8082); +# pin it to the port the healthcheck and compose files expect. ENV PORT=3000 EXPOSE 3000 ENTRYPOINT ["docker-entrypoint.sh"] diff --git a/apps/studio/api/server.js b/apps/studio/api/server.js deleted file mode 100644 index e999b6ac104..00000000000 --- a/apps/studio/api/server.js +++ /dev/null @@ -1,50 +0,0 @@ -// STUDIO_FRAMEWORK gates whether this function actually serves the TanStack -// SSR handler. Vercel auto-detects every file under /api as a Function -// regardless of the framework preset (vercel.com/docs/functions), so we -// can't keep this file from being deployed in the Next.js prod build — we -// just make it inert when the env var is unset. -const isTanstack = process.env.STUDIO_FRAMEWORK === 'tanstack' - -// Computed path keeps `dist/server/server.js` out of Vercel's function -// bundler's static analysis. In the Next.js prod deploy the `dist/` tree -// doesn't exist, but Vercel still bundles this file because it lives under -// `api/`. With the .join() the bundler treats the import as runtime-only -// and the missing dist/ isn't a build error. In TanStack mode the SSR -// bundle is shipped into the function via the `functions['api/server.js'] -// .includeFiles` config in vercel.ts. -const tanstackEntry = ['..', 'dist', 'server', 'server.js'].join('/') - -// Initialize server-side Sentry BEFORE the handler module is imported, so its -// instrumentation is in place when route modules evaluate. Gated to TanStack -// (the Next deploy uses instrumentation.ts / sentry.server.config.ts instead). -// Vercel functions can't use a `--import` startup flag, so we import the -// instrument module here at boot. Vercel provides env vars via process.env. -// A Sentry boot failure must never take the API down — mirror scripts/serve.js -// and fall back to the identity wrapper if init or the SDK import throws. -let wrapFetchWithSentry = (fetchHandler) => fetchHandler -if (isTanstack) { - try { - await import('../instrument.server.mjs') - } catch (err) { - console.warn('[api/server] Sentry server init skipped:', err?.message ?? err) - } - ;({ wrapFetchWithSentry } = await import('@sentry/tanstackstart-react').catch(() => ({ - wrapFetchWithSentry: (fetchHandler) => fetchHandler, - }))) -} - -const rawHandler = isTanstack - ? (await import(tanstackEntry)).default - : { fetch: () => new Response('Not Found', { status: 404 }) } - -// Wrap the fetch handler so request-scoped errors (including those swallowed -// into a 500 downstream) are captured with request context. -const handler = isTanstack - ? { ...rawHandler, fetch: wrapFetchWithSentry(rawHandler.fetch.bind(rawHandler)) } - : rawHandler - -// Vercel's Web API handler convention: export an object with `fetch(request)`. -// TanStack's server build is already shaped that way — default-export it -// verbatim and Vercel hands us a real Web Request. -// eslint-disable-next-line no-restricted-exports -export default handler diff --git a/apps/studio/data/utils/deployment-commit-query.ts b/apps/studio/data/utils/deployment-commit-query.ts index 8fe1f972feb..a0ad17b89b2 100644 --- a/apps/studio/data/utils/deployment-commit-query.ts +++ b/apps/studio/data/utils/deployment-commit-query.ts @@ -5,12 +5,10 @@ import { BASE_PATH } from '@/lib/constants' import type { ResponseError, UseCustomQueryOptions } from '@/types' export async function getDeploymentCommit() { - // Deliberately unpinned: API fetches never carry the `?dpl=` skew-protection - // pin — only built asset URLs do (TanStack, see skewProtectionDpl in - // vite.config.ts) — so Vercel's edge routes this to the LATEST deployment - // and the check can detect a newer version while the session's assets stay - // pinned. We keep the basePath URL so it still routes to studio in - // production (root `/api/*` there is the marketing site). + // Deliberately unpinned: TanStack only adds x-deployment-id to server-function + // calls (start.ts), and Nitro's session cookie is disabled. This API request + // sees the latest deployment while older assets stay in the immutable store. + // Keep the basePath so www routes this request to Studio in production. const response = await fetchHandler(`${BASE_PATH}/api/get-deployment-commit`) return (await response.json()) as { commitSha: string; commitTime: string } } diff --git a/apps/studio/hooks/use-check-latest-deploy.tsx b/apps/studio/hooks/use-check-latest-deploy.tsx index 1bf32df8664..dfa450c0793 100644 --- a/apps/studio/hooks/use-check-latest-deploy.tsx +++ b/apps/studio/hooks/use-check-latest-deploy.tsx @@ -27,9 +27,8 @@ const DeployCheckToast = ({ id }: { id: string | number }) => {