diff --git a/apps/studio/package.json b/apps/studio/package.json index 2f973d6dfd8..8756068aa06 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -6,8 +6,9 @@ "scripts": { "preinstall": "npx only-allow pnpm", "dev": "NODE_OPTIONS=--max-old-space-size=8192 vite dev --port ${STUDIO_PORT:-8082}", - "build": "vite build", - "start": "vite preview --port 8082", + "build": "vite build --mode ${MODE:-production}", + "start": "node scripts/serve.js", + "preview": "vite preview --mode ${MODE:-production} --port 8082", "dev:next": "next dev -p ${STUDIO_PORT:-8082}", "build:next": "next build && if [ \"$SKIP_ASSET_UPLOAD\" != \"1\" ]; then ./../../scripts/upload-static-assets.sh; fi", "start:next": "next start -p 8082", diff --git a/apps/studio/scripts/serve.js b/apps/studio/scripts/serve.js new file mode 100644 index 00000000000..aacf8f6e98d --- /dev/null +++ b/apps/studio/scripts/serve.js @@ -0,0 +1,165 @@ +#!/usr/bin/env node +// Standalone Node HTTP server that hosts the production studio build. +// +// We export the fetch-handler shape from `dist/server/server.js` because +// Vercel consumes it directly (see `apps/studio/api/server.js`). For +// self-hosted / e2e, we need an HTTP listener of our own — this is that +// listener. +// +// Responsibilities: +// - Load env files in vite preview's order so non-NEXT_PUBLIC_* values +// (POSTGRES_PASSWORD, PG_META_CRYPTO_KEY, etc.) are in process.env +// at request time. NEXT_PUBLIC_* are already inlined into the bundle +// at build time and don't need to be re-loaded. +// - Serve static client assets from `dist/client/` directly with the +// right MIME types and cache headers. +// - Forward everything else to the TanStack Start handler exported +// from `dist/server/server.js`. + +import { createServer } from 'node:http' +import { createReadStream } from 'node:fs' +import { readFile, stat } from 'node:fs/promises' +import { Readable } from 'node:stream' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const studioRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const clientDir = path.join(studioRoot, 'dist/client') +const mode = process.env.MODE || 'production' + +const dotenvLine = /^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*?)\s*$/ +const envFiles = ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`] +const parsed = {} +for (const file of envFiles) { + let content + try { + content = await readFile(path.join(studioRoot, file), 'utf8') + } catch { + continue + } + for (const raw of content.split('\n')) { + const m = dotenvLine.exec(raw) + if (!m) continue + let v = m[2] + if ( + (v.startsWith('"') && v.endsWith('"')) || + (v.startsWith("'") && v.endsWith("'")) + ) { + v = v.slice(1, -1) + } + parsed[m[1]] = v + } +} +// Don't clobber values the shell already provides — match `vite preview`. +for (const [k, v] of Object.entries(parsed)) { + if (process.env[k] !== undefined) continue + process.env[k] = v.replace( + /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, + (_, name) => process.env[name] ?? parsed[name] ?? '' + ) +} + +const { default: handler } = await import(path.join(studioRoot, 'dist/server/server.js')) + +const mimeByExt = new Map([ + ['.js', 'application/javascript; charset=utf-8'], + ['.mjs', 'application/javascript; charset=utf-8'], + ['.css', 'text/css; charset=utf-8'], + ['.html', 'text/html; charset=utf-8'], + ['.json', 'application/json; charset=utf-8'], + ['.map', 'application/json; charset=utf-8'], + ['.png', 'image/png'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.gif', 'image/gif'], + ['.svg', 'image/svg+xml'], + ['.ico', 'image/x-icon'], + ['.woff', 'font/woff'], + ['.woff2', 'font/woff2'], + ['.txt', 'text/plain; charset=utf-8'], + ['.webmanifest', 'application/manifest+json'], +]) + +// Vite emits hashed filenames (e.g. `index-DB4J79t9.js`) for everything +// it bundles. Those are content-addressed so we serve them immutable. +const HASHED_RE = /-[A-Za-z0-9_-]{6,}\.[a-z0-9]+$/ + +async function serveStatic(req, res) { + let pathname + try { + pathname = new URL(req.url, 'http://localhost').pathname + } catch { + return false + } + if (pathname === '/' || pathname.endsWith('/')) return false + if (pathname.includes('..') || pathname.includes('\\')) return false + const filePath = path.join(clientDir, pathname) + if (!filePath.startsWith(clientDir + path.sep)) return false + + let st + try { + st = await stat(filePath) + } catch { + return false + } + if (!st.isFile()) return false + + res.statusCode = 200 + res.setHeader('content-type', mimeByExt.get(path.extname(filePath).toLowerCase()) ?? 'application/octet-stream') + res.setHeader('content-length', String(st.size)) + res.setHeader( + 'cache-control', + HASHED_RE.test(pathname) ? 'public, max-age=31536000, immutable' : 'no-cache' + ) + await new Promise((resolve, reject) => { + const stream = createReadStream(filePath) + stream.on('error', reject) + stream.on('end', resolve) + stream.pipe(res) + }) + return true +} + +function toWebRequest(req) { + const protocol = req.socket.encrypted ? 'https' : 'http' + const url = `${protocol}://${req.headers.host ?? 'localhost'}${req.url}` + const headers = new Headers() + for (const [k, v] of Object.entries(req.headers)) { + if (k.startsWith(':')) continue + if (Array.isArray(v)) for (const vv of v) headers.append(k, vv) + else if (v !== undefined) headers.set(k, v) + } + const init = { method: req.method, headers } + if (req.method !== 'GET' && req.method !== 'HEAD') { + init.body = Readable.toWeb(req) + init.duplex = 'half' + } + return new Request(url, init) +} + +async function pipeWebResponse(response, res) { + res.statusCode = response.status + for (const [k, v] of response.headers) res.setHeader(k, v) + if (response.body) { + for await (const chunk of response.body) res.write(chunk) + } + res.end() +} + +const port = Number(process.env.PORT || 8082) +createServer(async (req, res) => { + try { + if (await serveStatic(req, res)) return + const response = await handler.fetch(toWebRequest(req)) + await pipeWebResponse(response, res) + } catch (err) { + console.error('[serve] request failed:', err) + if (!res.headersSent) { + res.statusCode = 500 + res.setHeader('content-type', 'text/plain; charset=utf-8') + } + res.end('Internal Server Error') + } +}).listen(port, () => { + console.log(`Studio listening on http://localhost:${port} (mode=${mode})`) +}) diff --git a/apps/studio/turbo.jsonc b/apps/studio/turbo.jsonc index 25d64c35428..7ae894371d5 100644 --- a/apps/studio/turbo.jsonc +++ b/apps/studio/turbo.jsonc @@ -98,6 +98,15 @@ "STATUSPAGE_PAGE_ID", "INCIDENT_IO_API_KEY", "LIVE_SUPABASE_SECRET_KEY", + // Selects the build mode for the studio's `build`/`start` scripts + // (e.g. e2e sets `MODE=test`). Listed so turbo invalidates the + // cache when it changes — without this, switching between test and + // production builds reuses a stale cached output. + "MODE", + // Read by scripts/serve.js (the Node host for `pnpm start`). + // Declared here so the studio-package turbo env lint rule passes + // even though turbo doesn't directly drive `start`. + "PORT", ], "passThroughEnv": [ "CURRENT_CLI_VERSION", @@ -106,7 +115,12 @@ "SNIPPETS_MANAGEMENT_FOLDER", "EDGE_FUNCTIONS_MANAGEMENT_FOLDER", ], - "outputs": [".next/**", "!.next/cache/**", "!.next/dev/**/*"], + "outputs": [ + ".next/**", + "!.next/cache/**", + "!.next/dev/**/*", + "dist/**", + ], }, }, } diff --git a/apps/studio/vite.config.ts b/apps/studio/vite.config.ts index 185db7bca16..24a311640f5 100644 --- a/apps/studio/vite.config.ts +++ b/apps/studio/vite.config.ts @@ -228,8 +228,10 @@ function assertNoChunkCycles(): Plugin { export default defineConfig(({ mode }) => { // Inline NEXT_PUBLIC_* env vars at build time so `process.env.NEXT_PUBLIC_*` - // works in the browser bundle (mirrors Next.js behaviour). loadEnv reads the - // standard .env file hierarchy and merges with process.env. + // works in the browser bundle (mirrors Next.js behaviour). For e2e the + // build is invoked with `--mode test` (no shell `NODE_ENV=test`, which + // would force the React plugin onto the dev JSX runtime), so `.env.test` + // overrides whatever a developer has in `.env.local`. const env = loadEnv(mode, rootDir, '') const publicEnvDefines = Object.fromEntries( Object.entries(env) @@ -384,7 +386,13 @@ export default defineConfig(({ mode }) => { // `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. - noExternal: ['lodash', /^next(\/|$)/, 'tslib', 'react-use'], + // `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. + noExternal: ['lodash', /^next(\/|$)/, 'tslib', 'react-use', 'awesome-debounce-promise'], }, plugins: [ nextCompat(), diff --git a/package.json b/package.json index 5183c05ba51..9584fb20257 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ "test:studio": "turbo run test --filter=studio", "test:studio:watch": "turbo run test --filter=studio -- watch", "e2e:setup:cli": "supabase stop --all --no-backup ; supabase start --exclude studio,mailpit && if [ -z \"${CI}\" ]; then supabase db reset; fi && supabase status --output json > keys.json && node scripts/generateLocalEnv.js", - "e2e:setup:selfhosted": "SKIP_ASSET_UPLOAD=1 pnpm e2e:setup:cli && NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" pnpm run build:studio && NODE_ENV=test pnpm --prefix ./apps/studio start", - "e2e:setup:selfhosted:start-studio": "SKIP_ASSET_UPLOAD=1 NODE_ENV=test pnpm --prefix ./apps/studio start", + "e2e:setup:selfhosted": "SKIP_ASSET_UPLOAD=1 pnpm e2e:setup:cli && MODE=test NODE_OPTIONS=\"--max-old-space-size=4096\" pnpm run build:studio && MODE=test pnpm --prefix ./apps/studio start", + "e2e:setup:selfhosted:start-studio": "SKIP_ASSET_UPLOAD=1 MODE=test pnpm --prefix ./apps/studio start", "e2e:setup:platform": "SKIP_ASSET_UPLOAD=1 NODE_OPTIONS=\"--max-old-space-size=4096\" pnpm run build:studio && pnpm --prefix ./apps/studio start", "e2e": "pnpm --prefix e2e/studio run e2e", "e2e:ui": "pnpm --prefix e2e/studio run e2e:ui", @@ -67,13 +67,6 @@ "pnpm": "10.24", "node": ">=22" }, - "keywords": [ - "postgres", - "firebase", - "storage", - "functions", - "database", - "auth" - ], + "keywords": ["postgres", "firebase", "storage", "functions", "database", "auth"], "packageManager": "pnpm@10.24.0" } diff --git a/scripts/generateLocalEnv.js b/scripts/generateLocalEnv.js index 808f964d89d..8f8f645f858 100644 --- a/scripts/generateLocalEnv.js +++ b/scripts/generateLocalEnv.js @@ -11,7 +11,10 @@ const defaultEnv = { // NEXT_ANALYTICS_BACKEND_PROVIDER: 'postgres', // SUPABASE_REST_URL: 'http://127.0.0.1:54321/rest/v1/', // NEXT_PUBLIC_ENABLE_LOGS: 'false', - // NEXT_PUBLIC_IS_PLATFORM: 'false', + // E2E tests target self-hosted studio (no auth). Explicitly written so that + // .env.test overrides any IS_PLATFORM=true that a developer's .env.local + // might set for their own platform-mode work. + NEXT_PUBLIC_IS_PLATFORM: 'false', PG_META_CRYPTO_KEY: 'SAMPLE_KEY', POSTGRES_PASSWORD: 'postgres', POSTGRES_HOST: 'db', @@ -31,7 +34,7 @@ const defaultEnv = { NEXT_PUBLIC_HCAPTCHA_SITE_KEY: '10000000-ffff-ffff-ffff-000000000001', NEXT_PUBLIC_NODE_ENV: 'test', SNIPPETS_MANAGEMENT_FOLDER: '../../supabase/snippets', - EDGE_FUNCTIONS_MANAGEMENT_FOLDER: '../../supabase/functions' // path relative to studio project + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: '../../supabase/functions', // path relative to studio project } const environment = { ...generatedEnv, ...defaultEnv }