@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/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,293 +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
- server: { middlewareMode: true, hmr: { port: hmrPort } },
131
- // machineStub keeps server machines out of browser module graphs: island
132
- // imports of a machine resolve to a `{ name }` stub (SSR loads are
133
- // untouched — `options.ssr` gates it).
134
- plugins: [stator(), machineStub({ machinesDir: resolve(config.machinesDir) })],
135
- // The framework's own source is TS that Vite must transform, not externalize.
136
- ssr: { noExternal: [/@statorjs\/stator/] },
137
- logLevel: 'warn',
138
- })
139
-
140
- // Load the runtime through Vite so it shares an instance with the templates.
141
- // (Type-only: the static import type is erased, so no second instance.)
142
- const runtime = (await vite.ssrLoadModule(
143
- '@statorjs/stator/server',
144
- )) as typeof import('./index.ts')
145
- const loader = (file: string) => vite.ssrLoadModule(file) as Promise<Record<string, unknown>>
146
-
147
- const root = resolve(config.root)
148
- const machinesDir = resolve(config.machinesDir)
149
- const routesDir = resolve(config.routesDir)
150
- const resolved = resolveAppConfig(config)
151
- // Level precedence: LOG_LEVEL env > config > dev default (info). Apply to this
152
- // module's logger AND the Vite-loaded runtime instance — SSE/http/effects log
153
- // through the latter — via setLogLevel, which also covers the scoped children.
154
- const logLevel = process.env.LOG_LEVEL ?? resolved.logLevel ?? 'info'
155
- setLogLevel(logLevel)
156
- runtime.setLogLevel(logLevel)
157
- // The session cookie is written by the Vite-loaded runtime, so configure that
158
- // instance's SameSite (the native one would set a different module's global).
159
- runtime.setSessionSameSite(resolved.sameSite ?? 'Lax')
160
- const inspectorOn = resolved.inspector ?? true
161
-
162
- const resultCache = new Map<string, ReturnType<typeof compile>>()
163
- const compiledFor = async (file: string) => {
164
- let r = resultCache.get(file)
165
- if (r === undefined) {
166
- // Match the plugin's kind detection so route-page frontmatter
167
- // (Stator.reads etc.) compiles under the route capability set.
168
- const kind = /[\\/]routes[\\/].*\.stator$/.test(file) ? 'route' : 'component'
169
- r = compile(await readFile(file, 'utf8'), { id: file, kind })
170
- resultCache.set(file, r)
171
- }
172
- return r
173
- }
174
-
175
- const headExtras = async (routeFile: string) => {
176
- const files = reachableStatorFiles(vite, routeFile)
177
- let css = ''
178
- const scripts: string[] = []
179
- for (const f of files) {
180
- const r = await compiledFor(f)
181
- if (r.css) css += `/* ${f} */\n${r.css}\n`
182
- if (r.isClient) {
183
- // Vite's transform middleware compiles this URL because browsers send
184
- // `Sec-Fetch-Dest: script` for module scripts (the `.stator` extension
185
- // alone wouldn't match its JS-request check). curl-style probes must
186
- // send that header or they'll see raw source and cry wolf.
187
- const url = `/${relative(root, f).replace(/\\/g, '/')}?stator&type=client`
188
- scripts.push(`<script type="module" src="${url}"></script>`)
189
- }
190
- }
191
- const head: string[] = []
192
- // Vite's HMR client — pages are rendered by Hono, not Vite's
193
- // transformIndexHtml, so it isn't injected for us. Without it the browser
194
- // has no socket to receive the full-reload signal on a source change.
195
- head.push('<script type="module" src="/@vite/client"></script>')
196
- // The dev inspector toolbar (off in production — only the dev server injects it).
197
- if (inspectorOn) head.push('<script src="/@stator/inspector.js" defer></script>')
198
- if (css.trim()) head.push(`<style data-stator-dev>\n${css.trim()}\n</style>`)
199
- head.push(...scripts)
200
- return head.join('\n')
201
- }
202
-
203
- // The app graph is rebuilt on a source change so edits don't need a restart.
204
- let store: MachineStore
205
- let routes: DiscoveredRoute[] = []
206
- let machineCount = 0
207
- let app: Hono
208
-
209
- const rebuildStore = async (): Promise<void> => {
210
- const { defs } = await runtime.discoverMachines(machinesDir, loader)
211
- machineCount = defs.length
212
- store = new runtime.MachineStore(defs, resolved.session ?? new runtime.InMemoryStore(), {
213
- sessionTtlSeconds: resolved.sessionTtlSeconds,
214
- appStore: resolved.app,
215
- })
216
- // Wire the effect scheduler before booting — a fresh app machine's
217
- // initial-state entry effect fires during boot and needs a live scheduler.
218
- runtime.wireAppEffects(store)
219
- await store.bootAppMachines()
220
- // Dev-only lint: a session machine whose `after` timer drives a loop with
221
- // a data-loading entry effect is server-side polling that runs for
222
- // sessions nobody is watching. Warn with the steer, don't block.
223
- for (const f of runtime.findPollLoops(defs)) {
224
- console.warn(
225
- `stator: session machine "${f.machine}" self-reschedules through \`after\` ` +
226
- `(${f.cycle.join(' → ')} via ${f.event}) with a data-loading entry effect on the loop. ` +
227
- `This polls upstream for sessions nobody is watching. Prefer a client-owned clock ` +
228
- `with a staleness guard — see the effects guide, "Who owns the clock".`,
229
- )
230
- }
231
- }
232
- const rebuildRoutes = async (): Promise<void> => {
233
- routes = await runtime.discoverRoutes(routesDir, loader)
234
- // Vite serves `public/` at the root ahead of Hono in dev, so a static
235
- // file at a data route's URL silently wins. Warn at discovery time.
236
- // (The brand is a plain property, so this native-import check works on
237
- // defs the Vite-loaded runtime created.)
238
- for (const r of routes) {
239
- if (!r.GET || !isStatorQueryRoute(r.GET) || r.paramNames.length > 0) continue
240
- if (existsSync(resolve(root, 'public', r.urlPath.slice(1)))) {
241
- console.warn(
242
- `stator: public/${r.urlPath.slice(1)} shadows the data route ${r.urlPath} in dev — ` +
243
- `the static file wins. Rename one of them.`,
244
- )
245
- }
246
- }
247
- }
248
- const rebuildServer = async (): Promise<void> => {
249
- // Re-discover `middleware.ts` each rebuild so edits to it take effect. Loaded
250
- // through the Vite runtime so its handlers share the runtime's instance.
251
- const middleware = await runtime.discoverMiddleware(resolve(root, 'middleware.ts'), loader)
252
- app = await runtime.buildHonoApp({
253
- routes,
254
- store,
255
- staticDir: config.staticDir,
256
- headExtras,
257
- inspector: inspectorOn,
258
- trustedOrigins: resolved.trustedOrigins,
259
- sameSite: resolved.sameSite,
260
- origin: resolved.origin,
261
- secret: resolved.secret,
262
- buildId: devBuildId,
263
- cors: resolved.cors,
264
- middleware,
265
- })
266
- }
267
-
268
- await rebuildStore()
269
- await rebuildRoutes()
270
- await rebuildServer()
271
-
272
- // Discover boot.ts ONCE per process (not per rebuild) — a long-lived source
273
- // shouldn't restart on every edit. Runs in listen() below.
274
- const bootDef = await runtime.discoverBoot(resolve(root, 'boot.ts'), loader)
275
-
276
- // Live reload: on a relevant source change, re-discover and rebuild the app,
277
- // then tell the browser to reload. A template/route edit keeps the store (and
278
- // your session — cart contents and all) intact; only a machine edit resets it,
279
- // since route `reads:` bind to machine defs by identity and must re-bind as a
280
- // set. Rebuilds are serialized so overlapping saves can't race.
281
- const isAppFile = (file: string): boolean => {
282
- if (!/\.(stator|ts|js)$/.test(file)) return false
283
- if (file.includes('/node_modules/') || file.includes('/dist/')) return false
284
- if (file.startsWith(machinesDir) || file.startsWith(routesDir)) return true
285
- return (vite.moduleGraph.getModulesByFile(file)?.size ?? 0) > 0
286
- }
287
- let reloadChain: Promise<void> = Promise.resolve()
288
- const onChange = (file: string): void => {
289
- const abs = resolve(file)
290
- if (!isAppFile(abs)) return
291
- reloadChain = reloadChain.then(async () => {
292
- invalidateModuleTree(vite, abs)
293
- resultCache.delete(abs)
294
- try {
295
- if (abs.startsWith(machinesDir)) await rebuildStore()
296
- await rebuildRoutes()
297
- await rebuildServer()
298
- vite.ws.send({ type: 'full-reload' })
299
- logger.info({ file: relative(root, abs) }, 'reloaded')
300
- } catch (err) {
301
- logger.error({ err: (err as Error).message, file: relative(root, abs) }, 'reload failed')
302
- }
303
- })
304
- }
305
- vite.watcher.on('change', onChange)
306
- vite.watcher.on('add', onChange)
307
- vite.watcher.on('unlink', onChange)
308
-
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 {
309
101
  return {
310
102
  fetch: (request) => app.fetch(request),
311
103
  get hono() {
312
- return app
104
+ return app.hono
313
105
  },
314
- vite,
315
- // `store` is read at call time, not captured — a machine-edit rebuild
316
- // swaps it and the method follows.
317
- dispatchToApp: (machine, event) => runtime.dispatchToApp(store, machine, event),
318
- listen(port: number): Promise<void> {
319
- // Vite's middlewares serve client modules + HMR to the browser; anything
320
- // it doesn't handle (routes, /__events, /__sse) falls through to Hono.
321
- // The arrow re-reads `app` each request so a rebuild swaps in seamlessly.
322
- const honoListener = getRequestListener((req) => app.fetch(req))
323
- const server = createHttpServer((req, res) => {
324
- vite.middlewares(req, res, () => honoListener(req, res))
325
- })
326
- // Busy port? Shift up like every modern dev server (the requested
327
- // port is someone's other project, not an error).
328
- return findFreePort(port).then(
329
- (freePort) =>
330
- new Promise((resolveFn) => {
331
- server.listen(freePort, async () => {
332
- printDevBanner({
333
- port: freePort,
334
- requestedPort: port,
335
- machines: machineCount,
336
- routes: routes.length,
337
- inspector: true,
338
- })
339
- // Boot runs once the dev server is up (store is read at call time,
340
- // so a later rebuild's store is used). Teardown composes into the
341
- // shutdown below.
342
- const teardown = await runtime.runBoot(bootDef, {
343
- dispatchToApp: (machine, event) => runtime.dispatchToApp(store, machine, event),
344
- config: {
345
- origin: resolved.origin,
346
- trustedOrigins: resolved.trustedOrigins ?? [],
347
- sameSite: resolved.sameSite ?? 'Lax',
348
- cors: resolved.cors,
349
- },
350
- })
351
- installGracefulShutdown(async () => {
352
- if (teardown) await teardown()
353
- await vite.close()
354
- await new Promise<void>((done) => server.close(() => done()))
355
- })
356
- resolveFn()
357
- })
358
- }),
359
- )
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
360
118
  },
361
- close: () => vite.close(),
362
119
  }
363
120
  }
364
121
 
365
- /** Invalidate a changed file and everything that (transitively) imports it, so
366
- * the next `ssrLoadModule` re-executes them with fresh code. */
367
- function invalidateModuleTree(vite: ViteDevServer, file: string): void {
368
- const seen = new Set<unknown>()
369
- const stack: Array<{ importers: Set<unknown> }> = [
370
- ...((vite.moduleGraph.getModulesByFile(file) ?? []) as Set<never>),
371
- ]
372
- while (stack.length) {
373
- const mod = stack.pop()
374
- if (!mod || seen.has(mod)) continue
375
- seen.add(mod)
376
- vite.moduleGraph.invalidateModule(mod as never)
377
- 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)
378
126
  }
127
+ return withDeprecatedVite(await createNative(config))
379
128
  }
380
129
 
381
- /**
382
- * Walk the SSR module graph from a route's file and return every `.stator` file
383
- * reachable from it (route page + the components it renders). The caller compiles
384
- * each to collect scoped CSS and client-component module scripts.
385
- */
386
- function reachableStatorFiles(vite: ViteDevServer, entryFile: string): string[] {
387
- const seen = new Set<string>()
388
- const statorFiles = new Set<string>()
389
-
390
- const visit = (node: { id: string | null; importedModules: Set<unknown> } | undefined): void => {
391
- if (!node) return
392
- const id = node.id ?? ''
393
- if (seen.has(id)) return
394
- seen.add(id)
395
- const file = id.split('?')[0]!
396
- if (/\.stator$/.test(file)) statorFiles.add(file)
397
- for (const dep of node.importedModules) visit(dep as never)
398
- }
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))
399
134
 
400
- for (const node of vite.moduleGraph.getModulesByFile(entryFile) ?? []) {
401
- visit(node as never)
402
- }
403
- return [...statorFiles]
404
- }
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.
@@ -653,7 +653,7 @@ async function handleGet(
653
653
  }
654
654
  }
655
655
 
656
- function contentTypeFor(path: string): string {
656
+ export function contentTypeFor(path: string): string {
657
657
  const ext = extname(path).toLowerCase()
658
658
  if (ext === '.css') return 'text/css; charset=utf-8'
659
659
  if (ext === '.js' || ext === '.mjs') return 'application/javascript; charset=utf-8'
@@ -661,5 +661,11 @@ function contentTypeFor(path: string): string {
661
661
  if (ext === '.svg') return 'image/svg+xml'
662
662
  if (ext === '.png') return 'image/png'
663
663
  if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
664
+ if (ext === '.webp') return 'image/webp'
665
+ if (ext === '.gif') return 'image/gif'
666
+ if (ext === '.ico') return 'image/x-icon'
667
+ if (ext === '.wasm') return 'application/wasm'
668
+ if (ext === '.woff2') return 'font/woff2'
669
+ if (ext === '.woff') return 'font/woff'
664
670
  return 'application/octet-stream'
665
671
  }
@@ -55,6 +55,7 @@ export { buildHonoApp } from './http.ts'
55
55
  export type { InstanceHandle } from './instance-proxy.ts'
56
56
  export { createInstanceProxy, defForProxy } from './instance-proxy.ts'
57
57
  export { logger, scopedLogger, setLogLevel } from './logger.ts'
58
+ export { codeHashOf, codeInputsOf } from './machine-hash.ts'
58
59
  export { MachineStore } from './machine-store.ts'
59
60
  export type { MiddlewareDefinition } from './middleware.ts'
60
61
  export {