diff --git a/scripts/lib/freePort.ts b/scripts/lib/freePort.ts index 6953e18e..8729f76e 100644 --- a/scripts/lib/freePort.ts +++ b/scripts/lib/freePort.ts @@ -6,8 +6,8 @@ * (via `prompt`) whether to kill them and take over. Default answer is * "yes" — `Enter` accepts. Anything else cancels and exits 1. * - * macOS/Linux only — uses `lsof` and `ps`. The CMS isn't supported on - * Windows for local dev today, so this is intentionally Unix-only. + * macOS/Linux use `lsof` + `ps`; Windows falls back to `netstat` + + * `tasklist` (the `lsof`/`ps` binaries aren't on the default Windows PATH). */ const decoder = new TextDecoder() @@ -19,33 +19,72 @@ interface PortHolder { /** * Returns the PID(s) currently listening on `port` along with the - * holding process's `comm` name. Empty array when the port is free. + * holding process's name. Empty array when the port is free. */ function findPortHolders(port: number): PortHolder[] { - if (process.platform === 'win32') return [] - const lsof = Bun.spawnSync( - ['lsof', '-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], - { stdout: 'pipe', stderr: 'ignore' }, - ) - if (lsof.exitCode !== 0) return [] + if (process.platform !== 'win32') { + const lsof = Bun.spawnSync( + ['lsof', '-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], + { stdout: 'pipe', stderr: 'ignore' }, + ) + if (lsof.exitCode !== 0) return [] - const pids = decoder - .decode(lsof.stdout) - .split('\n') - .map((s) => s.trim()) - .filter((s) => s.length > 0) - .map((s) => Number(s)) - .filter((n) => Number.isInteger(n) && n > 0) + const pids = decoder + .decode(lsof.stdout) + .split('\n') + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .map((s) => Number(s)) + .filter((n) => Number.isInteger(n) && n > 0) + const holders: PortHolder[] = [] + for (const pid of pids) { + const ps = Bun.spawnSync(['ps', '-p', String(pid), '-o', 'comm='], { + stdout: 'pipe', + stderr: 'ignore', + }) + const command = + ps.exitCode === 0 ? decoder.decode(ps.stdout).trim() : '' + holders.push({ pid, command }) + } + return holders + } + + // Windows fallback: netstat -ano → PID, then tasklist for the image name. + const netstat = Bun.spawnSync(['netstat', '-ano'], { + stdout: 'pipe', + stderr: 'ignore', + }) + if (netstat.exitCode !== 0) return [] + const text = decoder.decode(netstat.stdout) const holders: PortHolder[] = [] - for (const pid of pids) { - const ps = Bun.spawnSync(['ps', '-p', String(pid), '-o', 'comm='], { - stdout: 'pipe', - stderr: 'ignore', - }) - const command = - ps.exitCode === 0 ? decoder.decode(ps.stdout).trim() : '' - holders.push({ pid, command }) + const seen = new Set() + for (const line of text.split('\n')) { + const cols = line.trim().split(/\s+/) + if (cols.length < 5) continue + if (cols[0] !== 'TCP' && cols[0] !== 'UDP') continue + const local = cols[1] + const state = cols[3] + const pid = Number(cols[4]) + if ( + (state === 'LISTENING' || state === 'UDP') && + local.endsWith(`:${port}`) && + Number.isInteger(pid) && + pid > 0 && + !seen.has(pid) + ) { + seen.add(pid) + const tl = Bun.spawnSync(['tasklist', '/FI', `PID eq ${pid}`, '/NH'], { + stdout: 'pipe', + stderr: 'ignore', + }) + const command = + tl.exitCode === 0 + ? (decoder.decode(tl.stdout).trim().split('\n')[1]?.split(/\s+/)[0] ?? + '') + : '' + holders.push({ pid, command }) + } } return holders } @@ -62,7 +101,7 @@ function probePort(port: number): 'free' | 'busy' { } } -function killHolder(holder: PortHolder, log: (msg: string) => void): boolean { +async function killHolder(holder: PortHolder, log: (msg: string) => void): Promise { try { process.kill(holder.pid, 'SIGTERM') } catch (err) { @@ -73,7 +112,7 @@ function killHolder(holder: PortHolder, log: (msg: string) => void): boolean { } // Wait briefly for graceful exit, then escalate. for (let i = 0; i < 20; i++) { - Bun.spawnSync(['sleep', '0.1']) + await Bun.sleep(100) try { process.kill(holder.pid, 0) } catch { @@ -146,7 +185,7 @@ export async function ensurePortFree( // Re-probe to confirm the OS has released the socket. for (let i = 0; i < 20; i++) { if (probePort(port) === 'free') return - Bun.spawnSync(['sleep', '0.1']) + await Bun.sleep(100) } log(`Port ${port} is still busy after killing its holders. Aborting.`) process.exit(1) diff --git a/src/admin/AuthenticatedAdmin.tsx b/src/admin/AuthenticatedAdmin.tsx index b47fee4c..d5728cb6 100644 --- a/src/admin/AuthenticatedAdmin.tsx +++ b/src/admin/AuthenticatedAdmin.tsx @@ -58,6 +58,8 @@ import { canAccessWorkspace, firstAccessibleWorkspace, workspacePath } from './a import { Navigate, useInRouterContext } from './lib/routing' import { SpotlightRoot } from './spotlight' import { prewarmedLazy } from './lib/prewarmedLazy' +import { preloadAdminCanvasEditorBody } from '@admin/layouts/AdminCanvasLayout/AdminCanvasLayout' + import { useAdminUi } from './state/adminUi' import styles from './AdminEntry.module.css' @@ -220,6 +222,17 @@ export default function AuthenticatedAdmin({ section, currentUser }: Authenticat // needs; in prod it's almost immediately after paint. Fallback to // setTimeout for browsers that don't support it (Safari < 17). useEffect(() => { + // Start the editor-body chunk fetch as early as possible, in parallel with + // the page render, so there is no separate serial wait for the editor. + // Idempotent; harmless in dev where the body is already eager. + if (section === 'site') preloadAdminCanvasEditorBody() + + // Sibling workspace pages: prewarm in prod only. In dev this forces Vite + // to transform all 9 workspace-page subgraphs up front (~20s measured) + // and starves the editor body of CPU — skip it in dev; they load on + // navigation instead. + if (import.meta.env.DEV) return + type IdleCb = (cb: () => void, options?: { timeout?: number }) => number type CancelIdleCb = (id: number) => void const w = window as unknown as { diff --git a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx index 5b07caee..066c62ab 100644 --- a/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx +++ b/src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx @@ -84,6 +84,13 @@ const AdminCanvasEditorBody = prewarmedLazy( { displayName: 'AdminCanvasEditorBody' }, ) +// Fire the editor-body chunk fetch as early as possible (idempotent). Used by +// the workspace prewarm so the editor chunk downloads in parallel with the +// page render instead of as a second, serial wait after first paint. +export const preloadAdminCanvasEditorBody = (): void => { + void AdminCanvasEditorBody.preload() +} + // SettingsModal is heavy (~37 KB raw) and closed 99% of the time. lazy() // pushes it into its own chunk and the conditional render below avoids // kicking off the dynamic import until the user actually opens settings. diff --git a/vite.config.ts b/vite.config.ts index 24432c22..d4fc52a8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig, type Plugin } from 'vite' +import { defineConfig } from 'vite' import react, { reactCompilerPreset } from '@vitejs/plugin-react' import babel from '@rolldown/plugin-babel' import path from 'path' @@ -164,11 +164,17 @@ function vendorChunkName(moduleId: string): string | null { // add the `"use no memo"` directive at the top of the function body. // https://vite.dev/config/ -export default defineConfig({ +export default defineConfig(({ command }) => { + const isBuild = command === 'build' + return { plugins: [ publicSiteDevProxyPlugin(), react(), - babel({ presets: [reactCompilerPreset()] }), + // React Compiler: production builds only. In dev it adds a full compiler + // pass to EVERY module transform, which (measured) accounts for a large + // share of the ~45s first-load transform cost. Production behaviour is + // unchanged — the build still compiles with the preset. + ...(isBuild ? [babel({ presets: [reactCompilerPreset()] })] : []), ], resolve: { alias: { @@ -257,4 +263,5 @@ export default defineConfig({ }, }, }, + } })