mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
## What kind of change does this PR introduce? Feature. ## What is the current behaviour? Studio Appearance preferences only select a theme mode. The underlying theme colours cannot be adjusted, and the existing proof of concept allowed unsafe combinations and introduced a bespoke Slider variant. ## What is the new behaviour? - Preserves the existing System, Dark, Light, and Classic Dark theme options. Classic Dark remains a fixed preset. - Adds four theme colour controls using the existing Supabase Slider unchanged. Each control presents a consistent 0 to 100 scale mapped to bounded light and dark ranges. - Previews colour changes while dragging and persists them once the interaction finishes, including rapid pointer gestures. - Stores light and dark overrides separately, validates stored values, clamps legacy values, and removes overrides that return to their shipped defaults. - Adds concise descriptions for Chroma, Contrast, Surface, and Elevation step, with a scoped Reset action shown only when the active theme differs from its defaults. - Keeps Slider in a stable shared chunk so production builds do not create a circular dependency between generated UI chunks. | Before | After | | --- | --- | | <img width="1448" height="1284" alt="CleanShot 2026-09-15 at 14 33 53@2x" src="https://github.com/user-attachments/assets/d55151c7-b2a9-40c6-9468-e77ae685ac38" /> | <img width="1454" height="1958" alt="CleanShot 2026-09-15 at 17 48 47@2x" src="https://github.com/user-attachments/assets/9d302e67-76cc-4341-948c-81713dea2e93" /> | ## To test 1. Open `/account/me` and scroll to Appearance. 2. Switch between System, Dark, Light, and Classic Dark. Confirm the same four modes remain available in the account theme menu. 3. Confirm Classic Dark retains its existing appearance and does not show theme colour controls. 4. In System, Dark, or Light, move each Theme colors slider to both ends. Confirm the dashboard previews the change, remains readable, and the theme cards do not shift or remount. 5. Reload the page and confirm colour changes persist separately for Light and Dark. 6. Return all sliders to their defaults, or select Reset, and confirm the Reset action disappears. 7. In System mode, change the operating system theme and confirm each resolved mode restores its own colour settings. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
800 lines
37 KiB
TypeScript
800 lines
37 KiB
TypeScript
/* 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<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).
|
|
// 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: <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 (directory and `-<hash>.js`
|
|
// suffix stripped), so the allowlist stays stable across builds.
|
|
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(/^.*\//, '')
|
|
.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<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)
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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/<salt>/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<string, string>).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<string, string>).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<string, string | undefined>
|
|
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-<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.
|
|
//
|
|
// `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,
|
|
}),
|
|
],
|
|
}
|
|
})
|