Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 66 additions & 27 deletions scripts/lib/freePort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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() : '<unknown>'
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() : '<unknown>'
holders.push({ pid, command })
const seen = new Set<number>()
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] ??
'<unknown>')
: '<unknown>'
holders.push({ pid, command })
}
}
return holders
}
Expand All @@ -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<boolean> {
try {
process.kill(holder.pid, 'SIGTERM')
} catch (err) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions src/admin/AuthenticatedAdmin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ const AdminCanvasEditorBody = prewarmedLazy<AdminCanvasEditorBodyProps>(
{ 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.
Expand Down
13 changes: 10 additions & 3 deletions vite.config.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -257,4 +263,5 @@ export default defineConfig({
},
},
},
}
})