@statorjs/stator 2.4.0 → 2.5.1

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 CHANGED
@@ -1,15 +1,8 @@
1
1
  # @statorjs/stator
2
2
 
3
- A server-canonical web framework: business logic lives in composable state
4
- machines that have no awareness of the UI, and the UI is a thin renderer
5
- binding machine outputs to DOM positions. Templates are `.stator` single-file
6
- components; interactions POST typed events; the server diffs affected bindings
7
- into small JSON patches. Client islands run the same machine engine in the
8
- browser as custom elements.
3
+ A server-canonical web framework: business logic lives in composable state machines that have no awareness of the UI, and the UI is a thin renderer binding machine outputs to DOM positions. Templates are `.stator` single-file components; interactions POST typed events; the server diffs affected bindings into small JSON patches. Client islands run the same machine engine in the browser as custom elements.
9
4
 
10
- Full documentation — tutorial, concepts, guides — lives in the repo's
11
- [`apps/docs`](https://github.com/statorjs/stator/tree/main/apps/docs) site;
12
- the repo [README](https://github.com/statorjs/stator#readme) has the tour.
5
+ Full documentation — tutorial, concepts, guides — lives in the repo's [`apps/docs`](https://github.com/statorjs/stator/tree/main/apps/docs) site; the repo [README](https://github.com/statorjs/stator#readme) has the tour.
13
6
 
14
7
  ## Install
15
8
 
@@ -36,8 +29,7 @@ pnpm add @statorjs/stator hono
36
29
  | `@statorjs/stator/build` | `buildApp`, `loadProductionHead`, `syncTypes` |
37
30
  | `@statorjs/stator/components` | built-ins (`<JsonLd>`) |
38
31
 
39
- `compiler` and `vite` subpaths exist but are internal — their shape may change
40
- in minor releases.
32
+ `compiler` and `vite` subpaths exist but are internal — their shape may change in minor releases.
41
33
 
42
34
  ## Minimal app
43
35
 
@@ -96,8 +88,7 @@ const [counter] = Stator.reads([Counter])
96
88
  </html>
97
89
  ```
98
90
 
99
- Run with `tsx server.ts`. See the repo's `examples/desksmith` for the full wiring
100
- (type sync, production build, deploy).
91
+ Run with `tsx server.ts`. See the repo's `examples/desksmith` for the full wiring (type sync, production build, deploy).
101
92
 
102
93
  ## License
103
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source (Vite/tsx-native by design).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -95,7 +95,7 @@
95
95
  "pino-pretty": "^13.1.3",
96
96
  "vite": "^6.0.0",
97
97
  "vitest": "^2.1.0",
98
- "@statorjs/stator": "2.4.0"
98
+ "@statorjs/stator": "2.5.1"
99
99
  },
100
100
  "scripts": {
101
101
  "typecheck": "tsc --noEmit",
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
2
3
  import { join, relative, resolve, sep } from 'node:path'
3
4
  import { compile } from '../compiler/index.ts'
@@ -47,8 +48,11 @@ export interface BuildResult {
47
48
  islands: number
48
49
  }
49
50
 
50
- /** Shape of `dist/stator-manifest.json` (only written when islands exist). */
51
+ /** Shape of `dist/stator-manifest.json` (always written — carries `buildId`). */
51
52
  export interface StatorManifest {
53
+ /** Per-build identifier — `stator start` serves it into live pages for the
54
+ * reload handshake (a client on an older build reloads on reconnect). */
55
+ buildId: string
52
56
  /** Island component (dist-relative `.stator` path) → its script URL. */
53
57
  islands: Record<string, string>
54
58
  /** Route file (dist-relative) → script URLs for every island it reaches. */
@@ -86,6 +90,9 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
86
90
  // (raw TS, run under tsx/native in prod like the rest of the server).
87
91
  const middlewareSrc = join(root, 'middleware.ts')
88
92
  if (await exists(middlewareSrc)) await cp(middlewareSrc, join(outDir, 'middleware.ts'))
93
+ // Same for a root-level boot.ts (the once-at-startup hook).
94
+ const bootSrc = join(root, 'boot.ts')
95
+ if (await exists(bootSrc)) await cp(bootSrc, join(outDir, 'boot.ts'))
89
96
 
90
97
  // Compile every .stator into a sibling .stator.ts; collect CSS and islands.
91
98
  const statorFiles = await walk(outDir, (f) => f.endsWith('.stator'))
@@ -120,10 +127,13 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
120
127
  await writeFile(join(outDir, 'static', 'components.css'), css)
121
128
  }
122
129
 
123
- if (islands.length > 0) {
124
- const manifest = await buildClientAssets(outDir, islands)
125
- await writeFile(join(outDir, 'stator-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
126
- }
130
+ // Always write the manifest — it carries the build-id even for an app with no
131
+ // islands (a live route without islands still needs the reload handshake).
132
+ const manifest: StatorManifest =
133
+ islands.length > 0
134
+ ? { buildId: randomUUID(), ...(await buildClientAssets(outDir, islands)) }
135
+ : { buildId: randomUUID(), islands: {}, routes: {} }
136
+ await writeFile(join(outDir, 'stator-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
127
137
 
128
138
  return { outDir, compiled: statorFiles.length, hasCss: Boolean(css), islands: islands.length }
129
139
  }
@@ -135,7 +145,7 @@ export async function buildApp(config: BuildConfig): Promise<BuildResult> {
135
145
  async function buildClientAssets(
136
146
  outDir: string,
137
147
  islands: Array<{ rel: string; entry: string }>,
138
- ): Promise<StatorManifest> {
148
+ ): Promise<Omit<StatorManifest, 'buildId'>> {
139
149
  const [{ build: viteBuild }, { machineStub }] = await Promise.all([
140
150
  import('vite'),
141
151
  import('../vite/stub.ts'),
package/src/build/head.ts CHANGED
@@ -7,12 +7,15 @@ import type { StatorManifest } from './build.ts'
7
7
  * build produced one, and injects each route's island `<script type="module">`
8
8
  * tags from `stator-manifest.json`. Pass the result to `createApp`:
9
9
  *
10
- * const app = await createApp({ ..., headExtras: await loadProductionHead(dist) })
10
+ * const { headExtras, buildId } = await loadProductionHead(dist)
11
+ * const app = await createApp({ ..., headExtras, buildId })
11
12
  *
12
- * Both artifacts are optional — a server-only app without styles gets an
13
- * empty hook.
13
+ * Everything is optional — a server-only app without styles gets an empty hook,
14
+ * and a build with no `buildId` in its manifest just skips the reload handshake.
14
15
  */
15
- export async function loadProductionHead(distDir: string): Promise<(filePath: string) => string> {
16
+ export async function loadProductionHead(
17
+ distDir: string,
18
+ ): Promise<{ headExtras: (filePath: string) => string; buildId?: string }> {
16
19
  const dist = resolve(distDir)
17
20
 
18
21
  let cssTag = ''
@@ -24,20 +27,23 @@ export async function loadProductionHead(distDir: string): Promise<(filePath: st
24
27
  }
25
28
 
26
29
  let routes: StatorManifest['routes'] = {}
30
+ let buildId: string | undefined
27
31
  try {
28
32
  const manifest = JSON.parse(
29
33
  await readFile(join(dist, 'stator-manifest.json'), 'utf8'),
30
34
  ) as StatorManifest
31
35
  routes = manifest.routes ?? {}
36
+ buildId = manifest.buildId
32
37
  } catch {
33
- // no islands
38
+ // no manifest
34
39
  }
35
40
 
36
- return (filePath: string): string => {
41
+ const headExtras = (filePath: string): string => {
37
42
  const rel = relative(dist, resolve(filePath)).replace(/\\/g, '/')
38
43
  const scripts = routes[rel] ?? []
39
44
  return [cssTag, ...scripts.map((url) => `<script type="module" src="${url}"></script>`)]
40
45
  .filter(Boolean)
41
46
  .join('\n')
42
47
  }
48
+ return { headExtras, buildId }
43
49
  }
@@ -16,6 +16,7 @@ export async function run(ctx: CliContext): Promise<void> {
16
16
  }
17
17
 
18
18
  const config = await loadConfig(ctx.root)
19
+ const { headExtras, buildId } = await loadProductionHead(dist)
19
20
  const app = await createApp({
20
21
  machinesDir: resolve(dist, 'machines'),
21
22
  routesDir: resolve(dist, 'routes'),
@@ -26,9 +27,12 @@ export async function run(ctx: CliContext): Promise<void> {
26
27
  trustedOrigins: config.trustedOrigins,
27
28
  origin: config.origin,
28
29
  host: config.host,
30
+ secret: config.secret,
29
31
  cors: config.cors,
30
- headExtras: await loadProductionHead(dist),
32
+ headExtras,
33
+ buildId,
31
34
  middlewareFile: resolve(dist, 'middleware.ts'),
35
+ bootFile: resolve(dist, 'boot.ts'),
32
36
  })
33
37
 
34
38
  await app.listen(resolvePort(ctx.portFlag, config.port))
@@ -72,7 +72,12 @@ function initLiveChannel(): { close(): void } | undefined {
72
72
  if (!meta) return
73
73
 
74
74
  const routeKey = `GET ${location.pathname}${location.search}`
75
- const url = `/__sse?route=${encodeURIComponent(routeKey)}&client=${encodeURIComponent(clientId)}`
75
+ // Echo the build this page was rendered against (if any) so the server can
76
+ // reload us when it's serving a newer build (dev restart / deploy). Baked
77
+ // into the URL at load time, so it rides EventSource's native reconnect too.
78
+ const build = document.querySelector('meta[name="stator-build"]')?.getAttribute('content')
79
+ const buildParam = build ? `&build=${encodeURIComponent(build)}` : ''
80
+ const url = `/__sse?route=${encodeURIComponent(routeKey)}&client=${encodeURIComponent(clientId)}${buildParam}`
76
81
 
77
82
  // Connection-state signal: `data-stator-connection` on the root element
78
83
  // (CSS hook for offline banners etc.) plus a `stator:connection-state`
@@ -0,0 +1,116 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { pathToFileURL } from 'node:url'
3
+ import type { AnyMachineDef, EventOf } from '../engine/index.ts'
4
+ import type { StatorConfigData } from './context.ts'
5
+ import type { ModuleLoader } from './discovery.ts'
6
+
7
+ // A global-registry symbol so the brand survives the dev dual-instance: the app's
8
+ // `boot.ts` is loaded through Vite while the framework checks it from the native
9
+ // module instance — `Symbol.for` resolves to the same symbol in both.
10
+ const BOOT_BRAND = Symbol.for('stator.boot.definition')
11
+
12
+ /**
13
+ * What a boot hook receives. Deliberately narrow: boot is a *source*, not a
14
+ * controller. It supplies events to the app-machine graph and reads config —
15
+ * policy (when/whether/how to act) belongs in the machine chart (a guard can
16
+ * debounce a `TICK`), not in this closure.
17
+ *
18
+ * Not exposed on purpose: `listen`/`fetch` (self-request foot-guns), the raw
19
+ * Hono app (that's `StatorApp.hono`'s break-glass), and the store (reaching
20
+ * around machines). Env is ambient — read `process.env` directly (`.env` is
21
+ * loaded before boot runs).
22
+ */
23
+ export interface BootContext {
24
+ /** Feed an event into an APP-lifecycle machine — the bound `dispatchToApp`.
25
+ * Boot has no session, so it operates on the app plane only. */
26
+ dispatchToApp<D extends AnyMachineDef>(
27
+ machine: D,
28
+ event: EventOf<D>,
29
+ ): Promise<{ committed: boolean }>
30
+ /** Curated framework config (the same view middleware sees via `stator(c)`):
31
+ * `origin`, `trustedOrigins`, `sameSite`, `cors`. No adapters or secret. */
32
+ readonly config: StatorConfigData
33
+ }
34
+
35
+ /** Cleanup run on graceful shutdown — clear a poll interval, unsubscribe a
36
+ * source. Return it from the boot function; the framework composes it into the
37
+ * SIGTERM/SIGINT handler. */
38
+ export type BootTeardown = () => void | Promise<void>
39
+
40
+ /** A boot function: runs once when the server starts serving. May be async, and
41
+ * may return a teardown. The `void` union is the React-`useEffect` shape — a
42
+ * hook that MAY return a cleanup — and is required so `defineBoot(() => {…})`
43
+ * with no return typechecks (`() => void` isn't assignable to `undefined`). */
44
+ // biome-ignore lint/suspicious/noConfusingVoidType: intentional useEffect-style optional-teardown return
45
+ export type BootFn = (ctx: BootContext) => void | BootTeardown | Promise<void | BootTeardown>
46
+
47
+ /** The value an app's `boot.ts` exports (default). Opaque — build it with
48
+ * `defineBoot`. */
49
+ export interface BootDefinition {
50
+ readonly [BOOT_BRAND]: true
51
+ readonly run: BootFn
52
+ }
53
+
54
+ /** Type guard for a discovered `boot.ts` default export. */
55
+ export function isBootDefinition(value: unknown): value is BootDefinition {
56
+ return typeof value === 'object' && value !== null && BOOT_BRAND in value
57
+ }
58
+
59
+ /**
60
+ * Define code that runs once when the server starts serving — the home for a
61
+ * long-lived inbound source: query config at boot, start a poll or subscription
62
+ * that dispatches events into the app-machine graph. Export the result as the
63
+ * default from `boot.ts` at the app root.
64
+ *
65
+ * Runs once per process (a dev restart re-runs it; an in-process rebuild does
66
+ * not). Return a teardown to clean up on shutdown.
67
+ *
68
+ * ```ts
69
+ * // boot.ts
70
+ * export default defineBoot(async ({ dispatchToApp }) => {
71
+ * const timer = setInterval(
72
+ * () => dispatchToApp(FleetMachine, { type: 'TICK', data: await poll() }),
73
+ * 30_000,
74
+ * )
75
+ * return () => clearInterval(timer)
76
+ * })
77
+ * ```
78
+ */
79
+ export function defineBoot(run: BootFn): BootDefinition {
80
+ return { [BOOT_BRAND]: true, run }
81
+ }
82
+
83
+ const nativeLoader: ModuleLoader = (file) => import(/* @vite-ignore */ pathToFileURL(file).href)
84
+
85
+ /**
86
+ * Load an app's `boot.ts` (if present) and validate its default export. Returns
87
+ * `undefined` when there's no file. `loader` is injected so dev goes through
88
+ * Vite; prod/native uses `import` — the same single toolchain touch-point as
89
+ * `discoverMiddleware`.
90
+ */
91
+ export async function discoverBoot(
92
+ file: string,
93
+ loader: ModuleLoader = nativeLoader,
94
+ ): Promise<BootDefinition | undefined> {
95
+ if (!existsSync(file)) return undefined
96
+ const mod = await loader(file)
97
+ const def = (mod as { default?: unknown }).default
98
+ if (!isBootDefinition(def)) {
99
+ throw new Error(`${file} must \`export default defineBoot((ctx) => { ... })\``)
100
+ }
101
+ return def
102
+ }
103
+
104
+ /**
105
+ * Run a discovered boot definition and return its teardown (if any). Called from
106
+ * `listen()` — once the server is up — so boot never fires during tests that use
107
+ * `app.fetch` without listening.
108
+ */
109
+ export async function runBoot(
110
+ def: BootDefinition | undefined,
111
+ ctx: BootContext,
112
+ ): Promise<BootTeardown | undefined> {
113
+ if (!def) return undefined
114
+ const result = await def.run(ctx)
115
+ return typeof result === 'function' ? result : undefined
116
+ }
@@ -7,7 +7,7 @@ import type { Store } from './store.ts'
7
7
  * The resolved config data `buildHonoApp` stashes on the context (early), for
8
8
  * the security defaults, `cors()`, and app middleware to read.
9
9
  */
10
- interface StatorConfigData {
10
+ export interface StatorConfigData {
11
11
  readonly origin?: string
12
12
  readonly trustedOrigins: readonly string[]
13
13
  readonly sameSite: 'Lax' | 'Strict'
@@ -6,6 +6,7 @@ import type { AnyMachineDef, EventOf } from '../engine/index.ts'
6
6
  import { dispatchToApp } from './app-dispatch.ts'
7
7
  import type { AppStore } from './app-store.ts'
8
8
  import { installGracefulShutdown, printStartupNotice } from './banner.ts'
9
+ import { type BootTeardown, discoverBoot, runBoot } from './boot.ts'
9
10
  import { type DeprecatedFlatConfig, resolveAppConfig } from './config-compat.ts'
10
11
  import { discoverMachines } from './discovery.ts'
11
12
  import { wireAppEffects } from './effects.ts'
@@ -77,6 +78,12 @@ export interface CreateAppConfig extends DeprecatedFlatConfig {
77
78
  /** Path to the app's `middleware.ts` (if any). Loaded and validated; its
78
79
  * default export must be `defineMiddleware`/`dangerouslyDefineMiddleware`. */
79
80
  middlewareFile?: string
81
+ /** Path to the app's `boot.ts` (if any). Its default export must be
82
+ * `defineBoot(...)`; it runs once when the app starts listening. */
83
+ bootFile?: string
84
+ /** Build identifier for the deploy-aware reload handshake. `stator start`
85
+ * passes the id baked into the build manifest; absent → no handshake. */
86
+ buildId?: string
80
87
  }
81
88
 
82
89
  export interface StatorApp {
@@ -128,6 +135,7 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
128
135
  const middleware = config.middlewareFile
129
136
  ? await discoverMiddleware(config.middlewareFile)
130
137
  : undefined
138
+ const bootDef = config.bootFile ? await discoverBoot(config.bootFile) : undefined
131
139
  const inspector = resolved.inspector
132
140
  const app = await buildHonoApp({
133
141
  routes,
@@ -145,6 +153,7 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
145
153
  sameSite: resolved.sameSite,
146
154
  origin: resolved.origin,
147
155
  secret: resolved.secret,
156
+ buildId: config.buildId,
148
157
  cors: resolved.cors,
149
158
  middleware,
150
159
  })
@@ -152,9 +161,27 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
152
161
  return {
153
162
  listen(port: number): Promise<void> {
154
163
  return new Promise((resolveFn) => {
155
- const server = serve({ fetch: app.fetch, port, hostname: resolved.host }, () => {
164
+ const server = serve({ fetch: app.fetch, port, hostname: resolved.host }, async () => {
156
165
  // Always-on (level-independent) so `warn` prod still confirms boot.
157
166
  printStartupNotice({ port, machines: defs.length, routes: routes.length })
167
+ // Run boot.ts once the server is up — its long-lived work (a poll, a
168
+ // subscription) starts here, not during createApp, so tests that only
169
+ // `app.fetch` never trigger it.
170
+ const teardown: BootTeardown | undefined = await runBoot(bootDef, {
171
+ dispatchToApp: (machine, event) => dispatchToApp(store, machine, event),
172
+ config: {
173
+ origin: resolved.origin,
174
+ trustedOrigins: resolved.trustedOrigins ?? [],
175
+ sameSite: resolved.sameSite ?? 'Lax',
176
+ cors: resolved.cors,
177
+ },
178
+ })
179
+ // Ctrl+C / SIGTERM (deploy rollover) exits 0, not 130 — quiet in prod.
180
+ // Boot teardown runs first (unsubscribe/clear timers), then the server.
181
+ installGracefulShutdown(async () => {
182
+ if (teardown) await teardown()
183
+ await new Promise<void>((done) => server.close(() => done()))
184
+ }, true)
158
185
  resolveFn()
159
186
  })
160
187
  // Production is strict about its port (a collision is a deploy
@@ -169,9 +196,6 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
169
196
  }
170
197
  throw err
171
198
  })
172
- // Ctrl+C / SIGTERM (deploy rollover) exits 0, not 130 — quiet in
173
- // prod: the structured logs are the record.
174
- installGracefulShutdown(() => new Promise<void>((done) => server.close(() => done())), true)
175
199
  })
176
200
  },
177
201
  fetch: (request: Request) => app.fetch(request),
package/src/server/dev.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import { existsSync } from 'node:fs'
2
3
  import { readFile } from 'node:fs/promises'
3
4
  import { createServer as createHttpServer } from 'node:http'
@@ -115,6 +116,10 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
115
116
  // Load .env before anything reads process.env. No-op if the CLI already
116
117
  // loaded it; covers a direct `createDevApp` call (a hand-written dev entry).
117
118
  loadDotenv(resolve(config.root))
119
+ // One build-id per dev boot — a `tsx` restart is a new process → new id → the
120
+ // client reloads on reconnect (the restart-without-reload gap). An inner Vite
121
+ // rebuild keeps the same id (no full reload; islands hot-reload via Vite).
122
+ const devBuildId = randomUUID()
118
123
  // Vite's HMR websocket defaults to 24678 for EVERY dev server — two
119
124
  // stator apps side by side would fight over it (the loser's live reload
120
125
  // silently dies). Probe a free one instead.
@@ -122,6 +127,11 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
122
127
  const vite = await createViteServer({
123
128
  root: resolve(config.root),
124
129
  appType: 'custom',
130
+ // Ignore a user `vite.config.*` — the production build already sets this,
131
+ // and reading it only in dev is a dev/prod divergence footgun (a plugin
132
+ // configured there would run in `stator dev` and silently vanish in
133
+ // `stator build`). Stator's own plugins are passed inline below.
134
+ configFile: false,
125
135
  server: { middlewareMode: true, hmr: { port: hmrPort } },
126
136
  // machineStub keeps server machines out of browser module graphs: island
127
137
  // imports of a machine resolve to a `{ name }` stub (SSR loads are
@@ -254,6 +264,7 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
254
264
  sameSite: resolved.sameSite,
255
265
  origin: resolved.origin,
256
266
  secret: resolved.secret,
267
+ buildId: devBuildId,
257
268
  cors: resolved.cors,
258
269
  middleware,
259
270
  })
@@ -263,6 +274,10 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
263
274
  await rebuildRoutes()
264
275
  await rebuildServer()
265
276
 
277
+ // Discover boot.ts ONCE per process (not per rebuild) — a long-lived source
278
+ // shouldn't restart on every edit. Runs in listen() below.
279
+ const bootDef = await runtime.discoverBoot(resolve(root, 'boot.ts'), loader)
280
+
266
281
  // Live reload: on a relevant source change, re-discover and rebuild the app,
267
282
  // then tell the browser to reload. A template/route edit keeps the store (and
268
283
  // your session — cart contents and all) intact; only a machine edit resets it,
@@ -313,16 +328,12 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
313
328
  const server = createHttpServer((req, res) => {
314
329
  vite.middlewares(req, res, () => honoListener(req, res))
315
330
  })
316
- installGracefulShutdown(async () => {
317
- await vite.close()
318
- await new Promise<void>((done) => server.close(() => done()))
319
- })
320
331
  // Busy port? Shift up like every modern dev server (the requested
321
332
  // port is someone's other project, not an error).
322
333
  return findFreePort(port).then(
323
334
  (freePort) =>
324
335
  new Promise((resolveFn) => {
325
- server.listen(freePort, () => {
336
+ server.listen(freePort, async () => {
326
337
  printDevBanner({
327
338
  port: freePort,
328
339
  requestedPort: port,
@@ -330,6 +341,23 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
330
341
  routes: routes.length,
331
342
  inspector: true,
332
343
  })
344
+ // Boot runs once the dev server is up (store is read at call time,
345
+ // so a later rebuild's store is used). Teardown composes into the
346
+ // shutdown below.
347
+ const teardown = await runtime.runBoot(bootDef, {
348
+ dispatchToApp: (machine, event) => runtime.dispatchToApp(store, machine, event),
349
+ config: {
350
+ origin: resolved.origin,
351
+ trustedOrigins: resolved.trustedOrigins ?? [],
352
+ sameSite: resolved.sameSite ?? 'Lax',
353
+ cors: resolved.cors,
354
+ },
355
+ })
356
+ installGracefulShutdown(async () => {
357
+ if (teardown) await teardown()
358
+ await vite.close()
359
+ await new Promise<void>((done) => server.close(() => done()))
360
+ })
333
361
  resolveFn()
334
362
  })
335
363
  }),
@@ -49,6 +49,10 @@ export interface HttpConfig {
49
49
  /** Signed-cookie signing key (`config.secret` ?? `STATOR_SECRET`). Stashed on
50
50
  * the request context so `cookies.setSigned`/`getSigned` can reach it. */
51
51
  secret?: string
52
+ /** Build identifier — per-boot in dev, per-build in prod. Emitted into live
53
+ * pages as `<meta name="stator-build">`; the client echoes it on /__sse
54
+ * connect, and a mismatch reloads the page (its slot map may be stale). */
55
+ buildId?: string
52
56
  /** Resolved CORS read policy, exposed via `stator(c).cors`. */
53
57
  cors?: { origins: readonly string[]; credentials: boolean }
54
58
  /** The app's discovered `middleware.ts` definition (if any). `withDefaults`
@@ -304,6 +308,17 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
304
308
  c.header('X-Accel-Buffering', 'no')
305
309
 
306
310
  return streamSSE(c, async (stream) => {
311
+ // Build-id handshake: this page was rendered against a server build
312
+ // (`build` param, from its `stator-build` meta). If ours differs — a dev
313
+ // restart or a deploy since page load — its DOM↔slot-ID map may be stale,
314
+ // so reload to fetch a fresh page instead of resyncing onto it. Checked
315
+ // before any render/registration: a doomed connection does no work.
316
+ const pageBuild = c.req.query('build')
317
+ if (config.buildId && pageBuild && pageBuild !== config.buildId) {
318
+ await stream.writeSSE({ data: JSON.stringify({ directives: [{ type: 'reload' }] }) })
319
+ return
320
+ }
321
+
307
322
  const runtime = new SessionRuntime(sessionId, config.store)
308
323
  await runtime.loadGraph(route.reads)
309
324
  const { renderState } = await renderRoute(route, routeKey, sessionId, runtime, request)
@@ -497,7 +512,15 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
497
512
  if (isStatorQueryRoute(getRoute)) {
498
513
  return runQueryRoute(c, matched.route, getRoute, config.store, matched.params)
499
514
  }
500
- return handleGet(c, matched.route, getRoute, matched.params, config.store, config.headExtras)
515
+ return handleGet(
516
+ c,
517
+ matched.route,
518
+ getRoute,
519
+ matched.params,
520
+ config.store,
521
+ config.headExtras,
522
+ config.buildId,
523
+ )
501
524
  })
502
525
 
503
526
  return app
@@ -568,6 +591,7 @@ async function handleGet(
568
591
  params: Record<string, string>,
569
592
  store: MachineStore,
570
593
  headExtras?: (filePath: string) => string | Promise<string>,
594
+ buildId?: string,
571
595
  ): Promise<Response> {
572
596
  {
573
597
  const { sessionId } = getOrCreateSessionId(c)
@@ -586,7 +610,15 @@ async function handleGet(
586
610
  const extra = await headExtras(discovered.filePath)
587
611
  if (extra) headHtml.push(extra)
588
612
  }
589
- if (route.live) headHtml.push('<meta name="stator-live" content="true">')
613
+ if (route.live) {
614
+ headHtml.push('<meta name="stator-live" content="true">')
615
+ // The build this page was rendered against — the client echoes it on
616
+ // /__sse connect so the server can reload a page from a stale build.
617
+ // `buildId` is a framework-generated UUID (no HTML-special chars).
618
+ if (buildId) {
619
+ headHtml.push(`<meta name="stator-build" content="${buildId}">`)
620
+ }
621
+ }
590
622
 
591
623
  // Auto-inject the client runtime (delegated events + patch application).
592
624
  // Apps never hand-include it — a forgotten <script> is a silently dead
@@ -18,6 +18,8 @@ export type {
18
18
  export { dispatchToApp } from './app-dispatch.ts'
19
19
  export type { AppStore } from './app-store.ts'
20
20
  export { InMemoryAppStore } from './app-store.ts'
21
+ export type { BootContext, BootDefinition, BootFn, BootTeardown } from './boot.ts'
22
+ export { defineBoot, discoverBoot, isBootDefinition, runBoot } from './boot.ts'
21
23
  export type { CachedStoreOptions } from './cached-store.ts'
22
24
  export { CachedStore } from './cached-store.ts'
23
25
  export type { StatorContext } from './context.ts'