/* 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 remapping, { type SourceMapInput } from '@jridgewell/remapping' 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 MagicString from 'magic-string' import { defineConfig, loadEnv, type Plugin } from 'vite' 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 (stripping the // `assets/` directory and the `-.js` suffix), so the allowlist // stays stable across builds even as Rolldown reassigns hashes. 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(/^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> = {} 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) }, } } // Skew protection (vercel.com/docs/skew-protection): bake `?dpl=` into every asset URL the client bundle can request, so every hashed // chunk resolves against the deployment that referenced it. Vercel's edge // routes any request carrying `?dpl=` to that exact deployment, so a // long-lived dashboard session keeps loading its own deployment's hashed // chunks after a redeploy, while document navigations (which carry no pin) // always land on the latest deployment. If the pinned deployment ages out of // Skew Protection's Maximum Age, Vercel 404s the chunk and the // `vite:preloadError` backstop in router.tsx reloads onto the latest deploy. // API / server-function fetches are deliberately unpinned — that is what // lets use-check-latest-deploy detect newer deploys mid-session. // // Three mechanisms are needed for full coverage: // 1. `experimental.renderBuiltUrl` (in the config below) — asset and // public-file URLs referenced from JS/CSS/HTML, including the // `__vite__mapDeps` preload lists for dynamic imports. // 2. The `generateBundle` hook here — chunk-to-chunk `import`/`from` // specifiers. Rolldown renders these as bare relative paths that // `renderBuiltUrl` never sees (vitejs/vite#13834), and they're what the // browser actually fetches; the preload list alone would just warm a // cache entry under a different URL. Module identity is keyed by URL, so // the rewrite must cover EVERY specifier or a chunk could load twice // (pinned + unpinned) and break singleton module state. // 3. The `buildApp` hook here — `_shell.html` is prerendered by TanStack's // own post-order `buildApp` hook from the router manifest, outside // Vite's asset pipeline, so its