toiljs 0.0.67 → 0.0.68

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.
Files changed (99) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +63 -61
  3. package/build/backend/.tsbuildinfo +1 -1
  4. package/build/cli/.tsbuildinfo +1 -1
  5. package/build/cli/index.js +13 -1
  6. package/build/client/.tsbuildinfo +1 -1
  7. package/build/client/index.d.ts +2 -0
  8. package/build/client/index.js +1 -0
  9. package/build/client/rpc.js +21 -1
  10. package/build/client/stream/client.d.ts +11 -0
  11. package/build/client/stream/client.js +59 -0
  12. package/build/compiler/.tsbuildinfo +1 -1
  13. package/build/compiler/config.d.ts +2 -0
  14. package/build/compiler/config.js +9 -7
  15. package/build/compiler/index.d.ts +1 -0
  16. package/build/compiler/index.js +16 -2
  17. package/build/compiler/toil-docs.generated.js +2 -2
  18. package/build/devserver/.tsbuildinfo +1 -1
  19. package/build/devserver/daemon/runtime.d.ts +13 -0
  20. package/build/devserver/daemon/runtime.js +29 -0
  21. package/build/devserver/db/database.d.ts +1 -0
  22. package/build/devserver/db/database.js +10 -0
  23. package/build/devserver/db/derives.d.ts +7 -0
  24. package/build/devserver/db/derives.js +94 -0
  25. package/build/devserver/db/index.d.ts +1 -0
  26. package/build/devserver/db/index.js +1 -0
  27. package/build/devserver/db/types.d.ts +1 -0
  28. package/build/devserver/db/types.js +1 -0
  29. package/build/devserver/http/proxy.d.ts +5 -1
  30. package/build/devserver/http/proxy.js +39 -36
  31. package/build/devserver/http/runtime.d.ts +62 -0
  32. package/build/devserver/http/runtime.js +194 -0
  33. package/build/devserver/index.d.ts +2 -0
  34. package/build/devserver/index.js +1 -0
  35. package/build/devserver/production-ipc.d.ts +50 -0
  36. package/build/devserver/production-ipc.js +21 -0
  37. package/build/devserver/production-worker.d.ts +1 -0
  38. package/build/devserver/production-worker.js +73 -0
  39. package/build/devserver/production.d.ts +35 -0
  40. package/build/devserver/production.js +502 -0
  41. package/build/devserver/runtime/module.d.ts +5 -0
  42. package/build/devserver/runtime/module.js +47 -1
  43. package/build/devserver/server.d.ts +1 -0
  44. package/build/devserver/server.js +32 -145
  45. package/build/devserver/ssr.d.ts +2 -0
  46. package/build/devserver/ssr.js +19 -2
  47. package/build/devserver/stream/catalog.d.ts +20 -0
  48. package/build/devserver/stream/catalog.js +54 -0
  49. package/build/devserver/stream/host.d.ts +9 -0
  50. package/build/devserver/stream/host.js +15 -0
  51. package/build/devserver/stream/index.d.ts +37 -0
  52. package/build/devserver/stream/index.js +220 -0
  53. package/build/devserver/stream/manager.d.ts +34 -0
  54. package/build/devserver/stream/manager.js +103 -0
  55. package/build/devserver/stream/router.d.ts +25 -0
  56. package/build/devserver/stream/router.js +64 -0
  57. package/build/devserver/stream/wire.d.ts +5 -0
  58. package/build/devserver/stream/wire.js +33 -0
  59. package/build/devserver/stream/ws.d.ts +18 -0
  60. package/build/devserver/stream/ws.js +46 -0
  61. package/docs/cli.md +3 -1
  62. package/docs/getting-started.md +7 -7
  63. package/examples/basic/server/routes/Guestbook.ts +38 -13
  64. package/package.json +2 -2
  65. package/src/cli/index.ts +14 -1
  66. package/src/client/index.ts +2 -0
  67. package/src/client/rpc.ts +25 -1
  68. package/src/client/stream/client.ts +107 -0
  69. package/src/compiler/config.ts +15 -7
  70. package/src/compiler/index.ts +24 -5
  71. package/src/compiler/toil-docs.generated.ts +2 -2
  72. package/src/devserver/daemon/runtime.ts +48 -0
  73. package/src/devserver/db/database.ts +14 -0
  74. package/src/devserver/db/derives.ts +121 -0
  75. package/src/devserver/db/index.ts +1 -0
  76. package/src/devserver/db/types.ts +6 -0
  77. package/src/devserver/http/proxy.ts +53 -39
  78. package/src/devserver/http/runtime.ts +287 -0
  79. package/src/devserver/index.ts +2 -0
  80. package/src/devserver/production-ipc.ts +63 -0
  81. package/src/devserver/production-worker.ts +83 -0
  82. package/src/devserver/production.ts +706 -0
  83. package/src/devserver/runtime/module.ts +95 -1
  84. package/src/devserver/server.ts +52 -201
  85. package/src/devserver/ssr.ts +23 -3
  86. package/src/devserver/stream/catalog.ts +106 -0
  87. package/src/devserver/stream/host.ts +42 -0
  88. package/src/devserver/stream/index.ts +308 -0
  89. package/src/devserver/stream/manager.ts +163 -0
  90. package/src/devserver/stream/router.ts +101 -0
  91. package/src/devserver/stream/wire.ts +58 -0
  92. package/src/devserver/stream/ws.ts +76 -0
  93. package/test/built-ssr.test.ts +98 -0
  94. package/test/devserver.test.ts +20 -4
  95. package/test/example-guestbook.test.ts +8 -5
  96. package/test/fixtures/stream-echo.ts +26 -0
  97. package/test/fixtures/stream-gate.ts +24 -0
  98. package/test/fixtures/stream-trap.ts +18 -0
  99. package/test/stream-emulation.test.ts +394 -0
@@ -13,7 +13,14 @@
13
13
 
14
14
  import fs from 'node:fs';
15
15
 
16
- import { DbFunctionKind, persistDb, setDbCatalog } from '../db/index.js';
16
+ import {
17
+ DbFunctionKind,
18
+ type DeriveEntry,
19
+ derivesForWrites,
20
+ parseDerives,
21
+ persistDb,
22
+ setDbCatalog,
23
+ } from '../db/index.js';
17
24
  import { parseRouteKinds, routeKindForRequest, type RouteKindEntry } from '../db/routeKinds.js';
18
25
  import {
19
26
  decodeResponseEnvelope,
@@ -76,6 +83,13 @@ interface HandleExports {
76
83
  readonly handle: (reqOfs: number, reqLen: number) => bigint;
77
84
  }
78
85
 
86
+ /** A `@database` with `@derive` methods exports `derive_run` (optional: absent
87
+ * when the program declares no derive). See toilscript `injectDeriveHandler`. */
88
+ interface DeriveExports {
89
+ readonly memory: WebAssembly.Memory;
90
+ readonly derive_run?: (deriveId: number) => bigint;
91
+ }
92
+
79
93
  /** Host functions the dev server provides under `env` (see `host.ts`). */
80
94
  const PROVIDED_IMPORTS = new Set([
81
95
  'abort',
@@ -143,6 +157,10 @@ export class WasmServerModule {
143
157
  private module: WebAssembly.Module | null = null;
144
158
  private loadedMtimeMs = -1;
145
159
  private routeKinds: readonly RouteKindEntry[] = [];
160
+ private derives: readonly DeriveEntry[] = [];
161
+ // Set when a (re)compile loaded a module with @derive methods; the first
162
+ // dispatch afterward rebuilds every materialized view from its sources.
163
+ private derivesDirty = false;
146
164
 
147
165
  constructor(private readonly wasmPath: string) {}
148
166
 
@@ -164,6 +182,8 @@ export class WasmServerModule {
164
182
  } catch {
165
183
  this.module = null;
166
184
  this.routeKinds = [];
185
+ this.derives = [];
186
+ this.derivesDirty = false;
167
187
  this.loadedMtimeMs = -1;
168
188
  return false;
169
189
  }
@@ -177,8 +197,13 @@ export class WasmServerModule {
177
197
  // after a @data type evolves + rebuild, old on-disk rows now look out of date.
178
198
  setDbCatalog(bytes);
179
199
  this.routeKinds = parseRouteKinds(bytes);
200
+ this.derives = parseDerives(bytes);
180
201
  this.module = module;
181
202
  this.loadedMtimeMs = mtimeMs;
203
+ // Rebuild materialized views from their sources on the first dispatch
204
+ // (after persistence is configured), so a freshly-loaded box serves
205
+ // up-to-date views even after an out-of-band change to the source data.
206
+ this.derivesDirty = this.derives.length > 0;
182
207
  return true;
183
208
  }
184
209
 
@@ -190,6 +215,10 @@ export class WasmServerModule {
190
215
  dispatch(req: EnvelopeRequest): WasmDispatchResult {
191
216
  if (this.module === null) throw new Error(`server wasm not loaded (${this.wasmPath})`);
192
217
 
218
+ // First dispatch after a (re)load: materialize views from their sources
219
+ // before serving the request, so reads see fresh views.
220
+ this.rebuildDerivedViewsIfStale();
221
+
193
222
  const envelope = encodeRequestEnvelope(req);
194
223
 
195
224
  const ref: MemoryRef = { memory: null };
@@ -232,6 +261,13 @@ export class WasmServerModule {
232
261
 
233
262
  const unhandled = headers.some(([n]) => n.toLowerCase() === UNHANDLED_HEADER);
234
263
 
264
+ // Materialize: re-run any @derive whose @database had a source
265
+ // collection written this request, under FunctionKind=Derive, so its
266
+ // view.publish lands BEFORE the single persistDb() below flushes both the
267
+ // request's writes and the derive's view to disk. A read writes nothing,
268
+ // so this is a no-op for GETs.
269
+ this.runAffectedDerives(state.db.writtenCollections);
270
+
235
271
  // Flush any DB writes this request made to disk, so dev data survives a
236
272
  // restart (and a crash never loses an already-served write).
237
273
  persistDb();
@@ -284,6 +320,64 @@ export class WasmServerModule {
284
320
  return new Uint8Array(exports.memory.buffer, ptr, len).slice();
285
321
  }
286
322
 
323
+ /**
324
+ * After a mutating dispatch, re-run each `@derive` whose `@database` had a
325
+ * source collection written, on a fresh instance under FunctionKind=Derive.
326
+ * Each derive recomputes + `view.publish`es its materialized view (a later
327
+ * Query route reads it via the non-scan `view.get`). A trapped derive is
328
+ * logged and skipped: the request has already succeeded and the next write
329
+ * re-derives, so a stale view is the worst case. Mirrors the edge runner,
330
+ * which folds events into the view off the request path; in single-process
331
+ * dev, running it synchronously-after-the-write is observably equivalent.
332
+ */
333
+ private runAffectedDerives(written: ReadonlySet<string>): void {
334
+ if (this.module === null) return;
335
+ for (const derive of derivesForWrites(this.derives, written)) {
336
+ try {
337
+ this.runDerive(derive.deriveId);
338
+ } catch (err) {
339
+ console.error(`[toil] derive ${derive.dbName}#${derive.methodName} failed:`, err);
340
+ }
341
+ }
342
+ }
343
+
344
+ /**
345
+ * On the first dispatch after a (re)compile, rebuild every materialized view
346
+ * from its sources (server start, hot-reload, or an out-of-band change to the
347
+ * persisted source data). Runs once per load; ongoing writes are materialized
348
+ * incrementally by {@link runAffectedDerives}. Mirrors the edge rebuilding a
349
+ * view from its event log when a box first comes up.
350
+ */
351
+ private rebuildDerivedViewsIfStale(): void {
352
+ if (!this.derivesDirty) return;
353
+ this.derivesDirty = false;
354
+ for (const derive of this.derives) {
355
+ try {
356
+ this.runDerive(derive.deriveId);
357
+ } catch (err) {
358
+ console.error(
359
+ `[toil] derive ${derive.dbName}#${derive.methodName} failed on load:`,
360
+ err,
361
+ );
362
+ }
363
+ }
364
+ }
365
+
366
+ /** One derive invocation: a fresh instance under Derive kind, calling the
367
+ * synthesized `derive_run(derive_id)` export (writes flow to the shared dev
368
+ * store via the `data.*` imports; the caller's persistDb() flushes them). */
369
+ private runDerive(deriveId: number): void {
370
+ if (this.module === null) return;
371
+ const ref: MemoryRef = { memory: null };
372
+ const state = freshDispatchState();
373
+ state.db.functionKind = DbFunctionKind.Derive;
374
+ const instance = new WebAssembly.Instance(this.module, buildHostImports(ref, state));
375
+ const exports = instance.exports as unknown as DeriveExports;
376
+ ref.memory = exports.memory;
377
+ if (typeof exports.derive_run !== 'function') return;
378
+ exports.derive_run(deriveId);
379
+ }
380
+
287
381
  /** Fail instantiation up front, with names, when the guest needs imports we do not provide. */
288
382
  private assertImportSurface(module: WebAssembly.Module): void {
289
383
  const missing = WebAssembly.Module.imports(module)
@@ -19,31 +19,30 @@
19
19
  * surface, trap isolation) is identical so a server that runs here runs there.
20
20
  */
21
21
 
22
- import fs from 'node:fs';
23
22
  import path from 'node:path';
24
23
 
25
- import { type Request, type Response, Server } from '@dacely/hyper-express';
24
+ import { Server } from '@dacely/hyper-express';
26
25
  import pc from 'picocolors';
27
26
 
28
27
  import type { EmailBackendConfig } from 'toiljs/shared';
29
28
 
30
- import { DaemonHost, daemonEmulationEnabled } from './daemon/index.js';
31
29
  import type { ResolvedDaemonConfig } from './daemon/host.js';
30
+ import { startDaemonRuntime } from './daemon/runtime.js';
32
31
  import { configureDbPersistence } from './db/index.js';
33
32
  import { initEmailService } from './email/index.js';
34
- import { applyCacheRule, lookupCache } from './http/cache.js';
35
- import { type EnvelopeRequest, METHOD_CODES } from './http/envelope.js';
36
33
  import { proxyToVite, type ViteTarget, wireWebsocketProxy } from './http/proxy.js';
37
- import { WasmServerModule } from './runtime/module.js';
38
34
  import {
39
- assembleSsr,
40
- buildSsrRoutes,
41
- type DevSsrTemplate,
42
- pathnameOf,
43
- type SsrResult,
44
- } from './ssr.js';
45
-
46
- const DEFAULT_MAX_BODY_LENGTH = 1024 * 1024 * 8;
35
+ assembleRouteSsr,
36
+ dispatchWasmRequest,
37
+ installRuntimeErrorHandler,
38
+ isDispatchableMethod,
39
+ runtimeServerOptions,
40
+ sendSsr,
41
+ } from './http/runtime.js';
42
+ import { WasmServerModule } from './runtime/module.js';
43
+ import { StreamRouter } from './stream/router.js';
44
+ import { streamEmulationEnabled, wireStreams } from './stream/wire.js';
45
+ import { buildSsrRoutes, type DevSsrTemplate, pathnameOf } from './ssr.js';
47
46
 
48
47
  /**
49
48
  * Paths that are Vite's own by construction; skipping the wasm round-trip for
@@ -52,26 +51,6 @@ const DEFAULT_MAX_BODY_LENGTH = 1024 * 1024 * 8;
52
51
  */
53
52
  const VITE_PREFIXES = ['/@', '/node_modules/', '/__toil/'];
54
53
 
55
- /** Minimal type map for `respond_file` bodies when the guest set no content-type. */
56
- const MIME: Readonly<Record<string, string>> = {
57
- '.html': 'text/html; charset=utf-8',
58
- '.js': 'text/javascript; charset=utf-8',
59
- '.mjs': 'text/javascript; charset=utf-8',
60
- '.css': 'text/css; charset=utf-8',
61
- '.json': 'application/json; charset=utf-8',
62
- '.txt': 'text/plain; charset=utf-8',
63
- '.svg': 'image/svg+xml',
64
- '.png': 'image/png',
65
- '.jpg': 'image/jpeg',
66
- '.jpeg': 'image/jpeg',
67
- '.webp': 'image/webp',
68
- '.avif': 'image/avif',
69
- '.gif': 'image/gif',
70
- '.ico': 'image/x-icon',
71
- '.wasm': 'application/wasm',
72
- '.woff2': 'font/woff2',
73
- };
74
-
75
54
  /** Options for {@link startDevServer}. */
76
55
  export interface DevServerOptions {
77
56
  /** Project root; `respond_file` paths resolve against it (and may not escape it). */
@@ -88,7 +67,13 @@ export interface DevServerOptions {
88
67
  * (per `nodeMode`). Omit for a project with no `@daemon` (the file is never built).
89
68
  */
90
69
  readonly coldWasmFile?: string;
91
- /** Which layer the dev process emulates (gates the daemon emulator). Default `all`. */
70
+ /**
71
+ * Absolute path to the stream artifact (`release-stream.wasm`). When present and this dev process
72
+ * serves streams (`nodeMode` regional/continental/all), the dev stream router (doc 08 4.1) serves
73
+ * `@stream`-route WebSocket upgrades. Omit for a project with no `@stream` (the file is never built).
74
+ */
75
+ readonly streamWasmFile?: string;
76
+ /** Which layer the dev process emulates (gates the daemon + stream emulators). Default `all`. */
92
77
  readonly nodeMode?: string;
93
78
  /** Daemon (L4) config mirror (drives the dev scheduler's budgets/caps). */
94
79
  readonly daemon?: ResolvedDaemonConfig;
@@ -124,92 +109,6 @@ function isViteInternal(url: string): boolean {
124
109
  return VITE_PREFIXES.some((p) => url.startsWith(p));
125
110
  }
126
111
 
127
- /** Resolves a guest `respond_file` path inside `root`, refusing traversal outside it. */
128
- function resolveSendfile(root: string, file: string): string | null {
129
- const resolved = path.resolve(root, file);
130
- if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
131
- if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) return null;
132
- return resolved;
133
- }
134
-
135
- /** Builds the envelope request for one incoming HTTP request. */
136
- async function toEnvelopeRequest(request: Request): Promise<EnvelopeRequest> {
137
- const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
138
- const body = hasBody ? new Uint8Array(await request.buffer()) : new Uint8Array(0);
139
- // Dev parity for `client_ip`: the edge keys on the unspoofable socket peer,
140
- // but the dev server has no DPDK socket, so best-effort from a proxy's
141
- // `x-forwarded-for`, else localhost, so `ctx.clientIp()` returns a value.
142
- const xff = request.headers['x-forwarded-for'];
143
- const clientIp =
144
- typeof xff === 'string' && xff.length > 0 ? xff.split(',')[0]!.trim() : '127.0.0.1';
145
- return {
146
- method: request.method,
147
- // `url` keeps the query string; the guest's RouteContext parses it off the path.
148
- path: request.url,
149
- headers: Object.entries(request.headers),
150
- body,
151
- clientIp,
152
- };
153
- }
154
-
155
- /** Sends a shaped wasm response, mirroring the edge's response defaults. */
156
- function sendWasmResponse(
157
- response: Response,
158
- root: string,
159
- result: {
160
- status: number;
161
- headers: readonly (readonly [string, string])[];
162
- body: Uint8Array;
163
- sendfile: string | null;
164
- },
165
- ): void {
166
- response.status(result.status);
167
- let hasContentType = false;
168
- for (const [name, value] of result.headers) {
169
- if (name.toLowerCase() === 'content-type') hasContentType = true;
170
- response.header(name, value);
171
- }
172
- response.header('server', 'toil-dev');
173
-
174
- if (result.sendfile !== null) {
175
- const file = resolveSendfile(root, result.sendfile);
176
- if (file === null) {
177
- response.status(404).send('not found\n');
178
- return;
179
- }
180
- if (!hasContentType) {
181
- // The edge defaults file bodies to application/octet-stream; in dev we
182
- // guess from the extension so a guest-served asset renders in the browser.
183
- response.header(
184
- 'content-type',
185
- MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
186
- );
187
- }
188
- response.sendFile(file);
189
- return;
190
- }
191
-
192
- if (!hasContentType) response.header('content-type', 'text/plain; charset=utf-8');
193
- response.send(Buffer.from(result.body.buffer, result.body.byteOffset, result.body.length));
194
- }
195
-
196
- /** Sends a spliced edge-SSR response (the full server-rendered HTML document). */
197
- function sendSsr(response: Response, out: SsrResult, headOnly: boolean): void {
198
- response.status(out.status);
199
- let hasContentType = false;
200
- for (const [name, value] of out.headers) {
201
- if (name.toLowerCase() === 'content-type') hasContentType = true;
202
- response.header(name, value);
203
- }
204
- if (!hasContentType) response.header('content-type', 'text/html; charset=utf-8');
205
- response.header('server', 'toil-dev');
206
- if (headOnly) {
207
- response.send('');
208
- return;
209
- }
210
- response.send(Buffer.from(out.html.buffer, out.html.byteOffset, out.html.length));
211
- }
212
-
213
112
  /**
214
113
  * Starts the front server. The caller owns the Vite dev server (start it on a
215
114
  * loopback port first) and the toilscript rebuild watcher; this watches only
@@ -269,84 +168,43 @@ export async function startDevServer(options: DevServerOptions): Promise<Running
269
168
  };
270
169
  refresh();
271
170
 
272
- const app = new Server({
273
- max_body_length: options.maxBodyLength ?? DEFAULT_MAX_BODY_LENGTH,
274
- max_body_buffer: 1024 * 32,
275
- fast_abort: true,
276
- });
277
-
278
- app.set_error_handler((_request: Request, response: Response, error: Error) => {
279
- if (response.completed) return;
280
- response.atomic(() => {
281
- response.status(500).send(`internal error: ${error.message}\n`);
282
- });
283
- });
284
-
285
- wireWebsocketProxy(app, options.vite);
171
+ const app = new Server(runtimeServerOptions(options));
172
+ installRuntimeErrorHandler(app);
286
173
 
287
- // Dev DAEMON (L4) emulation: load `release-cold.wasm` once, run `daemon_start()`, and drive
288
- // its `@scheduled` tasks. Only when `nodeMode` is daemon/all and a cold artifact path is given;
289
- // the host stays idle until the cold artifact appears (a `@daemon` build). It has no request to
290
- // hang a refresh off, so it polls its own mtime-watch on a low-frequency timer (section 9.3).
291
174
  const nodeMode = options.nodeMode ?? 'all';
292
- let daemonHost: DaemonHost | null = null;
293
- let daemonTimer: NodeJS.Timeout | null = null;
294
- if (options.coldWasmFile !== undefined && daemonEmulationEnabled(nodeMode) && options.daemon) {
295
- daemonHost = new DaemonHost(options.coldWasmFile, options.daemon, nodeMode);
296
- const pollDaemon = (): void => {
297
- try {
298
- daemonHost?.refresh();
299
- } catch (e) {
300
- process.stdout.write(pc.red(` ✗ daemon reload failed: ${String(e)}`) + '\n');
301
- }
302
- };
303
- pollDaemon();
304
- daemonTimer = setInterval(pollDaemon, 500);
305
- daemonTimer.unref?.();
175
+
176
+ // Dev STREAM (L2/L3) emulation (doc 08 4.1): when a stream artifact is built AND this dev process
177
+ // serves streams, route `@stream`-route WebSocket upgrades to the resident-box stream router and
178
+ // proxy everything else (Vite HMR) upstream; otherwise the plain Vite proxy (existing behaviour).
179
+ if (options.streamWasmFile !== undefined && streamEmulationEnabled(nodeMode)) {
180
+ wireStreams(app, options.vite, new StreamRouter(options.streamWasmFile));
181
+ } else {
182
+ wireWebsocketProxy(app, options.vite);
306
183
  }
307
184
 
308
- app.any('/*', async (request: Request, response: Response) => {
185
+ const daemon = startDaemonRuntime({
186
+ coldWasmFile: options.coldWasmFile,
187
+ nodeMode,
188
+ daemon: options.daemon,
189
+ });
190
+
191
+ app.any('/*', async (request, response) => {
309
192
  response.removeHeader('uWebSockets');
310
193
 
311
- const dispatchable =
312
- !isViteInternal(request.url) && METHOD_CODES[request.method] !== undefined;
194
+ const dispatchable = !isViteInternal(request.url) && isDispatchableMethod(request.method);
313
195
  if (dispatchable) refresh();
314
196
 
315
197
  if (dispatchable && module.available) {
316
- const envelopeReq = await toEnvelopeRequest(request);
317
- // Honor the tenant cache directive locally, same rules as the
318
- // edge: serve an identical request from the per-process cache,
319
- // else dispatch and apply/strip the directive on the response.
320
- const cacheHost = request.headers.host ?? 'dev';
321
- const hasAuth =
322
- request.headers.cookie !== undefined || request.headers.authorization !== undefined;
323
- const cached = lookupCache(cacheHost, request.method, request.url, envelopeReq.body);
324
- if (cached !== null) {
325
- sendWasmResponse(response, root, cached);
326
- return;
327
- }
328
- try {
329
- const result = module.dispatch(envelopeReq);
330
- if (!result.unhandled) {
331
- const finalized = applyCacheRule(
332
- cacheHost,
333
- request.method,
334
- request.url,
335
- envelopeReq.body,
336
- hasAuth,
337
- result,
338
- );
339
- sendWasmResponse(response, root, finalized);
340
- return;
341
- }
342
- } catch (e) {
343
- // A trap (ToilScript abort, OOB, malformed envelope) is isolated to
344
- // this request, exactly like the edge poisoning one instance.
345
- process.stdout.write(
346
- pc.red(` ✗ ${request.method} ${request.path} server error: ${String(e)}`) +
347
- '\n',
348
- );
349
- response.status(500).send('internal error\n');
198
+ const dispatch = await dispatchWasmRequest({
199
+ module,
200
+ request,
201
+ response,
202
+ root,
203
+ cacheHost: request.headers.host ?? 'dev',
204
+ serverHeader: 'toil-dev',
205
+ errorPrefix: '✗',
206
+ });
207
+ if (dispatch.handled) {
350
208
  return;
351
209
  }
352
210
 
@@ -355,10 +213,7 @@ export async function startDevServer(options: DevServerOptions): Promise<Running
355
213
  // serve the server-rendered HTML. A fail-safe envelope (no renderer
356
214
  // matched / malformed) returns null, so we fall through to Vite (the
357
215
  // route then client-renders, same as before).
358
- if (
359
- (request.method === 'GET' || request.method === 'HEAD') &&
360
- ssrRoutes.length > 0
361
- ) {
216
+ if ((request.method === 'GET' || request.method === 'HEAD') && ssrRoutes.length > 0) {
362
217
  const route = ssrRoutes.find((r) => r.test(pathnameOf(request.url)));
363
218
  if (route) {
364
219
  try {
@@ -366,12 +221,9 @@ export async function startDevServer(options: DevServerOptions): Promise<Running
366
221
  // render: serve the prerendered template directly so it paints instantly
367
222
  // instead of falling through to a (blank-until-JS) client render. Dynamic
368
223
  // routes run the guest `render` and splice its values in.
369
- const out: SsrResult | null =
370
- route.entries.length === 0
371
- ? { status: 200, headers: [], html: route.tmpl }
372
- : assembleSsr(route, module.dispatchRender(envelopeReq));
224
+ const out = assembleRouteSsr(route, module, dispatch.envelopeReq);
373
225
  if (out !== null) {
374
- sendSsr(response, out, request.method === 'HEAD');
226
+ sendSsr(response, out, request.method === 'HEAD', 'toil-dev');
375
227
  return;
376
228
  }
377
229
  } catch (e) {
@@ -392,8 +244,7 @@ export async function startDevServer(options: DevServerOptions): Promise<Running
392
244
  port: options.port,
393
245
  host,
394
246
  close: async (): Promise<void> => {
395
- if (daemonTimer !== null) clearInterval(daemonTimer);
396
- daemonHost?.close();
247
+ daemon?.close();
397
248
  await app.shutdown();
398
249
  },
399
250
  };
@@ -20,6 +20,8 @@ export interface DevSsrTemplate {
20
20
  name: string;
21
21
  tmpl: Uint8Array;
22
22
  entries: { id: number; offset: number }[];
23
+ /** Optional deployed template hash. Present for built/self-host SSR, omitted in dev. */
24
+ hash?: Uint8Array;
23
25
  }
24
26
 
25
27
  /** A matchable SSR route. */
@@ -28,6 +30,8 @@ export interface SsrRoute {
28
30
  test: (pathname: string) => boolean;
29
31
  tmpl: Uint8Array;
30
32
  entries: { id: number; offset: number }[];
33
+ /** Optional deployed template hash. When present, guest values must match it. */
34
+ hash?: Uint8Array;
31
35
  }
32
36
 
33
37
  /** The pathname of a request URL (strip the query string). */
@@ -71,12 +75,18 @@ function patternToTest(pattern: string): (pathname: string) => boolean {
71
75
 
72
76
  /** Build matchable SSR routes from the extracted dev templates. */
73
77
  export function buildSsrRoutes(templates: readonly DevSsrTemplate[]): SsrRoute[] {
74
- return templates.map((t) => ({ test: patternToTest(t.pattern), tmpl: t.tmpl, entries: t.entries }));
78
+ return templates.map((t) => ({
79
+ test: patternToTest(t.pattern),
80
+ tmpl: t.tmpl,
81
+ entries: t.entries,
82
+ hash: t.hash,
83
+ }));
75
84
  }
76
85
 
77
86
  /** A decoded guest values envelope. */
78
87
  interface DecodedValues {
79
88
  status: number;
89
+ hash: Uint8Array;
80
90
  headers: [string, string][];
81
91
  /** Slot value bytes keyed by numeric slot id. */
82
92
  values: Map<number, Uint8Array>;
@@ -92,7 +102,8 @@ function decodeValues(buf: Uint8Array): DecodedValues | null {
92
102
  if (!need(2 + 32 + 2)) return null;
93
103
  const status = dv.getUint16(o, true);
94
104
  o += 2;
95
- o += 32; // template hash (coherence is built together in dev; not checked)
105
+ const hash = buf.subarray(o, o + 32);
106
+ o += 32;
96
107
  const nHeaders = dv.getUint16(o, true);
97
108
  o += 2;
98
109
  const headers: [string, string][] = [];
@@ -124,12 +135,20 @@ function decodeValues(buf: Uint8Array): DecodedValues | null {
124
135
  values.set(id, buf.subarray(o, o + len));
125
136
  o += len;
126
137
  }
127
- return { status, headers, values };
138
+ return { status, hash, headers, values };
128
139
  } catch {
129
140
  return null;
130
141
  }
131
142
  }
132
143
 
144
+ function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
145
+ if (a.byteLength !== b.byteLength) return false;
146
+ for (let i = 0; i < a.byteLength; i++) {
147
+ if (a[i] !== b[i]) return false;
148
+ }
149
+ return true;
150
+ }
151
+
133
152
  /** Splice ascending-offset inserts into the template (mirrors the host `assemble`). */
134
153
  function splice(tmpl: Uint8Array, inserts: { offset: number; value: Uint8Array }[]): Uint8Array {
135
154
  const parts: Uint8Array[] = [];
@@ -159,6 +178,7 @@ export function assembleSsr(route: SsrRoute, envelope: Uint8Array): SsrResult |
159
178
  const decoded = decodeValues(envelope);
160
179
  if (decoded === null) return null;
161
180
  if (decoded.status >= 500 || decoded.values.size === 0) return null;
181
+ if (route.hash !== undefined && !sameBytes(decoded.hash, route.hash)) return null;
162
182
  const inserts = route.entries
163
183
  .map((e) => ({ offset: e.offset, value: decoded.values.get(e.id) ?? new Uint8Array(0) }))
164
184
  .sort((a, b) => a.offset - b.offset);
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Parse a compiled HOT server wasm's `toilstream.catalog` custom section into the dev stream router's
3
+ * route table (doc 08 section 3.1; emitted by the toilscript hot pass per RECONCILIATION Part 5 when
4
+ * any `@stream` class exists). The dev router keys on `route` (the `@stream` class's fixed mount path)
5
+ * to intercept matching WebSocket upgrades (section 4.1/4.2) and ignore the rest (Vite HMR).
6
+ *
7
+ * Byte layout (Part 5, all little-endian; mirrors the toilscript emitter + the edge decoder):
8
+ *
9
+ * u16 format_version (= 1)
10
+ * u16 n_streams
11
+ * per stream:
12
+ * str name (the @stream class name)
13
+ * str route (the mount path, e.g. "/echo")
14
+ * u8 hook_presence_bitmask (bit0 connect, bit1 message, bit2 close, bit3 disconnect)
15
+ * u8 declared_scope (0 = L2 regional, 1 = L3 continental)
16
+ * u8 message_mode (0 = raw bytes default, 1 = @data-typed)
17
+ * u32 max_frame_bytes
18
+ * u32 ingress_ring_bytes
19
+ * u32 message_value_data_id (0 when message_mode = 0)
20
+ * u32 message_schema_version (0 when message_mode = 0)
21
+ * u16 stream_index
22
+ *
23
+ * Fails closed via `DataReader.ok`: a short read mid-record stops the loop and yields the cleanly
24
+ * decoded prefix; an absent/unparseable section yields an empty map (the dev router serves no stream).
25
+ */
26
+
27
+ import { DataReader } from 'toiljs/io';
28
+
29
+ import { customSection } from '../wasm/sections.js';
30
+
31
+ /** One `@stream` class's catalog entry (doc 08 section 3.1). */
32
+ export interface StreamDef {
33
+ readonly name: string;
34
+ readonly route: string;
35
+ readonly hooks: {
36
+ readonly connect: boolean;
37
+ readonly message: boolean;
38
+ readonly close: boolean;
39
+ readonly disconnect: boolean;
40
+ };
41
+ readonly scope: 'regional' | 'continental';
42
+ readonly messageMode: 'raw' | 'data';
43
+ readonly maxFrameBytes: number;
44
+ readonly ingressRingBytes: number;
45
+ readonly messageValueDataId: number;
46
+ readonly messageSchemaVersion: number;
47
+ readonly streamIndex: number;
48
+ }
49
+
50
+ /** The dev router's route table, keyed by the `@stream` mount path. */
51
+ export type StreamCatalog = Map<string, StreamDef>;
52
+
53
+ /** Parse `toilstream.catalog` into a route table. Absent/unparseable -> empty (serve no stream). */
54
+ export function parseStreamCatalog(wasm: Buffer): StreamCatalog {
55
+ const out: StreamCatalog = new Map();
56
+ let sec: Buffer | null;
57
+ try {
58
+ sec = customSection(wasm, 'toilstream.catalog');
59
+ } catch {
60
+ return out;
61
+ }
62
+ if (sec === null) return out;
63
+ const r = new DataReader(sec);
64
+ r.readU16(); // format_version
65
+ const n = r.readU16();
66
+ for (let i = 0; i < n && r.ok; i++) {
67
+ const name = r.readString();
68
+ const route = r.readString();
69
+ const bits = r.readU8();
70
+ const scope = r.readU8() === 1 ? 'continental' : 'regional';
71
+ const messageMode = r.readU8() === 1 ? 'data' : 'raw';
72
+ const maxFrameBytes = r.readU32();
73
+ const ingressRingBytes = r.readU32();
74
+ const messageValueDataId = r.readU32();
75
+ const messageSchemaVersion = r.readU32();
76
+ const streamIndex = r.readU16();
77
+ if (!r.ok) break;
78
+ out.set(route, {
79
+ name,
80
+ route,
81
+ hooks: {
82
+ connect: (bits & 1) !== 0,
83
+ message: (bits & 2) !== 0,
84
+ close: (bits & 4) !== 0,
85
+ disconnect: (bits & 8) !== 0,
86
+ },
87
+ scope,
88
+ messageMode,
89
+ maxFrameBytes,
90
+ ingressRingBytes,
91
+ messageValueDataId,
92
+ messageSchemaVersion,
93
+ streamIndex,
94
+ });
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** Exact-path route match (doc 08 section 4.2): strip the query string, then look the path up in the
100
+ * catalog. Returns the matched `@stream` def, or `null` when the path is not a stream route (the dev
101
+ * router then proxies the upgrade to Vite). */
102
+ export function matchStreamRoute(catalog: StreamCatalog, path: string): StreamDef | null {
103
+ const q = path.indexOf('?');
104
+ const exact = q >= 0 ? path.slice(0, q) : path;
105
+ return catalog.get(exact) ?? null;
106
+ }