@statorjs/stator 2.5.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,562 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { realpathSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { createServer as createHttpServer } from 'node:http'
5
+ import { register } from 'node:module'
6
+ import { relative, resolve, sep } from 'node:path'
7
+ import { pathToFileURL } from 'node:url'
8
+ import { MessageChannel } from 'node:worker_threads'
9
+ import { getRequestListener } from '@hono/node-server'
10
+ import chokidar from 'chokidar'
11
+ import type { Hono } from 'hono'
12
+ import {
13
+ bundleIslands,
14
+ type IslandBundle,
15
+ localImports,
16
+ routeIslandMap,
17
+ walkFiles,
18
+ } from '../build/islands.ts'
19
+ import { sourceId } from '../build/source-id.ts'
20
+ import { CompileError, compile, formatCompileError, regionResolverFor } from '../compiler/index.ts'
21
+ import type { AnyMachineDef, EventOf } from '../engine/index.ts'
22
+ import { dispatchToApp } from './app-dispatch.ts'
23
+ import { findFreePort, installGracefulShutdown, printDevBanner } from './banner.ts'
24
+ import { type BootTeardown, discoverBoot, runBoot } from './boot.ts'
25
+ import { resolveAppConfig } from './config-compat.ts'
26
+ import type { DevAppConfig } from './dev.ts'
27
+ import { findPollLoops } from './dev-lint.ts'
28
+ import { discoverMachines } from './discovery.ts'
29
+ import { wireAppEffects } from './effects.ts'
30
+ import { loadDotenv } from './env.ts'
31
+ import { buildHonoApp, contentTypeFor } from './http.ts'
32
+ import { logger, setLogLevel } from './logger.ts'
33
+ import { codeHashOf, codeInputsOf } from './machine-hash.ts'
34
+ import { MachineStore } from './machine-store.ts'
35
+ import { discoverMiddleware } from './middleware.ts'
36
+ import { discoverRoutes } from './route-discovery.ts'
37
+ import { InMemoryStore } from './store.ts'
38
+
39
+ /**
40
+ * Native dev server — the Vite exit (Option D). The server runs from the app's
41
+ * SOURCE tree with no Vite and no mirror: `.stator` files are compiled on
42
+ * import by `dev-loader.mjs` (the framework compiler + esbuild on Node's loader
43
+ * hooks), machines/routes/middleware are discovered from their real paths, and
44
+ * the app is served by Hono exactly as `stator start` serves a build. Because
45
+ * nothing is copied, `import.meta.url`-relative paths in app code (a SQLite
46
+ * file, a data dir) mean the same thing in dev as in prod.
47
+ *
48
+ * The dev-only capabilities Vite used to provide are reproduced natively:
49
+ * - **Live reload** — a source change bumps the version of the changed file
50
+ * and its transitive importers (a static reverse import graph; the loader
51
+ * stamps every app module with its own `?v=`), so exactly that subtree
52
+ * re-evaluates and everything else keeps its module instance; connected
53
+ * browsers reload via a tiny SSE channel (`/__stator_dev`), replacing
54
+ * Vite's HMR websocket. A failed rebuild keeps the last good
55
+ * graph serving and renders the error (with its code frame) in an overlay.
56
+ * - **Scoped CSS + island scripts** — islands bundle through the
57
+ * `bundleIslands` seam (Vite today, never on the SSR path) and are served
58
+ * from memory with the concatenated scoped CSS, on the same URLs and with
59
+ * the same `<head>` shape as production.
60
+ *
61
+ * See spec `toolchain-adapter-seam-and-the-vite-exit`.
62
+ */
63
+ export interface NativeDevApp {
64
+ fetch: (request: Request) => Response | Promise<Response>
65
+ readonly hono: Hono
66
+ dispatchToApp<D extends AnyMachineDef>(
67
+ machine: D,
68
+ event: EventOf<D>,
69
+ ): Promise<{ committed: boolean }>
70
+ listen: (port: number) => Promise<void>
71
+ close: () => Promise<void>
72
+ }
73
+
74
+ // The dev browser client (replaces Vite's `/@vite/client`): reconnecting-EventSource
75
+ // that reloads on a successful rebuild and renders a full-screen overlay with the
76
+ // message on a build failure, so a compile error is visible instead of a frozen page.
77
+ const DEV_CLIENT_SCRIPT = `(()=>{let o;const show=(m)=>{if(o)o.remove();o=document.createElement('div');o.style.cssText='position:fixed;inset:0;z-index:2147483647;margin:0;padding:24px;overflow:auto;background:#1a1015;color:#f8d7da;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap';o.textContent='stator \\u2014 build failed\\n\\n'+m;document.body.appendChild(o)};const es=new EventSource('/__stator_dev');es.onmessage=(e)=>{let m;try{m=JSON.parse(e.data)}catch{return}if(m.type==='reload')location.reload();else if(m.type==='error')show(m.message)}})()`
78
+
79
+ const SKIP_DIRS = new Set(['node_modules', 'dist', 'tests', 'test', '__tests__'])
80
+ const APP_EXT = /\.(stator|ts|tsx|mts|cts|js|mjs)$/
81
+ /** Root-relative path segments that are never app source (mirrors SKIP_DIRS). */
82
+ const PRUNE_DIR = /(^|\/)(node_modules|dist|tests|test|__tests__)(\/|$)/
83
+ /** Any dot-prefixed segment: VCS/config dirs and editor temp/swap files. */
84
+ const DOT_SEGMENT = /(^|\/)\.[^/]+/
85
+ const EMPTY_BUNDLE: IslandBundle = { islandUrls: {}, assets: [], modules: [] }
86
+ const norm = (p: string): string => p.replace(/\\/g, '/')
87
+
88
+ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDevApp> {
89
+ // Real paths throughout: Node's resolver realpaths module URLs, and the
90
+ // per-file version map must key on what the loader sees.
91
+ const real = (p: string): string => {
92
+ try {
93
+ return realpathSync(p)
94
+ } catch {
95
+ return p
96
+ }
97
+ }
98
+ const root = real(resolve(config.root))
99
+ loadDotenv(root)
100
+
101
+ const resolved = resolveAppConfig(config)
102
+ // One session store for the life of the dev process. Rebuilds swap the
103
+ // MachineStore AROUND it, so sessions survive an edit unless their machine's
104
+ // code hash changed (hydration policy). Creating the default store inside
105
+ // rebuildStore would silently reset every session on any machine-touching
106
+ // edit — the opposite of what the per-machine hash promises.
107
+ const sessionStore = resolved.session ?? new InMemoryStore()
108
+ const logLevel = process.env.LOG_LEVEL ?? resolved.logLevel ?? 'info'
109
+ setLogLevel(logLevel)
110
+ const inspectorOn = resolved.inspector ?? true
111
+ const machinesDir = real(resolve(config.machinesDir))
112
+ const routesDir = real(resolve(config.routesDir))
113
+ const staticDir = config.staticDir ? real(resolve(config.staticDir)) : resolve(root, 'static')
114
+ // One build-id per dev process, as the Vite dev server did — rebuilds reload
115
+ // through the dev channel, a restart is a new id for the SSE reconnect handshake.
116
+ const buildId = randomUUID()
117
+
118
+ // Loaders, BEFORE the first app import. The CLI's TS loader first — a no-op
119
+ // extra link when the CLI already registered it, required when the dev server
120
+ // is started programmatically, because the dev loader (a hooks-thread module)
121
+ // imports the TypeScript compiler source. Then the dev loader: `.stator`
122
+ // compile-on-load plus per-file `?v=` stamping scoped to the app root, fed
123
+ // over a MessagePort.
124
+ const { port1, port2 } = new MessageChannel()
125
+ register('../cli/loader.js', import.meta.url)
126
+ register('./dev-loader.mjs', import.meta.url, {
127
+ data: { appDir: root, port: port2 },
128
+ transferList: [port2],
129
+ })
130
+
131
+ // ── Module graph + per-file versions (importer-only invalidation) ──────────
132
+ // An edit re-evaluates the changed file and its transitive importers — nothing
133
+ // else. The graph is a static regex read of relative imports (no evaluation),
134
+ // rebuilt on structural changes and patched per edited file otherwise; the
135
+ // loader stamps each app module with its version at resolve time.
136
+ const versions = new Map<string, number>()
137
+ const forward = new Map<string, Set<string>>()
138
+ const importers = new Map<string, Set<string>>()
139
+ let seq = 0
140
+ const acks = new Map<number, () => void>()
141
+ port1.on('message', (m: { type?: string; id?: number }) => {
142
+ if (m?.type !== 'ack' || m.id === undefined) return
143
+ acks.get(m.id)?.()
144
+ acks.delete(m.id)
145
+ if (acks.size === 0) port1.unref()
146
+ })
147
+ port1.unref()
148
+ // Bump versions and push them to the loader; resolves once the loader has
149
+ // them, so the next import sees the new stamps.
150
+ const bumpVersions = (files: Iterable<string>): Promise<void> => {
151
+ const entries: Array<[string, number]> = []
152
+ for (const f of files) {
153
+ const v = (versions.get(f) ?? 1) + 1
154
+ versions.set(f, v)
155
+ entries.push([f, v])
156
+ }
157
+ if (entries.length === 0) return Promise.resolve()
158
+ return new Promise((done) => {
159
+ const id = ++seq
160
+ acks.set(id, done)
161
+ port1.ref()
162
+ port1.postMessage({ type: 'versions', id, entries })
163
+ })
164
+ }
165
+ const bust = (file: string): Promise<Record<string, unknown>> =>
166
+ import(`${pathToFileURL(file).href}?v=${versions.get(file) ?? 1}`)
167
+
168
+ // ── Dev live-reload channel (replaces Vite's `/@vite/client` HMR socket) ────
169
+ const reloadClients = new Set<ReadableStreamDefaultController<Uint8Array>>()
170
+ const enc = new TextEncoder()
171
+ type DevMessage = { type: 'reload' } | { type: 'error'; message: string }
172
+ const broadcast = (msg: DevMessage): void => {
173
+ const frame = enc.encode(`data: ${JSON.stringify(msg)}\n\n`)
174
+ for (const c of reloadClients) {
175
+ try {
176
+ c.enqueue(frame)
177
+ } catch {
178
+ reloadClients.delete(c)
179
+ }
180
+ }
181
+ }
182
+ const devReloadResponse = (): Response =>
183
+ new Response(
184
+ new ReadableStream<Uint8Array>({
185
+ start(controller) {
186
+ controller.enqueue(enc.encode(': connected\n\n'))
187
+ reloadClients.add(controller)
188
+ },
189
+ // A closed stream's controller throws on the next enqueue and is pruned then.
190
+ }),
191
+ {
192
+ headers: {
193
+ 'Content-Type': 'text/event-stream',
194
+ 'Cache-Control': 'no-cache',
195
+ Connection: 'keep-alive',
196
+ 'X-Accel-Buffering': 'no',
197
+ },
198
+ },
199
+ )
200
+
201
+ // ── Source scan: scoped CSS + island-ness of every `.stator` ───────────────
202
+ // The loader compiles each `.stator` into its server module on import; this
203
+ // main-thread pass compiles for the two things the <head> needs up front —
204
+ // scoped CSS and which files are islands. Same `sourceId`, so the scope
205
+ // hashes agree with the markup the loader emits.
206
+ interface StatorInfo {
207
+ css: string
208
+ isClient: boolean
209
+ }
210
+ const infos = new Map<string, StatorInfo>() // abs `.stator` → info
211
+ const skipDir = (name: string): boolean => name.startsWith('.') || SKIP_DIRS.has(name)
212
+ // Prune rules apply to the path RELATIVE to the app root — the root's own
213
+ // ancestors may legitimately contain a `tests/` or a dot-dir.
214
+ const relOf = (abs: string): string => norm(relative(root, abs))
215
+ const pruned = (rel: string): boolean =>
216
+ rel.startsWith('..') || PRUNE_DIR.test(rel) || DOT_SEGMENT.test(rel)
217
+ const isAppFile = (abs: string): boolean => APP_EXT.test(abs) && !pruned(relOf(abs))
218
+
219
+ const setEdges = (file: string, children: Set<string>): void => {
220
+ for (const old of forward.get(file) ?? []) importers.get(old)?.delete(file)
221
+ forward.set(file, children)
222
+ for (const child of children) {
223
+ const set = importers.get(child) ?? new Set<string>()
224
+ set.add(file)
225
+ importers.set(child, set)
226
+ }
227
+ }
228
+ const updateEdges = async (files: string[]): Promise<void> => {
229
+ for (const f of files) setEdges(f, new Set((await localImports(f, root)).filter(isAppFile)))
230
+ }
231
+ const buildGraph = async (): Promise<void> => {
232
+ forward.clear()
233
+ importers.clear()
234
+ await updateEdges(await walkFiles(root, (f) => APP_EXT.test(f), skipDir))
235
+ }
236
+ /** The changed files plus everything that (transitively) imports them. */
237
+ const affectedBy = (files: string[]): Set<string> => {
238
+ const out = new Set<string>()
239
+ const stack = [...files]
240
+ while (stack.length) {
241
+ const f = stack.pop()!
242
+ if (out.has(f)) continue
243
+ out.add(f)
244
+ for (const parent of importers.get(f) ?? []) stack.push(parent)
245
+ }
246
+ return out
247
+ }
248
+ const compileInfo = async (file: string): Promise<StatorInfo> => {
249
+ const source = await readFile(file, 'utf8')
250
+ const { id, kind } = sourceId(root, file)
251
+ const r = compile(source, { id, kind, resolveRegions: regionResolverFor(file, source) })
252
+ return { css: r.css ?? '', isClient: r.isClient }
253
+ }
254
+ // Tolerant at boot: a broken file is logged and skipped here (it surfaces
255
+ // with its code frame when something imports it), so one bad template
256
+ // doesn't keep the whole dev server from starting.
257
+ const scanAll = async (): Promise<void> => {
258
+ infos.clear()
259
+ for (const file of await walkFiles(root, (f) => f.endsWith('.stator'), skipDir)) {
260
+ try {
261
+ infos.set(file, await compileInfo(file))
262
+ } catch (err) {
263
+ logger.error({ file: sourceId(root, file).id, err: describe(err) }, 'compile failed')
264
+ infos.set(file, { css: '', isClient: false })
265
+ }
266
+ }
267
+ }
268
+ let css = ''
269
+ const regenCss = (): void => {
270
+ css = ''
271
+ for (const [file, info] of infos)
272
+ if (info.css) css += `/* ${sourceId(root, file).id} */\n${info.css}\n`
273
+ }
274
+
275
+ // ── Islands: bundled through the seam, held in memory ──────────────────────
276
+ let islands: IslandBundle = EMPTY_BUNDLE
277
+ const assetBodies = new Map<string, string | Uint8Array>() // URL path → body
278
+ let islandGraph = new Set<string>() // every source module in the last bundle
279
+ let routeIslands = new Map<string, string[]>() // abs route file → island rels
280
+ const islandFiles = (): string[] => [...infos].filter(([, i]) => i.isClient).map(([f]) => f)
281
+ const rebundleIslands = async (): Promise<void> => {
282
+ const entries = islandFiles().map((file) => ({ rel: sourceId(root, file).id, file }))
283
+ // Inline sourcemaps in dev: browser devtools show island SOURCE, and the
284
+ // in-memory bundle stays self-contained (no `.map` files to serve).
285
+ islands = entries.length
286
+ ? await bundleIslands({ root, machinesDir, entries, sourcemap: true })
287
+ : EMPTY_BUNDLE
288
+ assetBodies.clear()
289
+ for (const a of islands.assets) assetBodies.set(`/static/assets/${a.fileName}`, a.source)
290
+ islandGraph = new Set(islands.modules)
291
+ }
292
+ const remapRoutes = async (): Promise<void> => {
293
+ const shells = new Map(islandFiles().map((file) => [file, sourceId(root, file).id]))
294
+ routeIslands = await routeIslandMap({ routesDir, baseDir: root, shells })
295
+ }
296
+
297
+ // Production head shape (CSS link + per-route island scripts), then the dev
298
+ // inspector and the reload client layered on top.
299
+ const headExtras = (routeFile: string): string => {
300
+ const head: string[] = []
301
+ if (css) head.push('<link rel="stylesheet" href="/static/components.css">')
302
+ for (const rel of routeIslands.get(resolve(routeFile)) ?? []) {
303
+ const url = islands.islandUrls[rel]
304
+ if (url) head.push(`<script type="module" src="${url}"></script>`)
305
+ }
306
+ if (inspectorOn) head.push('<script src="/@stator/inspector.js" defer></script>')
307
+ head.push(`<script>${DEV_CLIENT_SCRIPT}</script>`)
308
+ return head.join('\n')
309
+ }
310
+
311
+ // Dev-owned URLs answered ahead of the app: the reload channel and the
312
+ // in-memory build outputs. Everything else — including the app's real
313
+ // `static/` dir, served in place — falls through to Hono.
314
+ const noCache = (type: string): HeadersInit => ({
315
+ 'Content-Type': type,
316
+ 'Cache-Control': 'no-cache',
317
+ })
318
+ const serveDev = (request: Request): Response | undefined => {
319
+ const { pathname } = new URL(request.url)
320
+ if (pathname === '/__stator_dev') return devReloadResponse()
321
+ if (pathname === '/static/components.css' && css)
322
+ return new Response(css, { headers: noCache('text/css; charset=utf-8') })
323
+ const body = assetBodies.get(pathname)
324
+ if (body !== undefined)
325
+ return new Response(typeof body === 'string' ? body : new Uint8Array(body), {
326
+ headers: noCache(contentTypeFor(pathname)),
327
+ })
328
+ return undefined
329
+ }
330
+
331
+ // ── App graph (rebuilt on edits) ────────────────────────────────────────────
332
+ let store: MachineStore
333
+ let defs: AnyMachineDef[] = []
334
+ let routes: Awaited<ReturnType<typeof discoverRoutes>> = []
335
+ let machineCount = 0
336
+ let app: Hono
337
+ const handle = (request: Request): Response | Promise<Response> =>
338
+ serveDev(request) ?? app.fetch(request)
339
+
340
+ // Which machines' code hashes changed across a store rebuild — the sessions
341
+ // of exactly those start fresh on their next request (hydration policy).
342
+ const rebuildStore = async (): Promise<string[]> => {
343
+ const before = new Map(defs.map((d) => [d.name, codeHashOf(d)]))
344
+ defs = (await discoverMachines(machinesDir, bust)).defs
345
+ machineCount = defs.length
346
+ const changed = defs.filter((d) => before.has(d.name) && before.get(d.name) !== codeHashOf(d))
347
+ store = new MachineStore(defs, sessionStore, {
348
+ sessionTtlSeconds: resolved.sessionTtlSeconds,
349
+ appStore: resolved.app,
350
+ })
351
+ wireAppEffects(store)
352
+ await store.bootAppMachines()
353
+ // Dev-only lint: a session machine whose `after` timer drives a loop with
354
+ // a data-loading entry effect is server-side polling that runs for
355
+ // sessions nobody is watching. Warn with the steer, don't block.
356
+ for (const f of findPollLoops(defs)) {
357
+ console.warn(
358
+ `stator: session machine "${f.machine}" self-reschedules through \`after\` ` +
359
+ `(${f.cycle.join(' → ')} via ${f.event}) with a data-loading entry effect on the loop. ` +
360
+ `This polls upstream for sessions nobody is watching. Prefer a client-owned clock ` +
361
+ `with a staleness guard — see the effects guide, "Who owns the clock".`,
362
+ )
363
+ }
364
+ return changed.map((d) => d.name)
365
+ }
366
+
367
+ // An edit touches a machine when the file is in that machine's hashed
368
+ // closure — the machine file itself or anything it imports. (A file under
369
+ // machines/ that no machine reaches is still a structural change to the set.)
370
+ const touchesMachine = (files: string[]): boolean =>
371
+ files.some(
372
+ (f) => f.startsWith(machinesDir + sep) || defs.some((d) => codeInputsOf(d).includes(f)),
373
+ )
374
+
375
+ const rebuildServer = async (): Promise<void> => {
376
+ routes = await discoverRoutes(routesDir, bust)
377
+ const middleware = await discoverMiddleware(resolve(root, 'middleware.ts'), bust)
378
+ await remapRoutes()
379
+ app = await buildHonoApp({
380
+ routes,
381
+ store,
382
+ staticDir,
383
+ headExtras,
384
+ inspector: inspectorOn,
385
+ inspect: true,
386
+ trustedOrigins: resolved.trustedOrigins,
387
+ sameSite: resolved.sameSite,
388
+ origin: resolved.origin,
389
+ secret: resolved.secret,
390
+ buildId,
391
+ cors: resolved.cors,
392
+ middleware,
393
+ })
394
+ }
395
+
396
+ await scanAll()
397
+ await buildGraph()
398
+ regenCss()
399
+ await rebundleIslands()
400
+ await rebuildStore()
401
+ await rebuildServer()
402
+
403
+ // boot.ts once per process (a long-lived source shouldn't restart on edits).
404
+ const bootDef = await discoverBoot(resolve(root, 'boot.ts'), bust)
405
+
406
+ // ── Watch → recompile → reload ──────────────────────────────────────────────
407
+ // Recompile just what changed, then bump the changed files + their importers
408
+ // so exactly that subtree re-evaluates. Islands rebundle only when an edit
409
+ // touches a module the last bundle contained (the seam reports that set), a
410
+ // file became an island, or the file set changed; machine edits rebuild the
411
+ // store; everything rebuilds the server graph.
412
+ const doRebuild = async (files: string[], structural: boolean): Promise<void> => {
413
+ const t0 = performance.now()
414
+ let rebundled = false
415
+ let storeRebuilt = false
416
+ let machinesChanged: string[] = []
417
+ try {
418
+ if (structural) {
419
+ await scanAll()
420
+ await buildGraph()
421
+ } else {
422
+ for (const f of files) if (f.endsWith('.stator')) infos.set(f, await compileInfo(f))
423
+ await updateEdges(files)
424
+ }
425
+ const affected = affectedBy(files)
426
+ await bumpVersions(affected)
427
+ regenCss()
428
+ const needsBundle =
429
+ structural || files.some((f) => islandGraph.has(norm(f)) || infos.get(f)?.isClient)
430
+ if (needsBundle) {
431
+ await rebundleIslands()
432
+ rebundled = true
433
+ }
434
+ if (touchesMachine(files)) {
435
+ machinesChanged = await rebuildStore()
436
+ storeRebuilt = true
437
+ }
438
+ await rebuildServer()
439
+ broadcast({ type: 'reload' })
440
+ logger.info(
441
+ {
442
+ ms: Math.round(performance.now() - t0),
443
+ modules: affected.size,
444
+ islands: rebundled,
445
+ store: storeRebuilt,
446
+ routes: routes.length,
447
+ files: files.map((f) => sourceId(root, f).id),
448
+ },
449
+ 'reloaded',
450
+ )
451
+ if (storeRebuilt) {
452
+ logger.info(
453
+ { machines: machinesChanged },
454
+ machinesChanged.length
455
+ ? 'machine code changed — their sessions start fresh on the next request'
456
+ : 'machine files changed but no machine code changed — sessions carry over',
457
+ )
458
+ }
459
+ } catch (err) {
460
+ // Keep serving the last good graph, but surface the failure (with its code
461
+ // frame for a compile error) in the browser instead of a frozen page.
462
+ const message = describe(err)
463
+ logger.error(
464
+ { err: message, structural, files: files.map((f) => sourceId(root, f).id) },
465
+ 'reload failed',
466
+ )
467
+ broadcast({ type: 'error', message })
468
+ }
469
+ }
470
+
471
+ const changed = new Set<string>()
472
+ let structural = false
473
+ let timer: ReturnType<typeof setTimeout> | undefined
474
+ let reloadChain: Promise<void> = Promise.resolve()
475
+ // chokidar gives reliable add/change/unlink events across macOS/Linux/Windows,
476
+ // and awaitWriteFinish coalesces an editor's atomic save (temp write + rename)
477
+ // into one settled event — the cross-platform robustness `fs.watch` lacks.
478
+ const watcher = chokidar.watch(root, {
479
+ ignoreInitial: true,
480
+ // Prune deps/build-output/VCS/tests and dotfile+editor-temp artifacts at the
481
+ // directory level, so the watch never descends node_modules.
482
+ ignored: (p: string) => {
483
+ const rel = relOf(resolve(p))
484
+ return rel !== '' && pruned(rel)
485
+ },
486
+ // 20 ms is enough to see a rename-style atomic save settle; a torn read
487
+ // only costs one overlay, which the next settled write clears.
488
+ awaitWriteFinish: { stabilityThreshold: 20, pollInterval: 10 },
489
+ })
490
+ const onEvent = (event: 'add' | 'change' | 'unlink', p: string): void => {
491
+ const abs = resolve(p)
492
+ if (!isAppFile(abs)) return
493
+ changed.add(abs)
494
+ // add/unlink change the file SET → rescan; a plain edit stays incremental.
495
+ if (event === 'add' || event === 'unlink') structural = true
496
+ // Small debounce to coalesce a multi-file save (awaitWriteFinish already
497
+ // settled each file, so this only groups a batch).
498
+ clearTimeout(timer)
499
+ timer = setTimeout(() => {
500
+ const files = [...changed]
501
+ const wasStructural = structural
502
+ changed.clear()
503
+ structural = false
504
+ reloadChain = reloadChain.then(() => doRebuild(files, wasStructural))
505
+ }, 20)
506
+ }
507
+ watcher.on('add', (p) => onEvent('add', p))
508
+ watcher.on('change', (p) => onEvent('change', p))
509
+ watcher.on('unlink', (p) => onEvent('unlink', p))
510
+
511
+ return {
512
+ fetch: handle,
513
+ get hono() {
514
+ return app
515
+ },
516
+ dispatchToApp: (machine, event) => dispatchToApp(store, machine, event),
517
+ listen(port: number): Promise<void> {
518
+ const listener = getRequestListener(handle)
519
+ const server = createHttpServer(listener)
520
+ return findFreePort(port).then(
521
+ (freePort) =>
522
+ new Promise((resolveFn) => {
523
+ server.listen(freePort, async () => {
524
+ printDevBanner({
525
+ port: freePort,
526
+ requestedPort: port,
527
+ machines: machineCount,
528
+ routes: routes.length,
529
+ inspector: inspectorOn,
530
+ })
531
+ const teardown: BootTeardown | undefined = await runBoot(bootDef, {
532
+ dispatchToApp: (machine, event) => dispatchToApp(store, machine, event),
533
+ config: {
534
+ origin: resolved.origin,
535
+ trustedOrigins: resolved.trustedOrigins ?? [],
536
+ sameSite: resolved.sameSite ?? 'Lax',
537
+ cors: resolved.cors,
538
+ },
539
+ })
540
+ installGracefulShutdown(async () => {
541
+ if (teardown) await teardown()
542
+ await watcher.close()
543
+ await new Promise<void>((done) => server.close(() => done()))
544
+ })
545
+ resolveFn()
546
+ })
547
+ }),
548
+ )
549
+ },
550
+ close: async () => {
551
+ await watcher.close()
552
+ port1.close()
553
+ },
554
+ }
555
+ }
556
+
557
+ /** Error text for logs and the overlay — a located compile error renders with
558
+ * its file:line:column and code frame. */
559
+ function describe(err: unknown): string {
560
+ if (err instanceof CompileError) return formatCompileError(err)
561
+ return err instanceof Error ? err.message : String(err)
562
+ }