@statorjs/stator 2.5.0 → 2.6.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.
- package/README.md +1 -1
- package/package.json +5 -5
- package/src/build/build.ts +93 -115
- package/src/build/head.ts +11 -4
- package/src/build/index.ts +9 -0
- package/src/build/islands.ts +228 -0
- package/src/build/source-id.ts +15 -0
- package/src/cli/commands/build.ts +11 -1
- package/src/cli/commands/dev.ts +7 -3
- package/src/cli/commands/start.ts +2 -1
- package/src/cli/run.ts +1 -1
- package/src/cli/stator.js +9 -0
- package/src/compiler/diagnostics.ts +10 -0
- package/src/compiler/index.ts +2 -1
- package/src/compiler/regions.ts +30 -0
- package/src/engine/actor.ts +10 -3
- package/src/engine/index.ts +1 -0
- package/src/engine/types.ts +24 -1
- package/src/server/create-app.ts +7 -1
- package/src/server/dev-loader.mjs +124 -0
- package/src/server/dev-native.ts +561 -0
- package/src/server/dev-vite.ts +361 -0
- package/src/server/dev.ts +44 -313
- package/src/server/discovery.ts +45 -1
- package/src/server/effects.ts +10 -1
- package/src/server/http.ts +7 -1
- package/src/server/index.ts +1 -0
- package/src/server/machine-hash.ts +195 -0
- package/src/server/machine-store.ts +9 -2
- package/src/server/session-runtime.ts +9 -4
- package/src/server/snapshot-policy.ts +110 -0
- package/src/vite/index.ts +10 -4
- package/src/vite/plugin.ts +12 -32
- package/src/vite/stub.ts +4 -1
|
@@ -0,0 +1,561 @@
|
|
|
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
|
+
trustedOrigins: resolved.trustedOrigins,
|
|
386
|
+
sameSite: resolved.sameSite,
|
|
387
|
+
origin: resolved.origin,
|
|
388
|
+
secret: resolved.secret,
|
|
389
|
+
buildId,
|
|
390
|
+
cors: resolved.cors,
|
|
391
|
+
middleware,
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
await scanAll()
|
|
396
|
+
await buildGraph()
|
|
397
|
+
regenCss()
|
|
398
|
+
await rebundleIslands()
|
|
399
|
+
await rebuildStore()
|
|
400
|
+
await rebuildServer()
|
|
401
|
+
|
|
402
|
+
// boot.ts once per process (a long-lived source shouldn't restart on edits).
|
|
403
|
+
const bootDef = await discoverBoot(resolve(root, 'boot.ts'), bust)
|
|
404
|
+
|
|
405
|
+
// ── Watch → recompile → reload ──────────────────────────────────────────────
|
|
406
|
+
// Recompile just what changed, then bump the changed files + their importers
|
|
407
|
+
// so exactly that subtree re-evaluates. Islands rebundle only when an edit
|
|
408
|
+
// touches a module the last bundle contained (the seam reports that set), a
|
|
409
|
+
// file became an island, or the file set changed; machine edits rebuild the
|
|
410
|
+
// store; everything rebuilds the server graph.
|
|
411
|
+
const doRebuild = async (files: string[], structural: boolean): Promise<void> => {
|
|
412
|
+
const t0 = performance.now()
|
|
413
|
+
let rebundled = false
|
|
414
|
+
let storeRebuilt = false
|
|
415
|
+
let machinesChanged: string[] = []
|
|
416
|
+
try {
|
|
417
|
+
if (structural) {
|
|
418
|
+
await scanAll()
|
|
419
|
+
await buildGraph()
|
|
420
|
+
} else {
|
|
421
|
+
for (const f of files) if (f.endsWith('.stator')) infos.set(f, await compileInfo(f))
|
|
422
|
+
await updateEdges(files)
|
|
423
|
+
}
|
|
424
|
+
const affected = affectedBy(files)
|
|
425
|
+
await bumpVersions(affected)
|
|
426
|
+
regenCss()
|
|
427
|
+
const needsBundle =
|
|
428
|
+
structural || files.some((f) => islandGraph.has(norm(f)) || infos.get(f)?.isClient)
|
|
429
|
+
if (needsBundle) {
|
|
430
|
+
await rebundleIslands()
|
|
431
|
+
rebundled = true
|
|
432
|
+
}
|
|
433
|
+
if (touchesMachine(files)) {
|
|
434
|
+
machinesChanged = await rebuildStore()
|
|
435
|
+
storeRebuilt = true
|
|
436
|
+
}
|
|
437
|
+
await rebuildServer()
|
|
438
|
+
broadcast({ type: 'reload' })
|
|
439
|
+
logger.info(
|
|
440
|
+
{
|
|
441
|
+
ms: Math.round(performance.now() - t0),
|
|
442
|
+
modules: affected.size,
|
|
443
|
+
islands: rebundled,
|
|
444
|
+
store: storeRebuilt,
|
|
445
|
+
routes: routes.length,
|
|
446
|
+
files: files.map((f) => sourceId(root, f).id),
|
|
447
|
+
},
|
|
448
|
+
'reloaded',
|
|
449
|
+
)
|
|
450
|
+
if (storeRebuilt) {
|
|
451
|
+
logger.info(
|
|
452
|
+
{ machines: machinesChanged },
|
|
453
|
+
machinesChanged.length
|
|
454
|
+
? 'machine code changed — their sessions start fresh on the next request'
|
|
455
|
+
: 'machine files changed but no machine code changed — sessions carry over',
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
} catch (err) {
|
|
459
|
+
// Keep serving the last good graph, but surface the failure (with its code
|
|
460
|
+
// frame for a compile error) in the browser instead of a frozen page.
|
|
461
|
+
const message = describe(err)
|
|
462
|
+
logger.error(
|
|
463
|
+
{ err: message, structural, files: files.map((f) => sourceId(root, f).id) },
|
|
464
|
+
'reload failed',
|
|
465
|
+
)
|
|
466
|
+
broadcast({ type: 'error', message })
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const changed = new Set<string>()
|
|
471
|
+
let structural = false
|
|
472
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
473
|
+
let reloadChain: Promise<void> = Promise.resolve()
|
|
474
|
+
// chokidar gives reliable add/change/unlink events across macOS/Linux/Windows,
|
|
475
|
+
// and awaitWriteFinish coalesces an editor's atomic save (temp write + rename)
|
|
476
|
+
// into one settled event — the cross-platform robustness `fs.watch` lacks.
|
|
477
|
+
const watcher = chokidar.watch(root, {
|
|
478
|
+
ignoreInitial: true,
|
|
479
|
+
// Prune deps/build-output/VCS/tests and dotfile+editor-temp artifacts at the
|
|
480
|
+
// directory level, so the watch never descends node_modules.
|
|
481
|
+
ignored: (p: string) => {
|
|
482
|
+
const rel = relOf(resolve(p))
|
|
483
|
+
return rel !== '' && pruned(rel)
|
|
484
|
+
},
|
|
485
|
+
// 20 ms is enough to see a rename-style atomic save settle; a torn read
|
|
486
|
+
// only costs one overlay, which the next settled write clears.
|
|
487
|
+
awaitWriteFinish: { stabilityThreshold: 20, pollInterval: 10 },
|
|
488
|
+
})
|
|
489
|
+
const onEvent = (event: 'add' | 'change' | 'unlink', p: string): void => {
|
|
490
|
+
const abs = resolve(p)
|
|
491
|
+
if (!isAppFile(abs)) return
|
|
492
|
+
changed.add(abs)
|
|
493
|
+
// add/unlink change the file SET → rescan; a plain edit stays incremental.
|
|
494
|
+
if (event === 'add' || event === 'unlink') structural = true
|
|
495
|
+
// Small debounce to coalesce a multi-file save (awaitWriteFinish already
|
|
496
|
+
// settled each file, so this only groups a batch).
|
|
497
|
+
clearTimeout(timer)
|
|
498
|
+
timer = setTimeout(() => {
|
|
499
|
+
const files = [...changed]
|
|
500
|
+
const wasStructural = structural
|
|
501
|
+
changed.clear()
|
|
502
|
+
structural = false
|
|
503
|
+
reloadChain = reloadChain.then(() => doRebuild(files, wasStructural))
|
|
504
|
+
}, 20)
|
|
505
|
+
}
|
|
506
|
+
watcher.on('add', (p) => onEvent('add', p))
|
|
507
|
+
watcher.on('change', (p) => onEvent('change', p))
|
|
508
|
+
watcher.on('unlink', (p) => onEvent('unlink', p))
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
fetch: handle,
|
|
512
|
+
get hono() {
|
|
513
|
+
return app
|
|
514
|
+
},
|
|
515
|
+
dispatchToApp: (machine, event) => dispatchToApp(store, machine, event),
|
|
516
|
+
listen(port: number): Promise<void> {
|
|
517
|
+
const listener = getRequestListener(handle)
|
|
518
|
+
const server = createHttpServer(listener)
|
|
519
|
+
return findFreePort(port).then(
|
|
520
|
+
(freePort) =>
|
|
521
|
+
new Promise((resolveFn) => {
|
|
522
|
+
server.listen(freePort, async () => {
|
|
523
|
+
printDevBanner({
|
|
524
|
+
port: freePort,
|
|
525
|
+
requestedPort: port,
|
|
526
|
+
machines: machineCount,
|
|
527
|
+
routes: routes.length,
|
|
528
|
+
inspector: inspectorOn,
|
|
529
|
+
})
|
|
530
|
+
const teardown: BootTeardown | undefined = await runBoot(bootDef, {
|
|
531
|
+
dispatchToApp: (machine, event) => dispatchToApp(store, machine, event),
|
|
532
|
+
config: {
|
|
533
|
+
origin: resolved.origin,
|
|
534
|
+
trustedOrigins: resolved.trustedOrigins ?? [],
|
|
535
|
+
sameSite: resolved.sameSite ?? 'Lax',
|
|
536
|
+
cors: resolved.cors,
|
|
537
|
+
},
|
|
538
|
+
})
|
|
539
|
+
installGracefulShutdown(async () => {
|
|
540
|
+
if (teardown) await teardown()
|
|
541
|
+
await watcher.close()
|
|
542
|
+
await new Promise<void>((done) => server.close(() => done()))
|
|
543
|
+
})
|
|
544
|
+
resolveFn()
|
|
545
|
+
})
|
|
546
|
+
}),
|
|
547
|
+
)
|
|
548
|
+
},
|
|
549
|
+
close: async () => {
|
|
550
|
+
await watcher.close()
|
|
551
|
+
port1.close()
|
|
552
|
+
},
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Error text for logs and the overlay — a located compile error renders with
|
|
557
|
+
* its file:line:column and code frame. */
|
|
558
|
+
function describe(err: unknown): string {
|
|
559
|
+
if (err instanceof CompileError) return formatCompileError(err)
|
|
560
|
+
return err instanceof Error ? err.message : String(err)
|
|
561
|
+
}
|