Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376#!/usr/bin/env bun// scripts/dev.ts//// `bun run dev` — starts the local Automerge sync server and Vite together,// wires Vite to the sync server via `VITE_SYNC_SERVER_URL`, tears both// down on Ctrl+C.
import { spawn, type ChildProcess } from 'node:child_process'import { existsSync, readFileSync, unlinkSync } from 'node:fs'import { dirname, join, resolve as pathResolve } from 'node:path'import { fileURLToPath } from 'node:url'
import { getHarnessConfig, syncServerCmdlineFile, syncServerPidFile, syncServerStorageIdFile, type HarnessConfig,} from '../tests/integration/pushwork-sync/support/env.ts'import { startSyncServer, stopSyncServer,} from '../tests/integration/pushwork-sync/support/server.ts'
const DEFAULT_DEV_SERVER_PORT = 7002const DEFAULT_DEV_SYNC_PORT = 3031
function envPort(name: string, fallback: number): number { const raw = process.env[name] if (raw === undefined || raw === '') return fallback const port = Number(raw) if (!Number.isFinite(port) || port <= 0 || port > 65_535 || !Number.isInteger(port)) { throw new Error(`${name} must be an integer from 1 to 65535, got '${raw}'.`) } return port}
function devRepoRoot(): string { return pathResolve(dirname(fileURLToPath(import.meta.url)), '..')}
/** * Dev sync state is intentionally isolated from the test harness state * (default `.dev-state/pushwork-sync/`). Using a separate dir + port * means: * * 1. A test run does not stomp dev session data, and a dev session * does not see test fixtures appear in its tree. * 2. `stopSyncServer` on dev never targets the test harness's PID * (they read different state files). * 3. Both can be live concurrently. `.envrc` assigns separate dev and * test ports per worktree; standalone invocations retain :3031 and * :3030 as their respective fallbacks. * * Env-var overrides (SYNC_SERVER_URL, SYNC_SERVER_PORT, * PUSHWORK_SYNC_STATE_ROOT) are still honored — but the coherence * rules from `getHarnessConfig` apply, so a URL/port disagreement * throws rather than silently producing a misaligned config. */function buildDevConfig(): HarnessConfig { const devSyncPort = envPort('REAMS_DEV_SYNC_PORT', DEFAULT_DEV_SYNC_PORT) const devSyncUrl = `ws://localhost:${devSyncPort}` // Setting our defaults via env BEFORE calling getHarnessConfig means // getHarnessConfig's URL/port coherence logic still runs over our // chosen values: if the user sets SYNC_SERVER_URL alone, the helper // derives the matching port; if they set SYNC_SERVER_PORT alone, it // derives the matching URL. Without this seeding, dev would have to // re-implement (and re-bug) the same coherence rules. if (process.env.SYNC_SERVER_URL === undefined && process.env.SYNC_SERVER_PORT === undefined) { process.env.SYNC_SERVER_URL = devSyncUrl process.env.SYNC_SERVER_PORT = String(devSyncPort) } if (process.env.PUSHWORK_SYNC_STATE_ROOT === undefined) { process.env.PUSHWORK_SYNC_STATE_ROOT = join(devRepoRoot(), '.dev-state', 'dev-sync') } return getHarnessConfig()}
/** * Synchronous-only sync-server kill, used from the startup signal * handler where we can't await `stopSyncServer`'s SIGTERM-then-poll- * then-SIGKILL loop before `process.exit` terminates us. Reads the PID * file, SIGKILLs, unlinks state files. Best-effort. */function syncKillSyncServer(config: HarnessConfig): boolean { const pidFile = syncServerPidFile(config) if (!existsSync(pidFile)) return false let pid: number try { pid = Number(readFileSync(pidFile, 'utf8').trim()) } catch { pid = NaN } if (Number.isFinite(pid) && pid > 0) { try { process.kill(pid, 'SIGKILL') } catch { // ESRCH / EPERM — nothing more to do. } } for (const path of [ pidFile, syncServerCmdlineFile(config), syncServerStorageIdFile(config), ]) { try { if (existsSync(path)) unlinkSync(path) } catch { // Best-effort. Stale files are recoverable on next start. } } return Number.isFinite(pid) && pid > 0}
type SyncOwnership = 'started-by-us' | 'reattached'type RunPhase = 'starting-sync' | 'starting-vite' | 'running' | 'shutting-down'
async function main(): Promise<void> { const devServerPort = envPort('REAMS_DEV_PORT', DEFAULT_DEV_SERVER_PORT) const config = buildDevConfig()
if (!existsSync(config.syncServerBin)) { process.stderr.write( `Sync server binary not found${ config.syncServerBin ? ` at ${config.syncServerBin}` : ' on $PATH' }.\n` + 'Enter the Nix dev shell (direnv allow / nix develop), which\n' + 'provides automerge-repo-sync-server, or set SYNC_SERVER_BIN=<path>.\n' + '\n' + 'To run Vite alone (and connect to whatever sync server is in\n' + 'localStorage `reams:sync-server-url`, VITE_SYNC_SERVER_URL, or\n' + 'the baked-in default), use:\n' + ' bun run dev:vite-only\n', ) process.exit(1) }
// Mutable state shared between the signal handler and main(). A // single handler covers all phases — switching handlers mid-flight // is error-prone (process.off can race with signal arrival, and // some bun versions have signal-dispatch quirks that ignore handler // swaps in unobvious ways). const ctx: { phase: RunPhase ownership: SyncOwnership | null vite: ChildProcess | null ctrlCCount: number killEscalation: NodeJS.Timeout | null keepalive: NodeJS.Timeout | null shutdownInFlight: boolean } = { phase: 'starting-sync', ownership: null, vite: null, ctrlCCount: 0, killEscalation: null, keepalive: null, shutdownInFlight: false, }
// Always-installed signal handler. Behavior depends on phase: // starting-sync: SIGKILL the sync server (sync; we can't await), // unlink state files, process.exit(130). // starting-vite: same — we got the sync server up but Vite hasn't // spawned yet, so there's no Vite child to kill. // running: graceful Vite SIGTERM with 5s SIGKILL escalation; // Vite's exit handler triggers stopSyncServer + // process.exit via finalize(). // shutting-down: count Ctrl+C strikes; third strike force-exits // to give the user an escape hatch from a stuck // shutdown. const onSignal = (signal: NodeJS.Signals): void => { ctx.ctrlCCount += 1
if (ctx.phase === 'starting-sync' || ctx.phase === 'starting-vite') { process.stderr.write( `\nReceived ${signal} during startup (phase=${ctx.phase}). Killing children …\n`, ) // Vite may have been spawned in `starting-vite` before the // phase transition to `running` and before the exit/error // listeners were wired (see the phase-transition block // below). Kill it here if so — otherwise process.exit(130) // leaves a detached Vite child with no parent. if (ctx.vite !== null && ctx.vite.pid !== undefined) { try { process.kill(-ctx.vite.pid, 'SIGKILL') } catch { // ESRCH / EPERM — already gone or can't reach. } } const killed = syncKillSyncServer(config) if (killed) process.stderr.write(' sync server killed (SIGKILL).\n') process.exit(130) }
if (ctx.phase === 'running') { ctx.phase = 'shutting-down' process.stdout.write(`\n▸ Received ${signal}, stopping Vite …\n`) if (ctx.vite !== null && ctx.vite.pid !== undefined) { try { process.kill(-ctx.vite.pid, 'SIGTERM') } catch { // ESRCH / EPERM — vite already gone, exit handler will fire. } } ctx.killEscalation = setTimeout(() => { if ( ctx.vite !== null && ctx.vite.exitCode === null && ctx.vite.signalCode === null ) { process.stderr.write(' Vite did not exit on SIGTERM, sending SIGKILL …\n') try { process.kill(-ctx.vite.pid!, 'SIGKILL') } catch { // Already gone. } } }, 5000) return }
// phase === 'shutting-down' if (ctx.ctrlCCount >= 3) { // Force-exit fast path: third Ctrl+C bails out immediately. The // detached sync server child survives (no shutdown), the next // `bun run dev` will reattach via the PID file. process.stderr.write('\nForce-exit (3rd signal). Sync server may be left running.\n') process.exit(130) } process.stderr.write( `(already shutting down; hit Ctrl+C ${3 - ctx.ctrlCCount} more time(s) to force-exit)\n`, ) } process.on('SIGINT', onSignal) process.on('SIGTERM', onSignal)
process.stdout.write(`▸ Starting sync server at ${config.syncServerUrl} …\n`) const syncResult = await startSyncServer(config) ctx.ownership = syncResult.state === 'already-running' ? 'reattached' : 'started-by-us' if (ctx.ownership === 'reattached') { process.stdout.write( ` (reattached to existing server, pid ${syncResult.pid})\n` + ' Ctrl+C will NOT stop the server — it was running before this dev session.\n' + ' Stop it manually with: bun run test:pushwork-sync:server:down\n', ) } else { process.stdout.write(` started (pid ${syncResult.pid})\n`) } process.stdout.write(` storage id ${syncResult.storageId}\n`) process.stdout.write(` log file ${config.stateRoot}/server.log\n`)
ctx.phase = 'starting-vite'
const localViteBin = join(devRepoRoot(), 'node_modules', '.bin', 'vite') const workspaceViteBin = join(devRepoRoot(), '..', '..', 'node_modules', '.bin', 'vite') const viteBin = existsSync(localViteBin) ? localViteBin : workspaceViteBin if (!existsSync(viteBin)) { process.stderr.write( `Vite binary not found at ${localViteBin} or ${workspaceViteBin}.\n` + 'Run `bun install` first.\n', ) if (ctx.ownership === 'started-by-us') { await stopSyncServer(config).catch(() => {}) } process.exit(1) }
// Declare `finalize` BEFORE spawning Vite so the exit/error // listeners (attached immediately after the spawn) close over a // bound binding instead of a temporal-dead-zone reference. The // const-after-spawn shape worked by event-loop-semantics luck // (listeners only fire async, after the surrounding sync block); // declaring first removes that fragility. const finalize = async (exitCode: number): Promise<void> => { if (ctx.shutdownInFlight) return ctx.shutdownInFlight = true // DO NOT clear `ctx.keepalive` here. The keepalive interval is the // only thing holding bun's event loop open during // `await stopSyncServer` — bun (1.3.x) releases the event loop // after `startSyncServer`'s `child.unref()` even when the awaited // promise is the only pending work. Clearing keepalive before // awaiting would let bun exit mid-await, leaking the sync server. // We clear it on the line right before `process.exit`.
// ONLY stop the sync server if we started it. Reattaching means // someone else owns the lifecycle. if (ctx.ownership === 'started-by-us') { process.stdout.write('▸ Stopping sync server …\n') try { const pid = await stopSyncServer(config) if (pid !== null) process.stdout.write(` stopped pid ${pid}\n`) } catch (err) { process.stderr.write( `WARN: failed to stop sync server cleanly: ${ err instanceof Error ? err.message : String(err) }\n` + ' Inspect with: bun run test:pushwork-sync:server:status\n', ) } } else { process.stdout.write( '▸ Leaving sync server running (this dev session reattached to it).\n', ) } if (ctx.keepalive !== null) clearInterval(ctx.keepalive) process.exit(exitCode) }
process.stdout.write(`▸ Starting Vite on http://localhost:${devServerPort} …\n`)
// `--strictPort` makes Vite fail loudly if the port is in use rather // than silently picking another port. The worktree's `DEV_SERVER_URL` // must continue to identify the server that the e2e suite targets. // `detached: true` puts Vite in its own process group so we can // signal the entire group via `process.kill(-pid, sig)` and reach // its grandchildren (esbuild workers, etc.) reliably. ctx.vite = spawn( viteBin, ['--port', String(devServerPort), '--host', '--strictPort'], { stdio: 'inherit', detached: true, env: { ...process.env, VITE_SYNC_SERVER_URL: config.syncServerUrl, }, }, )
// Attach exit/error listeners BEFORE transitioning phase to // 'running'. If a signal arrives between `spawn` and the phase // transition while no exit listener is attached, `onSignal`'s // running-phase branch would SIGTERM Vite but no listener would // call `finalize` when Vite dies — the keepalive would hold bun // open forever waiting for an exit event that nobody handles. ctx.vite.on('exit', (code, signal) => { if (ctx.killEscalation !== null) clearTimeout(ctx.killEscalation) // Conventional shell semantics: signal → 128 + signal number. Keeps // `bun run dev` distinguishable in CI between "user hit Ctrl+C" // (exit 130) and "Vite crashed" (propagated exit code). let exitCode: number if (code !== null) { exitCode = code } else if (signal !== null) { const SIGNALS: Record<string, number> = { SIGINT: 2, SIGTERM: 15, SIGKILL: 9, SIGHUP: 1 } exitCode = 128 + (SIGNALS[signal] ?? 0) } else { exitCode = 1 } void finalize(exitCode) })
ctx.vite.on('error', (err) => { process.stderr.write(`Vite failed to spawn: ${err.message}\n`) void finalize(1) })
ctx.phase = 'running'
// Keepalive: see finalize() comment. Without this bun exits the // event loop too early on signal-driven shutdown and the // `await stopSyncServer` inside `finalize` never resolves. // (Remove when bun's event loop matches Node's behavior for // pending awaits with no other handles. Tracked as a known quirk // in bun 1.3.x; sig-test12.ts demonstrates the minimal repro.) ctx.keepalive = setInterval(() => {}, 24 * 60 * 60 * 1000)}
main().catch((err) => { process.stderr.write( `dev startup failed: ${err instanceof Error ? err.message : String(err)}\n`, ) process.exit(1)})