From b689343d1bbf58d09f33330d601f8fc225587858 Mon Sep 17 00:00:00 2001 From: KalPop Date: Tue, 7 Jul 2026 16:25:09 +0100 Subject: [PATCH 1/2] Speed up dev launch + fix ENOENT spawning bun on Windows - React Compiler: run in build only (was adding a full compiler pass to every module transform in dev, ~45s first-load cost) - Preload the editor body chunk in parallel with page render; skip the 9 sibling workspace-page prewarms in dev (they forced ~20s of transforms and starved the editor) - freePort: add Windows netstat/tasklist fallback - dev.ts: resolve bun via process.execPath and other bins via Bun.which so child spawns don't ENOENT on Windows shims --- scripts/dev.ts | 14 ++- scripts/lib/freePort.ts | 90 +++++++++++++------ src/admin/AuthenticatedAdmin.tsx | 13 +++ .../AdminCanvasLayout/AdminCanvasLayout.tsx | 7 ++ vite.config.ts | 13 ++- 5 files changed, 108 insertions(+), 29 deletions(-) diff --git a/scripts/dev.ts b/scripts/dev.ts index a60564b3d..eeaca31c5 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -264,8 +264,20 @@ function stopChildren(signal: NodeJS.Signals = 'SIGTERM'): void { } } +// Resolve the executable for a command string. On Windows the `bun` the user +// launched may be a shim/alias rather than a real `bun.exe` on PATH, so a bare +// `Bun.spawn(['bun', ...])` fails with ENOENT. Use the absolute path of the +// running Bun binary for `bun`, and `Bun.which` (which resolves local +// node_modules/.bin entries) for everything else. +function resolveCommand(command: string): string[] { + const [bin, ...rest] = command.split(' ') + const resolved = + bin === 'bun' ? process.execPath : (Bun.which(bin) ?? bin) + return [resolved, ...rest] +} + for (const cfg of processes) { - const child = Bun.spawn(cfg.command.split(' '), { + const child = Bun.spawn(resolveCommand(cfg.command), { env: { ...process.env, ...cfg.env }, stdin: 'inherit', stdout: 'inherit', diff --git a/scripts/lib/freePort.ts b/scripts/lib/freePort.ts index d7f51e3b7..e778ee852 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,32 +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[] { - 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 } @@ -61,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) { @@ -72,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 { diff --git a/src/admin/AuthenticatedAdmin.tsx b/src/admin/AuthenticatedAdmin.tsx index 9b662fa1e..56243c310 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 acba6de1e..c93913c06 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 24432c221..d4fc52a8d 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({ }, }, }, + } }) From 4860a0be6a3cda45519c89675a8f5546f92a4566 Mon Sep 17 00:00:00 2001 From: KalPop Date: Tue, 7 Jul 2026 16:31:07 +0100 Subject: [PATCH 2/2] fix(dev): spawn bun/vite via real bun.exe, not shim bins On Windows the launched 'bun' is a .cmd shim (Bun.spawn(['bun',...]) -> ENOENT) and 'vite' is not on the child's PATH (Bun.which('vite') is null). Resolve both through process.execPath: bun for its own entry, bun executing node_modules/vite/bin/vite.js for the dev server. --- scripts/dev.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/scripts/dev.ts b/scripts/dev.ts index eeaca31c5..ce13be247 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -26,7 +26,7 @@ */ import { mkdir } from 'node:fs/promises' -import { dirname } from 'node:path' +import path from 'node:path' import { isSqliteUrl } from '../server/db' import { ensurePortFree } from './lib/freePort' @@ -208,7 +208,7 @@ async function waitForPostgresReady(timeoutMs = 60_000): Promise { if (isSqliteUrl(DATABASE_URL)) { const dbPath = DATABASE_URL.replace(/^sqlite:|^file:/, '') - await mkdir(dirname(dbPath), { recursive: true }) + await mkdir(path.dirname(dbPath), { recursive: true }) log(`Using SQLite at ${dbPath} — skipping Postgres docker provisioning`) } else { if (!dockerInstalled()) { @@ -264,15 +264,25 @@ function stopChildren(signal: NodeJS.Signals = 'SIGTERM'): void { } } -// Resolve the executable for a command string. On Windows the `bun` the user -// launched may be a shim/alias rather than a real `bun.exe` on PATH, so a bare -// `Bun.spawn(['bun', ...])` fails with ENOENT. Use the absolute path of the -// running Bun binary for `bun`, and `Bun.which` (which resolves local -// node_modules/.bin entries) for everything else. +// Resolve a command string to a spawnable argv. On Windows the `bun` the user +// launched is a `.cmd` shim (so `Bun.spawn(['bun', ...])` fails with ENOENT), +// and local bins like `vite` aren't on PATH inside a spawned child either +// (`Bun.which('vite')` is null because node_modules/.bin isn't on the child's +// PATH). Run both through the real Bun binary (`process.execPath`) — bun for +// its own entry, and bun executing vite's JS bin for the dev server. +const projectRoot = path.resolve(import.meta.dir, '..') + function resolveCommand(command: string): string[] { const [bin, ...rest] = command.split(' ') - const resolved = - bin === 'bun' ? process.execPath : (Bun.which(bin) ?? bin) + if (bin === 'bun') return [process.execPath, ...rest] + if (bin === 'vite') { + return [ + process.execPath, + path.resolve(projectRoot, 'node_modules/vite/bin/vite.js'), + ...rest, + ] + } + const resolved = Bun.which(bin) ?? bin return [resolved, ...rest] }