@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.
package/src/server/dev.ts CHANGED
@@ -1,44 +1,24 @@
1
- import { randomUUID } from 'node:crypto'
2
- import { existsSync } from 'node:fs'
3
- import { readFile } from 'node:fs/promises'
4
- import { createServer as createHttpServer } from 'node:http'
5
- import { relative, resolve } from 'node:path'
6
- import { getRequestListener } from '@hono/node-server'
7
1
  import type { Hono } from 'hono'
8
- import { createServer as createViteServer, type ViteDevServer } from 'vite'
9
- import { compile } from '../compiler/index.ts'
2
+ import type { ViteDevServer } from 'vite'
10
3
  import type { LogLevel } from '../config.ts'
11
4
  import type { AnyMachineDef, EventOf } from '../engine/index.ts'
12
- import { machineStub, stator } from '../vite/index.ts'
13
5
  import type { AppStore } from './app-store.ts'
14
- import { findFreePort, installGracefulShutdown, printDevBanner } from './banner.ts'
15
- import { resolveAppConfig } from './config-compat.ts'
16
- import { loadDotenv } from './env.ts'
17
- import { logger, setLogLevel } from './logger.ts'
18
- import type { MachineStore } from './machine-store.ts'
19
- import type { DiscoveredRoute } from './route-discovery.ts'
20
- import { isStatorQueryRoute } from './routing.ts'
6
+ import { createNativeDevApp as createNative, type NativeDevApp } from './dev-native.ts'
21
7
  import type { Store } from './store.ts'
22
8
 
23
9
  /**
24
- * Dev server: embeds Vite in middleware mode so `.stator` (and TS) modules are
25
- * compiled on the way in, loads machines + routes through `vite.ssrLoadModule`,
26
- * and injects each route's scoped component CSS into `<head>` at render time
27
- * (SSR head injection — independent of the client JS bundle).
10
+ * The dev entry. `createDevApp` boots the NATIVE dev server (`dev-native.ts`):
11
+ * the app runs from its source tree under Node's module loader exactly as
12
+ * `stator start` runs a build — no Vite in the server path, no dev/prod
13
+ * divergence, truthful `import.meta.url`s.
28
14
  *
29
- * Critically, the framework runtime itself is loaded *through Vite*
30
- * (`ssrLoadModule('@statorjs/stator/server')`), not imported natively — so the
31
- * render-context, machine defs, routes, and templates all share one module
32
- * instance. Importing the runtime natively here would create a second instance
33
- * whose render-context doesn't match the one the templates resolve against, and
34
- * `read()` would throw. (This mirrors how Astro/SvelteKit run SSR through Vite.)
35
- *
36
- * The production serve path (pre-built assets + manifest, no Vite) is a separate
37
- * follow-up; this is the dev half of Phase 3a.
15
+ * `STATOR_VITE_DEV=1` keeps the previous Vite-embedded dev server
16
+ * (`dev-vite.ts`) for one minor as an escape hatch while the native loop beds
17
+ * in. It is loaded lazily, so the default path never imports Vite.
38
18
  */
39
19
 
40
20
  export interface DevAppConfig {
41
- /** Vite root — the app directory (must reach node_modules for resolution). */
21
+ /** The app directory (must reach node_modules for resolution). */
42
22
  root: string
43
23
  machinesDir: string
44
24
  routesDir: string
@@ -94,13 +74,15 @@ export interface DevAppConfig {
94
74
 
95
75
  export interface DevApp {
96
76
  fetch: (request: Request) => Response | Promise<Response>
97
- vite: ViteDevServer
77
+ /** @deprecated The dev server no longer embeds Vite — this is `undefined`
78
+ * (with a one-time warning) and will be removed in the next major. Under the
79
+ * transitional `STATOR_VITE_DEV=1` escape hatch it is still the real
80
+ * `ViteDevServer` for one minor. */
81
+ readonly vite: ViteDevServer | undefined
98
82
  /** Server-originated dispatch to an APP-lifecycle machine (webhooks, cron)
99
83
  * — the dev counterpart of `StatorApp.dispatchToApp`. Closes over the
100
- * current store (surviving machine-edit rebuilds) and runs through the
101
- * Vite-loaded runtime, so SSE fan-out reaches live connections; a
102
- * natively-imported `dispatchToApp` would be a second module instance
103
- * whose connection registry is empty. */
84
+ * current store, surviving machine-edit rebuilds, so SSE fan-out reaches
85
+ * live connections. */
104
86
  dispatchToApp<D extends AnyMachineDef>(
105
87
  machine: D,
106
88
  event: EventOf<D>,
@@ -112,298 +94,42 @@ export interface DevApp {
112
94
  close: () => Promise<void>
113
95
  }
114
96
 
115
- export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
116
- // Load .env before anything reads process.env. No-op if the CLI already
117
- // loaded it; covers a direct `createDevApp` call (a hand-written dev entry).
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()
123
- // Vite's HMR websocket defaults to 24678 for EVERY dev server — two
124
- // stator apps side by side would fight over it (the loser's live reload
125
- // silently dies). Probe a free one instead.
126
- const hmrPort = await findFreePort(24678)
127
- const vite = await createViteServer({
128
- root: resolve(config.root),
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,
135
- server: { middlewareMode: true, hmr: { port: hmrPort } },
136
- // machineStub keeps server machines out of browser module graphs: island
137
- // imports of a machine resolve to a `{ name }` stub (SSR loads are
138
- // untouched — `options.ssr` gates it).
139
- plugins: [stator(), machineStub({ machinesDir: resolve(config.machinesDir) })],
140
- // The framework's own source is TS that Vite must transform, not externalize.
141
- ssr: { noExternal: [/@statorjs\/stator/] },
142
- logLevel: 'warn',
143
- })
144
-
145
- // Load the runtime through Vite so it shares an instance with the templates.
146
- // (Type-only: the static import type is erased, so no second instance.)
147
- const runtime = (await vite.ssrLoadModule(
148
- '@statorjs/stator/server',
149
- )) as typeof import('./index.ts')
150
- const loader = (file: string) => vite.ssrLoadModule(file) as Promise<Record<string, unknown>>
151
-
152
- const root = resolve(config.root)
153
- const machinesDir = resolve(config.machinesDir)
154
- const routesDir = resolve(config.routesDir)
155
- const resolved = resolveAppConfig(config)
156
- // Level precedence: LOG_LEVEL env > config > dev default (info). Apply to this
157
- // module's logger AND the Vite-loaded runtime instance — SSE/http/effects log
158
- // through the latter — via setLogLevel, which also covers the scoped children.
159
- const logLevel = process.env.LOG_LEVEL ?? resolved.logLevel ?? 'info'
160
- setLogLevel(logLevel)
161
- runtime.setLogLevel(logLevel)
162
- // The session cookie is written by the Vite-loaded runtime, so configure that
163
- // instance's SameSite (the native one would set a different module's global).
164
- runtime.setSessionSameSite(resolved.sameSite ?? 'Lax')
165
- const inspectorOn = resolved.inspector ?? true
166
-
167
- const resultCache = new Map<string, ReturnType<typeof compile>>()
168
- const compiledFor = async (file: string) => {
169
- let r = resultCache.get(file)
170
- if (r === undefined) {
171
- // Match the plugin's kind detection so route-page frontmatter
172
- // (Stator.reads etc.) compiles under the route capability set.
173
- const kind = /[\\/]routes[\\/].*\.stator$/.test(file) ? 'route' : 'component'
174
- r = compile(await readFile(file, 'utf8'), { id: file, kind })
175
- resultCache.set(file, r)
176
- }
177
- return r
178
- }
179
-
180
- const headExtras = async (routeFile: string) => {
181
- const files = reachableStatorFiles(vite, routeFile)
182
- let css = ''
183
- const scripts: string[] = []
184
- for (const f of files) {
185
- const r = await compiledFor(f)
186
- if (r.css) css += `/* ${f} */\n${r.css}\n`
187
- if (r.isClient) {
188
- // Vite's transform middleware compiles this URL because browsers send
189
- // `Sec-Fetch-Dest: script` for module scripts (the `.stator` extension
190
- // alone wouldn't match its JS-request check). curl-style probes must
191
- // send that header or they'll see raw source and cry wolf.
192
- const url = `/${relative(root, f).replace(/\\/g, '/')}?stator&type=client`
193
- scripts.push(`<script type="module" src="${url}"></script>`)
194
- }
195
- }
196
- const head: string[] = []
197
- // Vite's HMR client — pages are rendered by Hono, not Vite's
198
- // transformIndexHtml, so it isn't injected for us. Without it the browser
199
- // has no socket to receive the full-reload signal on a source change.
200
- head.push('<script type="module" src="/@vite/client"></script>')
201
- // The dev inspector toolbar (off in production — only the dev server injects it).
202
- if (inspectorOn) head.push('<script src="/@stator/inspector.js" defer></script>')
203
- if (css.trim()) head.push(`<style data-stator-dev>\n${css.trim()}\n</style>`)
204
- head.push(...scripts)
205
- return head.join('\n')
206
- }
207
-
208
- // The app graph is rebuilt on a source change so edits don't need a restart.
209
- let store: MachineStore
210
- let routes: DiscoveredRoute[] = []
211
- let machineCount = 0
212
- let app: Hono
213
-
214
- const rebuildStore = async (): Promise<void> => {
215
- const { defs } = await runtime.discoverMachines(machinesDir, loader)
216
- machineCount = defs.length
217
- store = new runtime.MachineStore(defs, resolved.session ?? new runtime.InMemoryStore(), {
218
- sessionTtlSeconds: resolved.sessionTtlSeconds,
219
- appStore: resolved.app,
220
- })
221
- // Wire the effect scheduler before booting — a fresh app machine's
222
- // initial-state entry effect fires during boot and needs a live scheduler.
223
- runtime.wireAppEffects(store)
224
- await store.bootAppMachines()
225
- // Dev-only lint: a session machine whose `after` timer drives a loop with
226
- // a data-loading entry effect is server-side polling that runs for
227
- // sessions nobody is watching. Warn with the steer, don't block.
228
- for (const f of runtime.findPollLoops(defs)) {
229
- console.warn(
230
- `stator: session machine "${f.machine}" self-reschedules through \`after\` ` +
231
- `(${f.cycle.join(' → ')} via ${f.event}) with a data-loading entry effect on the loop. ` +
232
- `This polls upstream for sessions nobody is watching. Prefer a client-owned clock ` +
233
- `with a staleness guard — see the effects guide, "Who owns the clock".`,
234
- )
235
- }
236
- }
237
- const rebuildRoutes = async (): Promise<void> => {
238
- routes = await runtime.discoverRoutes(routesDir, loader)
239
- // Vite serves `public/` at the root ahead of Hono in dev, so a static
240
- // file at a data route's URL silently wins. Warn at discovery time.
241
- // (The brand is a plain property, so this native-import check works on
242
- // defs the Vite-loaded runtime created.)
243
- for (const r of routes) {
244
- if (!r.GET || !isStatorQueryRoute(r.GET) || r.paramNames.length > 0) continue
245
- if (existsSync(resolve(root, 'public', r.urlPath.slice(1)))) {
246
- console.warn(
247
- `stator: public/${r.urlPath.slice(1)} shadows the data route ${r.urlPath} in dev — ` +
248
- `the static file wins. Rename one of them.`,
249
- )
250
- }
251
- }
252
- }
253
- const rebuildServer = async (): Promise<void> => {
254
- // Re-discover `middleware.ts` each rebuild so edits to it take effect. Loaded
255
- // through the Vite runtime so its handlers share the runtime's instance.
256
- const middleware = await runtime.discoverMiddleware(resolve(root, 'middleware.ts'), loader)
257
- app = await runtime.buildHonoApp({
258
- routes,
259
- store,
260
- staticDir: config.staticDir,
261
- headExtras,
262
- inspector: inspectorOn,
263
- trustedOrigins: resolved.trustedOrigins,
264
- sameSite: resolved.sameSite,
265
- origin: resolved.origin,
266
- secret: resolved.secret,
267
- buildId: devBuildId,
268
- cors: resolved.cors,
269
- middleware,
270
- })
271
- }
272
-
273
- await rebuildStore()
274
- await rebuildRoutes()
275
- await rebuildServer()
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
-
281
- // Live reload: on a relevant source change, re-discover and rebuild the app,
282
- // then tell the browser to reload. A template/route edit keeps the store (and
283
- // your session — cart contents and all) intact; only a machine edit resets it,
284
- // since route `reads:` bind to machine defs by identity and must re-bind as a
285
- // set. Rebuilds are serialized so overlapping saves can't race.
286
- const isAppFile = (file: string): boolean => {
287
- if (!/\.(stator|ts|js)$/.test(file)) return false
288
- if (file.includes('/node_modules/') || file.includes('/dist/')) return false
289
- if (file.startsWith(machinesDir) || file.startsWith(routesDir)) return true
290
- return (vite.moduleGraph.getModulesByFile(file)?.size ?? 0) > 0
291
- }
292
- let reloadChain: Promise<void> = Promise.resolve()
293
- const onChange = (file: string): void => {
294
- const abs = resolve(file)
295
- if (!isAppFile(abs)) return
296
- reloadChain = reloadChain.then(async () => {
297
- invalidateModuleTree(vite, abs)
298
- resultCache.delete(abs)
299
- try {
300
- if (abs.startsWith(machinesDir)) await rebuildStore()
301
- await rebuildRoutes()
302
- await rebuildServer()
303
- vite.ws.send({ type: 'full-reload' })
304
- logger.info({ file: relative(root, abs) }, 'reloaded')
305
- } catch (err) {
306
- logger.error({ err: (err as Error).message, file: relative(root, abs) }, 'reload failed')
307
- }
308
- })
309
- }
310
- vite.watcher.on('change', onChange)
311
- vite.watcher.on('add', onChange)
312
- vite.watcher.on('unlink', onChange)
313
-
97
+ let warnedVite = false
98
+ /** The native app in the public `DevApp` shape: `vite` is a deprecated getter
99
+ * that warns once and returns `undefined`. Exported for tests. */
100
+ export function withDeprecatedVite(app: NativeDevApp): DevApp {
314
101
  return {
315
102
  fetch: (request) => app.fetch(request),
316
103
  get hono() {
317
- return app
104
+ return app.hono
318
105
  },
319
- vite,
320
- // `store` is read at call time, not captured — a machine-edit rebuild
321
- // swaps it and the method follows.
322
- dispatchToApp: (machine, event) => runtime.dispatchToApp(store, machine, event),
323
- listen(port: number): Promise<void> {
324
- // Vite's middlewares serve client modules + HMR to the browser; anything
325
- // it doesn't handle (routes, /__events, /__sse) falls through to Hono.
326
- // The arrow re-reads `app` each request so a rebuild swaps in seamlessly.
327
- const honoListener = getRequestListener((req) => app.fetch(req))
328
- const server = createHttpServer((req, res) => {
329
- vite.middlewares(req, res, () => honoListener(req, res))
330
- })
331
- // Busy port? Shift up like every modern dev server (the requested
332
- // port is someone's other project, not an error).
333
- return findFreePort(port).then(
334
- (freePort) =>
335
- new Promise((resolveFn) => {
336
- server.listen(freePort, async () => {
337
- printDevBanner({
338
- port: freePort,
339
- requestedPort: port,
340
- machines: machineCount,
341
- routes: routes.length,
342
- inspector: true,
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
- })
361
- resolveFn()
362
- })
363
- }),
364
- )
106
+ dispatchToApp: (machine, event) => app.dispatchToApp(machine, event),
107
+ listen: (port) => app.listen(port),
108
+ close: () => app.close(),
109
+ get vite(): undefined {
110
+ if (!warnedVite) {
111
+ warnedVite = true
112
+ console.warn(
113
+ 'stator: DevApp.vite is deprecated and returns undefined — the dev server no ' +
114
+ 'longer embeds Vite. STATOR_VITE_DEV=1 keeps the Vite dev server for one minor.',
115
+ )
116
+ }
117
+ return undefined
365
118
  },
366
- close: () => vite.close(),
367
119
  }
368
120
  }
369
121
 
370
- /** Invalidate a changed file and everything that (transitively) imports it, so
371
- * the next `ssrLoadModule` re-executes them with fresh code. */
372
- function invalidateModuleTree(vite: ViteDevServer, file: string): void {
373
- const seen = new Set<unknown>()
374
- const stack: Array<{ importers: Set<unknown> }> = [
375
- ...((vite.moduleGraph.getModulesByFile(file) ?? []) as Set<never>),
376
- ]
377
- while (stack.length) {
378
- const mod = stack.pop()
379
- if (!mod || seen.has(mod)) continue
380
- seen.add(mod)
381
- vite.moduleGraph.invalidateModule(mod as never)
382
- for (const imp of mod.importers) stack.push(imp as never)
122
+ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
123
+ if (process.env.STATOR_VITE_DEV === '1') {
124
+ const { createViteDevApp } = await import('./dev-vite.ts')
125
+ return createViteDevApp(config)
383
126
  }
127
+ return withDeprecatedVite(await createNative(config))
384
128
  }
385
129
 
386
- /**
387
- * Walk the SSR module graph from a route's file and return every `.stator` file
388
- * reachable from it (route page + the components it renders). The caller compiles
389
- * each to collect scoped CSS and client-component module scripts.
390
- */
391
- function reachableStatorFiles(vite: ViteDevServer, entryFile: string): string[] {
392
- const seen = new Set<string>()
393
- const statorFiles = new Set<string>()
394
-
395
- const visit = (node: { id: string | null; importedModules: Set<unknown> } | undefined): void => {
396
- if (!node) return
397
- const id = node.id ?? ''
398
- if (seen.has(id)) return
399
- seen.add(id)
400
- const file = id.split('?')[0]!
401
- if (/\.stator$/.test(file)) statorFiles.add(file)
402
- for (const dep of node.importedModules) visit(dep as never)
403
- }
130
+ /** @deprecated `createDevApp` IS the native dev server now — use it. (This
131
+ * alias always boots the native server, ignoring `STATOR_VITE_DEV`.) */
132
+ export const createNativeDevApp = async (config: DevAppConfig): Promise<DevApp> =>
133
+ withDeprecatedVite(await createNative(config))
404
134
 
405
- for (const node of vite.moduleGraph.getModulesByFile(entryFile) ?? []) {
406
- visit(node as never)
407
- }
408
- return [...statorFiles]
409
- }
135
+ export type { NativeDevApp } from './dev-native.ts'
@@ -1,7 +1,8 @@
1
1
  import { readdir } from 'node:fs/promises'
2
- import { extname, resolve } from 'node:path'
2
+ import { extname, relative, resolve } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import { type AnyMachineDef, isStatorMachine } from './define-machine.ts'
5
+ import { hashMachines, setCodeHash } from './machine-hash.ts'
5
6
 
6
7
  export interface DiscoveryResult {
7
8
  defs: AnyMachineDef[]
@@ -14,9 +15,25 @@ export type ModuleLoader = (file: string) => Promise<Record<string, unknown>>
14
15
 
15
16
  const nativeLoader: ModuleLoader = (file) => import(/* @vite-ignore */ pathToFileURL(file).href)
16
17
 
18
+ export interface DiscoverMachinesOptions {
19
+ /**
20
+ * Code hashes for the hydration policy (see `snapshot-policy.ts`).
21
+ * - omitted: computed live, one esbuild pass over the discovered files
22
+ * (the dev servers, and `createApp` without a build manifest);
23
+ * - a map keyed by machine FILE (relative to the machines dir, e.g.
24
+ * `cart.ts`): consumed as-is — `stator start` from the build manifest,
25
+ * whose hashes are per file because the build never executes a machine
26
+ * to learn its name. A discovered machine with no entry is an error,
27
+ * never a silent always-reset;
28
+ * - `false`: skip hashing (unit tests assembling stores from defs).
29
+ */
30
+ hashes?: ReadonlyMap<string, string> | Readonly<Record<string, string>> | false
31
+ }
32
+
17
33
  export async function discoverMachines(
18
34
  dir: string,
19
35
  load: ModuleLoader = nativeLoader,
36
+ opts: DiscoverMachinesOptions = {},
20
37
  ): Promise<DiscoveryResult> {
21
38
  const absDir = resolve(dir)
22
39
  // A missing conventional dir means "no machines yet" (e.g. early in a fresh
@@ -38,6 +55,19 @@ export async function discoverMachines(
38
55
 
39
56
  const defs: AnyMachineDef[] = []
40
57
  const seenNames = new Set<string>()
58
+ const supplied = opts.hashes
59
+ const byFile =
60
+ supplied === false || supplied === undefined
61
+ ? undefined
62
+ : supplied instanceof Map
63
+ ? supplied
64
+ : new Map(Object.entries(supplied))
65
+ // Live hashing: one esbuild pass over every machine file, before any import,
66
+ // so an unbundleable closure fails here with the file named.
67
+ const live =
68
+ supplied === undefined && files.length > 0
69
+ ? await hashMachines(files, { machinesDir: absDir })
70
+ : undefined
41
71
 
42
72
  for (const file of files) {
43
73
  const mod = await load(file)
@@ -52,6 +82,20 @@ export async function discoverMachines(
52
82
  throw new Error(`stator: duplicate machine name "${def.name}" in ${file}`)
53
83
  }
54
84
  seenNames.add(def.name)
85
+ if (byFile) {
86
+ const key = relative(absDir, file).replace(/\\/g, '/')
87
+ const hash = byFile.get(key)
88
+ if (hash === undefined) {
89
+ throw new Error(
90
+ `stator: no code hash for machine "${def.name}" (${key}) in the build manifest — ` +
91
+ `rebuild with \`stator build\`; a machine without a hash cannot hydrate safely.`,
92
+ )
93
+ }
94
+ setCodeHash(def, hash)
95
+ } else if (live) {
96
+ const { hash, inputs } = live.get(file)!
97
+ setCodeHash(def, hash, inputs)
98
+ }
55
99
  defs.push(def)
56
100
  }
57
101
 
@@ -2,6 +2,7 @@ import type { AnyMachineDef, EffectInvocation, EventObject } from '../engine/ind
2
2
  import { dispatchToApp } from './app-dispatch.ts'
3
3
  import { scopedLogger } from './logger.ts'
4
4
  import type { MachineStore } from './machine-store.ts'
5
+ import { CLAIMS_KEY } from './session.ts'
5
6
  import { withSessionLock } from './session-lock.ts'
6
7
  import { SessionRuntime } from './session-runtime.ts'
7
8
  import { fanOut } from './sse.ts'
@@ -156,7 +157,15 @@ async function runSessionEffect(
156
157
  try {
157
158
  let completion: Awaited<ReturnType<EffectInvocation['run']>>
158
159
  try {
159
- completion = await invocation.run(controller.signal)
160
+ // The session the effect runs for: its id and its claims as of now —
161
+ // loaded here (outside the lock, like the I/O the effect is about to do)
162
+ // so an entry effect can reload a durable fact by identity after a fresh
163
+ // start or a snapshot reset, with no client round trip.
164
+ const claims = (await store.persistence.get(sessionId, CLAIMS_KEY)) ?? undefined
165
+ completion = await invocation.run(controller.signal, {
166
+ id: sessionId,
167
+ claims: <T>() => claims as T | undefined,
168
+ })
160
169
  } catch (err) {
161
170
  // Backstop only — the type contract asks effects to catch and return
162
171
  // their failure event. Never crashes the host.
@@ -9,6 +9,7 @@ import { applyRenderedEffects, runApiRoute } from './api-route.ts'
9
9
  import { crossSiteGuard } from './csrf.ts'
10
10
  import { scheduleSessionEffects } from './effects.ts'
11
11
  import { record, replayFor } from './event-dedupe.ts'
12
+ import { buildInspectPayload } from './inspect.ts'
12
13
  import { scopedLogger } from './logger.ts'
13
14
  import type { MachineStore } from './machine-store.ts'
14
15
  import type { MiddlewareDefinition } from './middleware.ts'
@@ -36,6 +37,11 @@ export interface HttpConfig {
36
37
  /** Serve the dev inspector asset at `/@stator/inspector.js`. The dev server
37
38
  * sets this and injects the script tag; production leaves it off. */
38
39
  inspector?: boolean
40
+ /** Serve the state-inspection endpoint at `/@stator/inspect` — the caller's
41
+ * own session's machine snapshots plus the machine/route catalog. Only the
42
+ * dev servers set this; production never does (machine context is working
43
+ * state and may hold anything), even when it opts into the wire toolbar. */
44
+ inspect?: boolean
39
45
  /** SSE heartbeat interval in ms (default 25s). Tests shorten it. */
40
46
  ssePingMs?: number
41
47
  /** Origins allowed to make cross-site writes despite the guard (exact or
@@ -246,6 +252,22 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
246
252
  })
247
253
  }
248
254
 
255
+ // Dev state inspection — cookie-scoped to the caller's own session (the
256
+ // request bridge above established it). See server/inspect.ts for the
257
+ // privacy stance; the flag is dev-server-only by construction.
258
+ if (config.inspect) {
259
+ app.get('/@stator/inspect', async (c) => {
260
+ const payload = await buildInspectPayload({
261
+ store: config.store,
262
+ routes: config.routes,
263
+ sessionId: getSessionState(c)?.sid,
264
+ buildId: config.buildId,
265
+ })
266
+ c.header('Cache-Control', 'no-cache')
267
+ return c.json(payload)
268
+ })
269
+ }
270
+
249
271
  if (config.staticDir) {
250
272
  const staticDir = resolve(config.staticDir)
251
273
  app.get('/static/*', async (c) => {
@@ -653,7 +675,7 @@ async function handleGet(
653
675
  }
654
676
  }
655
677
 
656
- function contentTypeFor(path: string): string {
678
+ export function contentTypeFor(path: string): string {
657
679
  const ext = extname(path).toLowerCase()
658
680
  if (ext === '.css') return 'text/css; charset=utf-8'
659
681
  if (ext === '.js' || ext === '.mjs') return 'application/javascript; charset=utf-8'
@@ -661,5 +683,11 @@ function contentTypeFor(path: string): string {
661
683
  if (ext === '.svg') return 'image/svg+xml'
662
684
  if (ext === '.png') return 'image/png'
663
685
  if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
686
+ if (ext === '.webp') return 'image/webp'
687
+ if (ext === '.gif') return 'image/gif'
688
+ if (ext === '.ico') return 'image/x-icon'
689
+ if (ext === '.wasm') return 'application/wasm'
690
+ if (ext === '.woff2') return 'font/woff2'
691
+ if (ext === '.woff') return 'font/woff'
664
692
  return 'application/octet-stream'
665
693
  }
@@ -52,9 +52,12 @@ export {
52
52
  export { scheduleSessionEffects, wireAppEffects } from './effects.ts'
53
53
  export type { HttpConfig } from './http.ts'
54
54
  export { buildHonoApp } from './http.ts'
55
+ export type { InspectPayload, InspectRoute, InspectRouteMethod } from './inspect.ts'
56
+ export { buildInspectPayload } from './inspect.ts'
55
57
  export type { InstanceHandle } from './instance-proxy.ts'
56
58
  export { createInstanceProxy, defForProxy } from './instance-proxy.ts'
57
59
  export { logger, scopedLogger, setLogLevel } from './logger.ts'
60
+ export { codeHashOf, codeInputsOf } from './machine-hash.ts'
58
61
  export { MachineStore } from './machine-store.ts'
59
62
  export type { MiddlewareDefinition } from './middleware.ts'
60
63
  export {