/* eslint-disable no-restricted-exports */ import fs from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' import { fileURLToPath } from 'node:url' import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite' 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 { nitro } from 'nitro/vite' import { defineConfig, loadEnv, type Plugin } from 'vite' import { vercelSpaRoutes } from './scripts/vercel-spa-routes' import { getSecurityHeaders } from './security-headers' const rootDir = path.dirname(fileURLToPath(import.meta.url)) const compatRoot = path.resolve(rootDir, 'compat/next') // Absolute dir of lodash-es, for the SSR-only lodash alias below. Resolved // here (not left as a bare 'lodash-es' replacement) because rollup-alias // rewrites the id but resolution still runs from the ORIGINAL importer — // under pnpm's strict node_modules, workspace packages that don't declare // lodash-es (ui, common) would fail to resolve the bare specifier. const lodashEsDir = path.dirname(createRequire(import.meta.url).resolve('lodash-es/package.json')) // 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 = { '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 = { '@/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). // SSR-only lodash → lodash-es rewrite, for the whole SSR module graph (app // source, workspace packages, and node_modules deps alike). The CJS lodash // in `ssr.noExternal` evaluates as pure ESM in the dev module runner — no // `module`/`exports`/`require` — so its UMD wrapper silently attaches `_` to // the global and every named import binds to `undefined`, exploding only when // first CALLED during SSR render ("(0, __vite_ssr_import_0__.isEqual) is not // a function"). Deep imports (`lodash/isEqual`) fail harder: their plain-CJS // `require` throws "require is not defined". lodash-es is the same version as // real ESM, so both import styles just work. Client bundles are untouched — // resolution is gated on `options.ssr`, and the Next build doesn't read this // config. function ssrLodashEs(): Plugin { return { name: 'studio-ssr-lodash-es', enforce: 'pre', resolveId(source, _importer, options) { if (!options?.ssr) return if (source === 'lodash') return path.join(lodashEsDir, 'lodash.js') const subpath = source.match(/^lodash\/(.+?)(\.js)?$/) if (subpath) return path.join(lodashEsDir, `${subpath[1]}.js`) }, } } 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: 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 (directory and `-.js` // suffix stripped), so the allowlist stays stable across builds. const KNOWN_CHUNK_CYCLES: ReadonlyArray> = [ // `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(/^.*\//, '') .replace(/-[A-Za-z0-9_-]{6,}\.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> = {} 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 = {} const lowlinks: Record = {} const onStack: Record = {} 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) }, } } // Vite bundles `?worker` modules (Monaco's workers via graphiql) with the ROOT // `build.assetsDir`, while Nitro only rewrites the client environment's (to // `_vercel/immutable//nitro` on Vercel). Keep the workers in the same // directory so they land in the immutable store too. function workersFollowClientAssetsDir(): Plugin { return { name: 'studio-workers-follow-client-assets-dir', apply: 'build', configResolved(config) { const clientAssetsDir = config.environments.client?.build.assetsDir if (clientAssetsDir) config.build.assetsDir = clientAssetsDir }, } } 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).NODE_ENV = 'production' } else if (process.env.NODE_ENV === 'test') { // `pnpm dev:studio-local` runs with a shell NODE_ENV=test (the Next // path needs it to load `.env.test`), and Vite's // define plugin inlines `process.env.NODE_ENV || mode` into the client — // which would bake 'test' in and trip the vitest-only API_URL path. // `next dev` always runs the bundle at 'development' regardless of the // shell NODE_ENV; mirror that. Env-file selection is unaffected — the // vite dev path selects `.env.test` via MODE=test (see envMode below), // not NODE_ENV. ;(process.env as Record).NODE_ENV = 'development' } // `pnpm dev:studio-local` needs the `.env.test` cascade (self-hosted mode // plus the supabase-cli keys that generateLocalEnv.js writes) — the Next // path selects it via NODE_ENV=test, and the tanstack build via // `--mode test` (e2e:setup:selfhosted). But `vite dev --mode test` is not // an option: TanStack Start's dev-server plugin treats mode 'test' as // "running under vitest" and skips installing its SSR middleware entirely, // so every route 404s (see the `isTest` guard in devServerPlugin, // @tanstack/start-plugin-core). So dev keeps mode 'development' and // emulates the env cascade of the mode named by MODE instead: load it for // the NEXT_PUBLIC_* defines below, and seed process.env for the SSR // runtime. The seeding must not clobber shell-provided values (matching // serve.js), and survives TanStack's own load-env plugin: that plugin // Object.assigns loadEnv(mode) at configResolved — after this runs — and // loadEnv gives existing process.env values priority over env-file values. const envMode = command === 'serve' && process.env.MODE ? process.env.MODE : mode // 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(envMode, rootDir, '') if (envMode !== mode) { const processEnv = process.env as Record for (const [key, value] of Object.entries(env)) { processEnv[key] ??= value } } 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', // Sentry release (sentry.tanstack.ts): the SDK silently drops session // envelopes when the client has no release, so Release Health would send // nothing. The commit SHA is also what withSentryConfig resolves the Next // build's release to, keeping release names aligned across both builds. 'VERCEL_GIT_COMMIT_SHA', ] as const for (const key of vercelPublicVars) { const value = env[key] if (value !== undefined) { publicEnvDefines[`process.env.NEXT_PUBLIC_${key}`] = JSON.stringify(value) } } // Pin server-function calls to the build that created them, without a // session cookie that also pins document reloads and the update check. publicEnvDefines['process.env.NEXT_PUBLIC_VERCEL_DEPLOYMENT_ID'] = JSON.stringify( env.VERCEL_SKEW_PROTECTION_ENABLED === '1' ? env.VERCEL_DEPLOYMENT_ID : undefined ) ?? 'undefined' // `MAINTENANCE_MODE` gates the "redirect everything to /maintenance" rule. // It's deliberately unprefixed, and the other two consumers both read it at // BUILD time: `next.config.ts` reads it in `redirects()`, which Next bakes // into `routes-manifest.json` during `next build`, and `vercel.ts` reads it // while emitting `vercel.json`. So flipping maintenance has always meant a // rebuild/redeploy, never just a server restart. Inline it here on the same // terms so the isomorphic `beforeLoad` in `routes/__root.tsx` — which // mirrors those rules for the TanStack runtime — can read it on the client // too, without self-hosters having to set a second, NEXT_PUBLIC_-prefixed // var. Falls back to `''` (not left undefined) so the browser bundle never // ends up with a bare `process.env` reference. publicEnvDefines['process.env.MAINTENANCE_MODE'] = JSON.stringify(env.MAINTENANCE_MODE ?? '') // Sentry init (lib/sentry-client-options.ts, reached via router.tsx) reads // these at runtime in the browser. When a var is unset it gets no define // entry above, which would leave a literal `process.env.*` in the built // bundle — and an undeclared `process` throws in the browser. Inline // `undefined` as the fallback, mirroring how Next inlines unset // NEXT_PUBLIC_* vars. for (const key of [ 'NEXT_PUBLIC_SENTRY_DSN', 'NEXT_PUBLIC_SENTRY_ENVIRONMENT', 'NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA', ]) { publicEnvDefines[`process.env.${key}`] ??= 'undefined' } // Vite's public base keeps asset requests under www's `/dashboard` proxy. // Nitro's baseURL stays `/`: its immutable manifest must use the reserved // root path. vercel-spa-routes rewrites the browser's prefixed asset URLs // to that root path, including assets retained from older deployments. const basePath = env.NEXT_PUBLIC_BASE_PATH || undefined // Self-hosted responses get next.config.ts's security headers via Nitro // route rules. On Vercel they come from vercel.ts: a `/**` header route in // the Build Output config would stop matching before Nitro's asset and // skew-cookie rules. const securityHeaders = Object.fromEntries( getSecurityHeaders().map(({ key, value }) => [key, value]) ) // 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, }, preview: { // The prerender step (@tanstack/start-plugin-core) boots `vite preview` // on an ephemeral port and crawls the first resolved URL. With the // default host (`localhost`) the server can bind the IPv6 loopback // while the crawler's fetch connects to 127.0.0.1 — split name // resolution that ECONNREFUSEDs the whole prerender inside docker // build containers. Pin both sides to IPv4 loopback. host: '127.0.0.1', }, 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-.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-.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. // // `packages/ui/src/components/shadcn/ui/field.tsx` — its only // non-barrel importer is Storage's `FileExplorerHeader`, so // Rolldown pools it into the storage bucket page chunk while the // `ui` package barrel (`packages/ui/index.tsx`) re-exports it — // `ui` ends up importing `FieldDescription` back from the page // chunk it's itself imported by. // // `packages/ui/src/components/shadcn/ui/drawer.tsx` — same shape, // its only non-barrel importer sits inside the Logs Explorer page // tree (`DataTableFilterControlsDrawer`), so it gets pooled into // the `logs` page chunk while `ui`'s barrel re-exports it too. // // `packages/ui/src/components/shadcn/ui/form.tsx` and // `packages/ui/src/components/shadcn/ui/sidebar.tsx` (+ // `use-mobile.tsx`) — same shape again: each gets pooled into // whichever page/feature chunk happens to be its only non-barrel // importer (a form page, `components/interfaces/Sidebar.tsx`) // while `ui`'s barrel re-exports them too. // // `packages/ui/src/components/shadcn/ui/slider.tsx` — same shape // again: its only non-barrel importer is the Appearance settings' // `ThemeColorSettings`, so Rolldown pools it into the `/account/me` // page chunk while `ui`'s barrel re-exports it too. 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' } if (id.includes('packages/ui/src/components/shadcn/ui/field.tsx')) { return 'ui-field' } if (id.includes('packages/ui/src/components/shadcn/ui/drawer.tsx')) { return 'ui-drawer' } if (id.includes('packages/ui/src/components/shadcn/ui/form.tsx')) { return 'ui-form' } if ( id.includes('packages/ui/src/components/shadcn/ui/sidebar.tsx') || id.includes('packages/ui/src/components/hooks/use-mobile.tsx') ) { return 'ui-sidebar' } if (id.includes('packages/ui/src/components/shadcn/ui/slider.tsx')) { return 'ui-slider' } 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: { optimizeDeps: { include: ['lodash'], }, // `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. // `tslib` is inlined for the BUILD only: Nitro's dev runner has no CJS // interop for the `tslib.js` that its ESM wrapper default-imports, and // every SSR request would 500. Left external in dev, Node loads it. noExternal: [ 'lodash', /^next(\/|$)/, ...(command === 'build' ? ['tslib'] : []), 'react-use', 'awesome-debounce-promise', ], }, plugins: [ nextCompat(), rawTextLoader(), graphiqlViteWorkers(), ssrStubGraphiql(), ssrLodashEs(), umdAmdShortCircuit(), assertNoChunkCycles(), workersFollowClientAssetsDir(), devtools(), tailwindcss(), // Nitro builds and hosts the server for every target: the Vercel // function (`.vercel/output`, preset auto-detected from `VERCEL`) and // the self-hosted node server (`.output`). nitro({ // `server.ts` is TanStack Start's SSR entry, not a Nitro entry; // without this Nitro's scan picks it up as both and warns. serverEntry: false, // Nitro bundles dependencies. libpg-query's emscripten glue reads // `__dirname` and loads its `.wasm` from disk, so keep it external // and fully copied (`*`). traceDeps: ['libpg-query*'], vercel: { // Content-addressed chunks under `/_vercel/immutable/`, shared // across deployments, so a tab opened before a redeploy keeps // loading its lazy chunks. immutableStaticFiles: true, // Nitro uses a session-wide __vdpl cookie, which pins reloads too. // Keep Skew Protection ENABLED in the Vercel dashboard: this flag // only disables Nitro's cookie integration, not Vercel's routing. // start.ts pins only server functions using Vercel's request header. skewProtection: false, // One function serves every API route, so the timeout must cover // the longest one (integrations/stripe-sync). functions: { maxDuration: 300 }, }, // Documents from the static shell; only /api/* and /_serverFn/* // invoke the function. modules: [vercelSpaRoutes({ basePath })], ...(!process.env.VERCEL && { routeRules: { '/**': { headers: securityHeaders } } }), }), 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(), // Sentry's TanStack Start plugin(s) MUST be last so source maps reflect // every prior transform. `sentryTanstackStart` returns an ARRAY of // plugins (route patterns, source-map upload, middleware auto-wrap), so // it's spread. Source-map UPLOAD is skipped gracefully without // SENTRY_AUTH_TOKEN (and under SKIP_ASSET_UPLOAD). We disable the // middleware auto-wrap because start.ts wires the Sentry global // middlewares explicitly. ...sentryTanstackStart({ org: process.env.SENTRY_ORG ?? 'supabase', project: process.env.SENTRY_PROJECT ?? 'supabase-studio-tanstack', authToken: process.env.SENTRY_AUTH_TOKEN, autoInstrumentMiddleware: false, sourcemaps: process.env.SKIP_ASSET_UPLOAD === '1' || !process.env.SENTRY_AUTH_TOKEN ? { disable: true } : undefined, }), ], } })