Files
supabase/apps/studio/vite.config.ts
18431efb25 fix(studio): TanStack post-merge fixes — Monaco loader, fonts, CSP (from #46424) (#47657)
Post-merge fixes for the TanStack Start migration (#46424) — things that
broke on the TanStack build as master evolved under the migration
branches. Kept on their own branch off master rather than piling onto
the E2E-matrix PR (#47119); all land on master and cascade up to S6 +
the big PR.

Common theme: a master PR changed something the Next pipeline handles
via `next/font` / `pages/_app.tsx` / `next.config.ts`, but the
hand-rolled TanStack equivalent (`routes/__root.tsx`,
`styles/fonts.css`, `vercel.ts`) wasn't updated to match — invisible on
the Next deploy, broken only on TanStack.

---

## 1. Monaco loader path (#47182)

#47182 re-nested the served Monaco assets from a flat
`public/monaco-editor/` layout into `public/monaco-editor/vs/` and
updated `pages/_app.tsx`, but `routes/__root.tsx` still pointed
`loader.config` at the old path, so `loader.js` 404'd and **no Monaco
editor mounted anywhere in the TanStack build**. Now mirrors the Next
config (`${origin}${BASE_PATH}/monaco-editor/vs`, window-guarded for
SSR). Was failing the whole `tanstack` E2E shard on #47119.

## 2. Inter + Manrope fonts (#47306)

#47306 renamed Tailwind's sans var `--font-custom` → `--font-sans` and
added `--font-heading` (Manrope), set via `next/font` on Next.
`fonts.css` still only set the now-ignored `--font-custom`, so the body
fell back to the theme's system chain (`Circular, custom-font,
Helvetica…`) at weight 450 — that's the "Inter weights look wrong".
Manrope was missing entirely.

- Wire `--font-sans` (Inter) + `--font-heading` (Manrope) to match
`next/font`.
- **Vendor all three families** (Inter, Manrope, Source Code Pro) via
`@font-face` so nothing depends on the Google Fonts CDN — matches
`next/font` self-hosting, and (see below) `font-src` doesn't allow
`fonts.gstatic.com` anyway.

Verified in-browser: computed `body` → `Inter`, headings → `Manrope`,
all loading from local `/assets/*.woff2`.

## 3. Security headers / CSP (next.config.ts `headers()`)

The Next build sets X-Frame-Options / X-Content-Type-Options / HSTS /
**Content-Security-Policy** / Referrer-Policy via `next.config.ts`. The
TanStack build never carried these over — `vercel.ts` only set
cache-control, so **the deployed TanStack dashboard shipped with no CSP
at all**.

The TanStack deploy serves a static shell (no server to attach headers),
so they go in the Vercel config:
- `security-headers.ts` — shared source of truth, reuses `getCSP()`,
env-gated exactly like next.config.
- `vercel.ts` — apply to every response (all base-path prefixes): full
`getCSP()` + HSTS on platform.
- `scripts/serve.js` — the non-platform set (`frame-ancestors 'none'`)
for the self-hosted server.

**Tested the policy in a real browser** (temporarily enforced it on the
TanStack build via /test-supabase-local): everything passed except one
real gap — `font-src` was missing `data:`, so GraphiQL's bundled Monaco
codicon font and Stripe's payment-element fonts (both data: URIs) were
blocked (37 violations on a cold load). Added `data:` to `font-src` in
`csp.ts` → violations drop to zero, SQL editor Monaco renders clean.
That gap affects the Next build too.

---

## 4. `node:path` import crashing `/project/[ref]/merge`

Found by a full-site click-through of the TanStack build (all product
areas, ongoing — see below). `useEdgeFunctionsDiff.ts` +
`EdgeFunctionsDiffPanel.tsx` did `import { basename } from 'path'` in
client code. Webpack (Next) polyfills `path` in the browser; Vite
externalizes it, so the whole `/merge` route crashed with "Module
\"path\" has been externalized for browser compatibility". Replaced the
two `basename` call sites with a string helper. Verified in-browser:
`/merge` renders.

## 5. URL shape — Next-style search-param semantics + shim fixes

The dashboard produced malformed URLs vs the Next build (strange query
params, trailing slashes, `##` hashes). Root cause + audit verified
empirically against `@tanstack/react-router@1.170.10`; all fixed with
unit tests and browser-verified:

- **`createRouter` used TanStack's default JSON search codec** —
`?flag=true` became `?flag=%22true%22` via links, repeated
`?filter=…&filter=…` collapsed into a JSON array (breaking
multi-filter/sort table-editor URLs and the account-page round-trip,
which double-encoded), and search values arrived as numbers/booleans
where the app expects strings. New `lib/router-search-params.ts`
(Next-style: strings in, strings out, repeated keys → string[]) wired
into the router.
- **Link shim** (`compat/next/link.tsx`): `URL.hash` includes the
leading `#` while TanStack's `hash` prop adds its own → every
`href="…#section"` navigated to `##section` (hash-scroll broke);
`Object.fromEntries(searchParams)` dropped repeated query params. Both
fixed.
- **Trailing slash injected before the query** on every `?`-only
relative navigation (`/auth/providers/?provider=…`): fixed in the compat
router (prefix current pathname) and via a custom nuqs adapter
(`lib/nuqs-tanstack-adapter.tsx`) replacing the stock tanstack-router
adapter, whose `navigate({ to: '?…' })` writes hit the same TanStack
behavior (123 files use nuqs).
- **Pathname-less `router.push({ query })` leaked path params** — Next
re-consumes `ref`/`id` from `query` into the path pattern; the shim
didn't, yielding
`/editor/17597?schema=public&ref=<ref>&id=17597&filter=…` from
table-editor filter/sort, linter panels, and advisor shortcuts. The shim
now defaults the pathname to the current route pattern and backfills
omitted params.
- **Redirects dropped query + hash** (Next's `redirects()` preserves
them): `__root.tsx` `matchRedirect` and `routes/index.tsx` now carry
incoming params/hash through (consumed rule params excluded,
destination's own params win). `/?next=new-project&projectName=zzz` →
`/new/new-project?projectName=zzz`; `/sql/quickstarts?template=x#frag` →
`/sql/examples?template=x#frag`.

Browser-verified post-fix: advisors `?preset=WARN`, providers
`?provider=Google`, `?schema=auth` — all clean (no `/?`, no leaks);
repeated `filter` params survive hydration; `=true` unquoted; single
`#`.

## 6. TanStack `navigate` corrupting query values (Logs Explorer SQL
newline loss)

TanStack router-core treats a query string embedded in `navigate({ to
})` as part of the *path*: `decodePath` percent-decodes it and
`sanitizePathSegment` strips control characters, silently deleting every
`%0A`. Logs Explorer's SQL (`s` param) lost its newlines on Run/reload —
`order by timestamp desc` / `limit 5` glued into `desclimit 5`, which
then failed the LIMIT lint. Pre-existing on the TanStack build (the
stock nuqs adapter had the same shape); Next unaffected.

Fixed by never embedding query strings in `to`: the nuqs adapter and the
compat `router.push`/`replace`/`prefetch` (plus the `next/navigation`
shim) now pass search as an object through the app codec
(`splitInternalUrl` hoisted to `lib/internal-url.ts`). Guard test drives
a real `createRouter` with multi-line SQL through both producers.
Browser-verified: newlines survive the full Run → reload → re-Run cycle.

## 7. Integration overview markdown never loaded (all integrations)

`MarkdownContent` used a template-literal dynamic import
(``import(`@/static-data/integrations/${id}/overview.md`)``) — webpack
builds a context module for that, Vite can't analyze it, so every
integration detail page threw `Failed to resolve module specifier` and
rendered no overview text. Fixed with an explicit lazy registry of
literal imports (`static-data/integrations/overviews.ts`, drift-guarded
by a test) plus an `mdRawLoader()` Vite plugin mirroring next.config's
turbopack raw-loader rule. Both runtimes keep working; md stays out of
the main bundle.

## 8. GraphiQL editor never mounted (`exports is not defined`)

Our `umdAmdShortCircuit()` Vite plugin (which disarms Monaco's global
AMD loader for deps like papaparse) rewrote `typeof define ===
'function' && define.amd` to `false` inside `monaco-editor`'s bundled
copy of marked — whose UMD relies on its own *local* `define` shim — so
the whole optimized monaco chunk failed to evaluate and GraphiQL's
editor pane stayed blank. The check now only short-circuits when
`define` is the global AMD loader. Browser-verified: all four GraphiQL
Monaco panes mount, queries execute. (Known follow-up: GraphiQL's Monaco
workers fall back to the main thread under Vite — functional, worker
wiring is Next-specific `setup-workers/webpack`.)

## 9. `@sentry/nextjs` bundling Next internals — built TanStack bundle
crashed (caught by E2E)

The E2E suite against the **built** TanStack bundle (not the dev server)
found lazy chunks like `table-editor-*.js` dead on arrival:
`@sentry/nextjs` (imported by ~25 client files) drags in
`next/dist/shared/lib/constants`, whose module scope evaluates
`process?.features?.typescript` — optional chaining doesn't guard an
undeclared `process` in the browser, so the whole chunk failed at load
with `ReferenceError: process is not defined`. Dev shims `process`,
which is why weeks of dev-server testing never saw it.

Fixed by aliasing `@sentry/nextjs` → `compat/sentry-nextjs.ts`
(re-exports `@sentry/react`, same deduped 10.59.0, plus explicit
stand-ins for the three Next-only APIs) in the Vite build only.
Verified: fresh build has zero Next-internals markers in any chunk;
table editor loads clean; full E2E suite run against the built bundle.

Note for the stack: `alaister/tanstack-start` / the E2E-matrix branch
already carried a different fix for the same crash (a `next/constants`
shim) that never made it to master — the cherry-pick onto those branches
keeps **both** (the shim covers any other transitive importer; the alias
keeps Next internals out of the client bundle entirely).

**Follow-up found while fixing:** Sentry is never *initialized* in the
TanStack runtime — `instrumentation-client.ts` /
`sentry.server.config.ts` are Next-convention files nothing imports
under TanStack, so `captureException` calls are silent no-ops. Needs an
`@sentry/react` init (+ `tanstackRouterBrowserTracingIntegration`) wired
into the TanStack client entry as its own PR.

## 10. GraphiQL Monaco workers + edge-function Deno typings (Vite-only
gaps)

- **GraphiQL's Monaco workers ran on the main thread** under Vite
("Could not create web worker(s)…" — `setup-workers/webpack`'s `new
URL(...)` form isn't rewritten by Vite). A `graphiqlViteWorkers()`
plugin resolves the import to graphiql's own `setup-workers/vite`
variant for client builds (SSR untouched, Next untouched); the
setup-workers chain is `optimizeDeps.exclude`d because the Rolldown
optimizer can't load `?worker` ids.
- **Edge-function editors silently lost their Deno typings** —
`AIEditor` loaded `public/deno/*.d.ts` via `/* @vite-ignore */` imports
that always failed at runtime under Vite. The `.md` raw loader is
generalized into `rawTextLoader` (exact-path allowlist for the two
typings files, served as virtual string modules so the dep scanner never
parses `.d.ts` syntax), and the imports are now static-analyzable
literals that both bundlers handle (turbopack's raw-loader rules match
them on the Next side).

## Split out for reviewability

App-level fixes that reproduce on the Next build too (DOM-nesting
hydration errors, the ghost deleted-snippet nav, the recurring pg-meta
`migrations` 400) moved to their own PR: #47667. Sentry initialization
for the TanStack runtime (captures were silent no-ops) is #47666,
stacked on this PR.

## Full-site test campaign

Drove every dashboard product area on the local TanStack build
(Playwright, human-style) hunting migration regressions:
redirects/404/catch-alls, org, account, project home/branches/merge,
table editor CRUD, SQL editor (Monaco/run/save/templates/AI), all
database pages, all auth pages, storage CRUD, edge functions + realtime,
logs/observability, advisors, settings, integrations hub incl. nested
routes, global UI (palette/connect/switchers/theme/fonts), and a
cross-cutting sweep (document titles, back/forward chain, hard-refresh
hydration on deep URLs, trailing-slash active state). Every failure
found is fixed above and re-verified in-browser; remaining console
quirks were cross-checked against the deployed Next build and are
pre-existing (tracked separately).

## To test

Most fixes are already browser-verified + covered by unit tests and the
self-hosted E2E suite; the last two landed after the final browser pass
and still need an in-browser check:

1. **GraphiQL Monaco workers** — restart the dev server (clear
`apps/studio/node_modules/.vite` once first — the optimizer cache may
hold a stale prebundle of the worker chain). Open
`/project/<ref>/integrations/graphiql/graphiql` with the console open:
the `Could not create web worker(s). Falling back to loading web worker
code in main thread` warning must be gone, and DevTools → Sources →
Threads shows the three workers (json, editor, graphql). Autocomplete in
the query editor stays responsive.
2. **Edge-function Deno typings** — `/project/<ref>/functions/new`: no
"Failed to load … typings" console error, and typing `Deno.` in the
editor offers typed completions (e.g. `Deno.env`).

Spot-checks for the rest (all previously verified):
- `/project/<ref>/merge` renders (no "Module path" crash).
- Multi-line SQL in Logs Explorer survives Run → reload (no `desclimit`
gluing, no LIMIT-lint false failure); `s` param keeps `%0A`.
- `/auth/providers` → open a provider → `?provider=…` with no trailing
slash before `?`; table-editor filter/sort URLs carry no leaked
`ref`/`id` params; `/?next=new-project&projectName=x` lands on
`/new/new-project?projectName=x`.
- Integration detail pages (cron/queues/vault/data_api) show their
overview prose; GraphiQL query editor mounts.
- Built bundle (`MODE=test vite build` + `start:tanstack`): table editor
loads with no `process is not defined`.
- `curl -sI` any page on a platform deploy: `X-Content-Type-Options:
nosniff` (was the invalid `no-sniff`).


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

* **New Features**
* Centralized integration overview markdown loading with registry-based
lookup.
* Improved Monaco loading/asset path handling for smoother editor
startup.
* **Bug Fixes**
* Next-style navigation/search handling now preserves pathname, hash,
repeated query keys, and special characters (including newlines).
* Redirects now reliably carry over query and hash with correct
precedence.
* **Security/Configuration**
* Updated CSP font sourcing and unified security headers delivery across
environments; conditional HSTS behavior.
* Refreshed font CSS variables and font-face definitions to match the
theme.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


---

### Review feedback: non-prod favicon (Joshen)

The TanStack `__root.tsx` hardcoded the prod favicon; local + hosted
staging now use the white staging favicon (`/favicon/staging`), matching
what `pages/_app.tsx` passes to `MetaFaviconsPagesRouter` for non-prod.
Rather than pull the pages-router component into the TanStack head, it
reuses the same synchronous `NEXT_PUBLIC_ENVIRONMENT` signal the file
already uses for `IS_DEV_TOOLBAR_ENABLED` (the `head()` route option
isn't a React component, so it can't run `_app`'s async CLI check — but
the env signal covers the reported local/staging case).

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-07-08 14:52:59 +08:00

578 lines
26 KiB
TypeScript

/* eslint-disable no-restricted-exports */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
import { devtools } from '@tanstack/devtools-vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import { defineConfig, loadEnv, type Plugin } from 'vite'
const rootDir = path.dirname(fileURLToPath(import.meta.url))
const compatRoot = path.resolve(rootDir, 'compat/next')
// Map of Next imports we've shimmed to their TanStack-backed replacement.
// Add an entry here + a file under compat/next/ when a new Next surface is
// needed by app source.
const nextShims: Record<string, string> = {
'next/compat/router': path.join(compatRoot, 'compat/router.ts'),
'next/dynamic': path.join(compatRoot, 'dynamic.tsx'),
'next/head': path.join(compatRoot, 'head.tsx'),
'next/image': path.join(compatRoot, 'image.tsx'),
'next/legacy/image': path.join(compatRoot, 'legacy/image.tsx'),
'next/link': path.join(compatRoot, 'link.tsx'),
'next/navigation': path.join(compatRoot, 'navigation.ts'),
'next/router': path.join(compatRoot, 'router.ts'),
'next/script': path.join(compatRoot, 'script.tsx'),
'next/server': path.join(compatRoot, 'server.ts'),
}
// Combined compat + migration guard:
// - If app source imports a shimmed `next/*` id, resolve it to the local
// shim (acts like resolve.alias).
// - Otherwise, if app source imports from `next` or `next/*`, fail the
// build so we catch unshimmed usage at build time during the migration.
// - node_modules imports (e.g. @sentry/nextjs reaching into next) pass
// through untouched.
function nextCompat(): Plugin {
return {
name: 'studio-next-compat',
enforce: 'pre',
resolveId(id, importer) {
if (!importer || importer.includes('/node_modules/')) return
if (nextShims[id]) return nextShims[id]
if (id === 'next' || id.startsWith('next/')) {
throw new Error(
`[next-compat] "${id}" imported from ${importer}.\n` +
`Add a shim under apps/studio/compat/next/ and register it in vite.config.ts, ` +
`or use a framework-agnostic equivalent.`
)
}
},
}
}
// Import specifiers (as they appear in app source) for files that import as
// raw text but whose extension the bundler would otherwise treat as code —
// the Deno typings that `components/ui/AIEditor` feeds to Monaco as extra
// libs. Deliberately an exact-specifier allowlist — do NOT widen to
// `*.d.ts`: hijacking declaration-file resolution globally would corrupt
// every package that ships `.d.ts` next to its JS.
const RAW_TEXT_SPECIFIERS: Record<string, string> = {
'@/public/deno/edge-runtime.d.ts': path.join(rootDir, 'public/deno/edge-runtime.d.ts'),
'@/public/deno/lib.deno.d.ts': path.join(rootDir, 'public/deno/lib.deno.d.ts'),
}
// `\0`-prefixed so the Rolldown dep scanner externalizes the module instead
// of descending into it (see `shouldExternalizeDep` in vite); `.js`-suffixed
// so no TS transform ever sees a `.d.ts`-looking id.
const RAW_TEXT_PREFIX = '\0studio-raw-text:'
const RAW_TEXT_SUFFIX = '.js'
// Mirror the raw-loader rules from next.config.ts: serve `*.md` files (used
// by `static-data/integrations/*/overview.md` via
// `static-data/integrations/overviews.ts`) and the Deno typings in
// `public/deno/*.d.ts` as JS modules whose default export is the file's
// text. Vite has `?raw` for this, but the query suffix would have to live
// in shared app source where it breaks the webpack/turbopack raw-loader
// rule, so the import specifiers stay query-free and this plugin does the
// conversion for the Vite pipeline.
//
// The `.d.ts` files can't go through a plain `transform` like the `.md`
// files do: the dep scanner's native scan pipeline skips JS transform/load
// hooks entirely and parses whatever the id resolves to, and raw TS
// *declaration* syntax (`get stdin(): WritableStream;`) is a parse error in
// its runtime-TS grammar — the whole dependency scan fails and Vite skips
// pre-bundling outright. Resolving the specifier to a `\0`-virtual id keeps
// the scanner out (it externalizes `\0` ids) and the `load` hook then
// serves the file's text for the real pipelines (dev, build, SSR).
function rawTextLoader(): Plugin {
return {
name: 'studio-raw-text-loader',
enforce: 'pre',
resolveId(id) {
const file = RAW_TEXT_SPECIFIERS[id]
if (file) return RAW_TEXT_PREFIX + file + RAW_TEXT_SUFFIX
},
load(id) {
if (!id.startsWith(RAW_TEXT_PREFIX)) return
const file = id.slice(RAW_TEXT_PREFIX.length, -RAW_TEXT_SUFFIX.length)
const content = fs.readFileSync(file, 'utf-8')
return { code: `export default ${JSON.stringify(content)}`, map: null }
},
transform(code, id) {
if (!id.endsWith('.md')) return
return { code: `export default ${JSON.stringify(code)}`, map: null }
},
}
}
// Swap graphiql's webpack worker setup for its Vite one in client builds.
//
// App source imports `graphiql/setup-workers/webpack` (GraphiQLTab.tsx),
// which registers `MonacoEnvironment.getWorker` using
// `new Worker(new URL('monaco-editor/...', import.meta.url))` — the URL form
// webpack/turbopack rewrites at build time. Vite doesn't rewrite bare module
// specifiers inside `new URL(..., import.meta.url)`, so under the TanStack
// build the worker URLs 404 and Monaco falls back to running the json /
// editorWorkerService / graphql workers on the main thread ("Could not
// create web worker(s)..." console warning). graphiql also ships
// `setup-workers/vite`, which imports the same three workers via Vite's
// `?worker` suffix; importing that unconditionally would break the Next
// build, so the swap happens here instead of in app source.
//
// SSR resolution is left untouched: neither variant's `getWorker` ever runs
// during SSR, and the webpack flavor is a plain global assignment while the
// vite flavor's `?worker` imports don't belong in the server graph.
function graphiqlViteWorkers(): Plugin {
return {
name: 'studio-graphiql-vite-workers',
enforce: 'pre',
resolveId(id, importer, options) {
if (id !== 'graphiql/setup-workers/webpack' || options.ssr) return
return this.resolve('graphiql/setup-workers/vite', importer, { skipSelf: true })
},
}
}
// Short-circuit UMD wrappers' AMD branch by string-replacing the
// `define.amd` check. Vite's `config.define` doesn't reach pre-bundled
// deps (Vite 8's Rolldown-based optimizer doesn't honour member-
// expression define keys at the prebundle stage), and adding the same
// substitution to `optimizeDeps.rolldownOptions.define` had no effect
// on the emitted `node_modules/.vite/deps/*.js`. This transform fires
// when Vite *serves* the prebundled file, rewriting the runtime AMD
// check before it reaches the browser.
//
// Applied broadly to any module containing the AMD check (not just
// papaparse) — UMD wrappers all share the same shape, and we never
// want to take the AMD branch when Monaco's loader is around.
//
// Surfaces concretely on /functions/[slug]/invocations: papaparse
// pre-bundled into `.vite/deps/papaparse.js` retained the literal
// `"function" == typeof define && define.amd` check; Monaco's CDN
// loader installs `window.define` first, so papaparse's UMD takes the
// AMD branch and calls an anonymous `define([], t)` that Monaco
// rejects with "Can only have one anonymous define call per script
// file".
function umdAmdShortCircuit(): Plugin {
// Matches both unminified (`typeof define === 'function' && define.amd`)
// and minified (`"function" == typeof define && define.amd`) forms of
// the UMD AMD-detection check.
const AMD_CHECK_PATTERNS = [
/typeof\s+define\s*===?\s*['"]function['"]\s*&&\s*define\.amd/g,
/['"]function['"]\s*===?\s*typeof\s+define\s*&&\s*define\.amd/g,
]
// Only short-circuit when `define` is the *global* AMD loader (Monaco's
// CDN loader) — that's the one we never want UMD wrappers to register
// against. Some vendored bundles install their own *local* `define` shim
// and rely on the AMD branch to capture their exports:
// `monaco-editor/esm/vs/base/common/marked/marked.js` (pulled in by
// @graphiql/react's bundled Monaco) wraps marked's UMD in
// `function define(deps, factory) { factory(__marked_exports) }` and its
// ESM tail reads `__marked_exports.X || exports.X`. Replacing the check
// with a bare `false` diverts the factory to the global-object branch,
// leaving `__marked_exports` empty, and the tail's `exports.X` fallback
// then throws `ReferenceError: exports is not defined` — the GraphiQL
// editor pane never mounts. The `define !== globalThis.define` guard
// keeps such local AMD shims working while still disarming the global
// one. (The operand order — guard *before* `define.amd` — also ensures
// the emitted expression can never re-match AMD_CHECK_PATTERNS.)
const AMD_CHECK_REPLACEMENT =
'(typeof define === "function" && define !== globalThis.define && define.amd)'
return {
name: 'studio-umd-amd-short-circuit',
enforce: 'pre',
transform(code, id) {
if (!code.includes('define.amd')) return
// Skip Monaco's loader.js if it ever ends up in our graph — it
// legitimately needs `define.amd` to register itself as AMD.
if (id.includes('monaco-editor/min/vs/loader')) return
let next = code
for (const pattern of AMD_CHECK_PATTERNS) {
next = next.replace(pattern, AMD_CHECK_REPLACEMENT)
}
if (next === code) return
return { code: next, map: null }
},
}
}
// Replace our `components/interfaces/GraphQL/GraphiQL` module with a no-op
// React component in SSR builds only.
//
// `@graphiql/react` transitively loads a codemirror addon that touches
// `document` at module-evaluation time. During the SPA shell prerender,
// that hard-crashes with "document is not defined" as soon as the graphiql
// chunk gets loaded.
//
// Stubbing `@graphiql/react` directly would require enumerating its 30+ named
// exports so Rolldown's static analysis is satisfied. Easier to stub the one
// internal consumer — `GraphiQL.tsx` only exposes a default-export component,
// and no SSR-reachable route renders it (the GraphiQL tab is client-only).
function ssrStubGraphiql(): Plugin {
return {
name: 'studio-ssr-stub-graphiql',
enforce: 'pre',
transform(_code, id, options) {
if (!options?.ssr) return
if (id.endsWith('/components/interfaces/GraphQL/GraphiQL.tsx')) {
return { code: 'export default function GraphiQLStub() { return null }', map: null }
}
},
}
}
// Build-time guard: scan the emitted client chunks for cross-chunk
// circular imports and fail the build if any are found. Catches the
// class of bug that produces runtime errors like
// "TypeError: <name> is not a function" at module load — when chunk
// A imports a binding from chunk B and B (transitively) imports A
// back, ES module live-bindings can be undefined at the point the
// chunk that evaluates first tries to use them.
//
// Cycles are matched by chunk basename prefix (stripping the
// `assets/` directory and the `-<hash>.js` suffix), so the allowlist
// stays stable across builds even as Rolldown reassigns hashes.
const KNOWN_CHUNK_CYCLES: ReadonlyArray<ReadonlyArray<string>> = [
// `ui` ↔ `TreeView` chunk cycle. `cva` lives in the `ui` chunk
// (Rolldown pools it there because many ui files use it), TreeView
// imports `cva` back from `ui` while `ui`'s barrel re-exports
// TreeView — runtime crash is "cva is not a function" at SSR.
// Worked around via the `class-variance-authority` manualChunks
// pin below; the chunk graph still surfaces the SCC even though
// the top-level `cva(...)` call inside TreeView no longer crashes.
// The variants below are the same SCC in different shapes — they
// shuffle as Rolldown re-chunks across merges.
['LoadingLine', 'TreeView', 'ui'],
['FormLayout', 'LoadingLine', 'TreeView', 'ui', 'index'],
['LoadingLine', 'TreeView', 'ui', 'index'],
]
function chunkPrefix(name: string): string {
return name
.replace(/^assets\//, '')
.replace(/-[A-Za-z0-9_-]{6,10}\.js$/, '')
.replace(/\.js$/, '')
}
function isKnownCycle(scc: string[]): boolean {
const prefixes = new Set(scc.map(chunkPrefix))
return KNOWN_CHUNK_CYCLES.some(
(known) => known.length === prefixes.size && known.every((p) => prefixes.has(p))
)
}
function assertNoChunkCycles(): Plugin {
return {
name: 'studio-assert-no-chunk-cycles',
apply: 'build',
generateBundle(_options, bundle) {
const graph: Record<string, Set<string>> = {}
for (const [name, asset] of Object.entries(bundle)) {
if (asset.type !== 'chunk') continue
graph[name] = new Set(asset.imports.filter((i) => i in bundle))
}
// Tarjan's strongly-connected-components algorithm. Any SCC with
// more than one node is a cycle in the output chunk graph.
const indices: Record<string, number> = {}
const lowlinks: Record<string, number> = {}
const onStack: Record<string, boolean> = {}
const stack: string[] = []
const sccs: string[][] = []
let nextIndex = 0
const strongconnect = (v: string) => {
indices[v] = nextIndex
lowlinks[v] = nextIndex
nextIndex++
stack.push(v)
onStack[v] = true
for (const w of graph[v] || []) {
if (indices[w] === undefined) {
strongconnect(w)
lowlinks[v] = Math.min(lowlinks[v], lowlinks[w])
} else if (onStack[w]) {
lowlinks[v] = Math.min(lowlinks[v], indices[w])
}
}
if (lowlinks[v] === indices[v]) {
const scc: string[] = []
let w: string | undefined
do {
w = stack.pop()
if (w === undefined) break
onStack[w] = false
scc.push(w)
} while (w !== v)
if (scc.length > 1) sccs.push(scc)
}
}
for (const v of Object.keys(graph)) {
if (indices[v] === undefined) strongconnect(v)
}
const unexpected = sccs.filter((scc) => !isKnownCycle(scc))
if (unexpected.length === 0) return
const summary = unexpected
.map((scc, i) => ` Cycle ${i + 1}:\n` + scc.map((c) => ` ${c}`).join('\n'))
.join('\n\n')
const msg =
`studio-assert-no-chunk-cycles: detected ${unexpected.length} new chunk-level cycle(s) in the client bundle.\n` +
`These cause "X is not a function" runtime errors at module-load time. ` +
`Either restructure the modules involved or add the cycle to KNOWN_CHUNK_CYCLES ` +
`in apps/studio/vite.config.ts.\n\n` +
summary
this.error(msg)
},
}
}
export default defineConfig(({ command, mode }) => {
// Match Next's "always production-NODE_ENV during build" behaviour.
// `pnpm run e2e:setup:selfhosted` invokes the build with a shell
// `NODE_ENV=test` so Next can pick up `.env.test` for env loading;
// Next overrides NODE_ENV back to 'production' internally before
// emitting code, so the bundle never sees 'test'. Vite respects the
// user's NODE_ENV by default and would bake `process.env.NODE_ENV ===
// 'test'` into the client bundle, which trips vitest-only code paths
// (notably `API_URL` in `lib/constants/index.ts` pointing the browser
// at the vitest MSW host on port 3000, breaking every API fetch in
// e2e). Override here so `--mode test` still loads `.env.test` (via
// Vite's mode-based env resolution) while the bundle stays at
// `NODE_ENV='production'`, mirroring Next.
if (command === 'build') {
// Next's types declare NODE_ENV as read-only, so cast to assign it.
;(process.env as Record<string, string>).NODE_ENV = 'production'
}
// Inline NEXT_PUBLIC_* env vars at build time so `process.env.NEXT_PUBLIC_*`
// works in the browser bundle (mirrors Next.js behaviour).
const env = loadEnv(mode, rootDir, '')
const publicEnvDefines = Object.fromEntries(
Object.entries(env)
.filter(([key]) => key.startsWith('NEXT_PUBLIC_'))
.map(([key, value]) => [`process.env.${key}`, JSON.stringify(value)])
)
// Vercel auto-populates `NEXT_PUBLIC_VERCEL_*` for Next.js projects but not
// for other frameworks. Mirror that behaviour by re-exposing the unprefixed
// system vars under their `NEXT_PUBLIC_VERCEL_*` names so call sites that
// predate the TanStack migration keep working.
const vercelPublicVars = [
'VERCEL_ENV',
'VERCEL_BRANCH_URL',
// Skew protection: the client pins its session to this deployment (see
// router.tsx). Both are build-time system env vars on Vercel.
'VERCEL_DEPLOYMENT_ID',
'VERCEL_SKEW_PROTECTION_ENABLED',
] as const
for (const key of vercelPublicVars) {
const value = env[key]
if (value !== undefined) {
publicEnvDefines[`process.env.NEXT_PUBLIC_${key}`] = JSON.stringify(value)
}
}
// Mirror Next's `basePath` via NEXT_PUBLIC_BASE_PATH. Unlike Next, TanStack
// Start has no single knob — the prefix has to be declared in three places
// (see BASE_PATH_REDIRECT_GUIDE.md):
// - Vite `base` — bakes the prefix into asset URLs in the
// built bundle.
// - tanstackStart router.basepath — must be passed explicitly. If
// omitted, the plugin's internal
// `deriveRouterBasepath` derives a value
// from `publicBase` and strips both
// leading and trailing slashes
// (`/dashboard` → `dashboard`), which then
// surfaces in `useRouter().basePath`
// consumers as relative URLs (e.g.
// `${BASE_PATH}/img/...` becomes
// `dashboard/img/...` and the browser
// resolves it against the current path).
// See planning.js:14 in
// @tanstack/start-plugin-core.
// - createRouter({ basepath }) — runtime navigation prefix; configured
// in router.tsx off the same env var
// (inlined via `define` above).
// Leaving the var empty keeps the app at `/` as today.
const basePath = env.NEXT_PUBLIC_BASE_PATH || undefined
// Substitutions that have to apply to *both* our app source (via Vite's
// `define`) and any pre-bundled dependencies (via esbuild's optimizeDeps).
// The two pipelines don't share config — Vite's `define` only touches
// files going through Vite's transform, while optimizeDeps runs esbuild
// on `node_modules` deps with its own separate `define`.
// - `global` → `globalThis`: makes Node-style libs (`randombytes` via
// `generate-password-browser`, etc.) work in the browser. Surfaces
// on /auth/hooks via `randombytes/browser.js:16`.
//
// NOTE: `define.amd` is deliberately NOT substituted here. The AMD
// short-circuit is handled exclusively by the `umdAmdShortCircuit()`
// transform above — a blanket `'define.amd': 'false'` define would also
// rewrite the *read* in vendored bundles that install their own local
// `define` shim (monaco-editor's `esm/vs/base/common/marked/marked.js`)
// and break them — see the plugin's comment for the failure mode.
const sharedDefines = {
global: 'globalThis',
}
return {
server: {
port: 3000,
},
resolve: {
tsconfigPaths: true,
alias: [
// `@sentry/nextjs`'s client entry drags in Next runtime internals
// (`next/dist/shared/lib/constants`), whose module scope evaluates
// `process?.features?.typescript` — optional chaining doesn't guard
// an undeclared `process` in the browser, so every built chunk
// containing it (e.g. table-editor) crashes at load with
// "ReferenceError: process is not defined". Dev is unaffected
// because the dev pipeline shims `process`. Point the bare import
// at a shim that re-exports `@sentry/react` (same 10.x version —
// it's what `@sentry/nextjs` wraps on the client) plus explicit
// stand-ins for the Next-only APIs. Next build (`build:next`)
// doesn't read this config and keeps the real package.
{
find: /^@sentry\/nextjs$/,
replacement: path.resolve(rootDir, 'compat/sentry-nextjs.ts'),
},
],
},
...(basePath && { base: basePath }),
optimizeDeps: {
// graphiql's Vite worker setup (swapped in for the webpack one by the
// `graphiqlViteWorkers` plugin above) imports Monaco's workers with
// Vite's `?worker` suffix. The dep optimizer can't load `?worker` ids
// (UNLOADABLE_DEPENDENCY: "No such file or directory" for
// `json.worker.js?worker` etc.), so keep the whole chain out of
// pre-bundling; the modules then go through the normal transform
// pipeline where Vite's built-in worker plugin turns each `?worker`
// import into a spawnable Worker constructor.
exclude: [
'graphiql/setup-workers/webpack',
'graphiql/setup-workers/vite',
'@graphiql/react/setup-workers/vite',
],
},
define: {
...publicEnvDefines,
...sharedDefines,
},
// Circular-dep workaround: pin shared library code into dedicated
// chunks so per-component chunks don't import from a chunk that
// (transitively) imports them back.
//
// `class-variance-authority` — TreeView gets split into its own
// chunk that imports `cva` from the `ui` chunk while `ui` imports
// TreeView back. Leaves `cva` undefined at TreeView's top-level
// `cva(...)` call during SSR prerender.
//
// `lucide-react` — each icon (e.g. `FolderOpen`) gets a per-icon
// chunk that imports `createLucideIcon` from the `ui` chunk; the
// `ui` chunk in turn re-exports icons from `lucide-react`. The
// circular leaves `createLucideIcon` undefined when the icon
// chunk's top-level `createLucideIcon('FolderOpen', …)` runs —
// surfaces in the browser as "TypeError: e is not a function" at
// `folder-open-<hash>.js`.
//
// `react` / `react-dom` — pinning lucide-react alone caused
// Rolldown to suck React into the lucide-react chunk (lucide
// depends on React, no explicit pin further up the graph). That
// shifted live-bindings across the rest of the chunk graph and
// broke unrelated chunks (e.g. `Alert-<hash>.js` started crashing
// with `c is not a function` because its `styleHandler` import
// came in through the now-too-large `lucide-react` chunk). Pin
// React explicitly so it stays a leaf vendor chunk.
build: {
rollupOptions: {
output: {
manualChunks: (id) => {
if (id.includes('node_modules/class-variance-authority/')) {
return 'class-variance-authority'
}
// Pin React / React-DOM (and their JSX runtimes + scheduler)
// before lucide-react, so downstream chunks consume React
// from one place. Rolldown can still inline React into
// adjacent chunks for CJS interop, but the explicit pin
// anchors the canonical copy here.
if (
/node_modules\/(react|react-dom|scheduler)(\/|$)/.test(id) ||
/node_modules\/react\/jsx-(runtime|dev-runtime)/.test(id)
) {
return 'react-vendor'
}
if (id.includes('node_modules/lucide-react/')) {
return 'lucide-react'
}
return undefined
},
},
},
},
css: {
// Disable PostCSS auto-discovery. Studio's postcss.config.cjs is kept
// for the Next build (`build:next`) and uses `@tailwindcss/postcss`,
// but under Vite we let `@tailwindcss/vite` (added below) handle
// Tailwind v4 directives directly. Running both plugins on the same
// CSS would double-process Tailwind output.
postcss: { plugins: [] },
},
ssr: {
// `lodash` is CJS; its named-export interop fails in Node ESM unless bundled.
// `next/*` must be bundled so our nextCompat shim wins — otherwise Vite's
// SSR externalizer leaves `next/router` as a runtime package import and
// Node resolves it to Next's real module.
// `tslib`'s Node ESM entry (`modules/index.js`) destructures from a
// default-imported CJS wrapper (`tslib.js`). When consumers like
// `@ai-sdk/amazon-bedrock` / `configcat-common` `import … from "tslib"`
// and that ESM-wrapper gets picked, Rolldown botches the flattened UMD
// body — "__extends is not a function" at SSR module evaluation time.
// Inlining `tslib` lets the bundler reach the pure ESM entry directly.
// `react-use` ships a CJS entry that Vite's SSR externalizer emits as
// `import pkg from 'react-use'` + destructure. Works locally but
// Vercel's Node resolves it differently and fails at module instantiate
// (`ModuleJob._instantiate`). Inlining sidesteps the interop entirely.
// `awesome-debounce-promise`'s CJS entry only emits
// `exports.default = fn` (no `module.exports = fn`, no `__esModule`
// flag). Node's CJS→ESM bridge therefore makes the default import the
// entire exports object `{ default: fn }`, and call sites like
// `AwesomeDebouncePromise(fn, 500)` crash with "is not a function" at
// SSR module evaluation. Surfaces on routes that load the table grid.
// `@sentry/nextjs` deliberately has no entry here: the resolve.alias
// above rewrites it to the `@sentry/react`-backed shim before SSR
// resolution ever sees the id, and `@sentry/react` ships real ESM
// ("import" condition → build/esm), so plain externalization works.
noExternal: ['lodash', /^next(\/|$)/, 'tslib', 'react-use', 'awesome-debounce-promise'],
},
plugins: [
nextCompat(),
rawTextLoader(),
graphiqlViteWorkers(),
ssrStubGraphiql(),
umdAmdShortCircuit(),
assertNoChunkCycles(),
devtools(),
tailwindcss(),
tanstackStart({
srcDirectory: './',
spa: {
enabled: true,
},
// Set `configuredBasepath` so `deriveRouterBasepath` short-circuits
// its slash-stripping branch. See the basePath comment above.
...(basePath && { router: { basepath: basePath } }),
}),
viteReact(),
],
}
})