toiljs 0.0.67 → 0.0.69
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/CHANGELOG.md +10 -0
- package/README.md +63 -61
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +13 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/index.d.ts +2 -0
- package/build/client/index.js +1 -0
- package/build/client/rpc.js +21 -1
- package/build/client/stream/client.d.ts +11 -0
- package/build/client/stream/client.js +59 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +2 -0
- package/build/compiler/config.js +9 -7
- package/build/compiler/index.d.ts +1 -0
- package/build/compiler/index.js +16 -2
- package/build/compiler/toil-docs.generated.js +5 -4
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/daemon/runtime.d.ts +13 -0
- package/build/devserver/daemon/runtime.js +29 -0
- package/build/devserver/db/database.d.ts +1 -0
- package/build/devserver/db/database.js +10 -0
- package/build/devserver/db/derives.d.ts +7 -0
- package/build/devserver/db/derives.js +94 -0
- package/build/devserver/db/index.d.ts +1 -0
- package/build/devserver/db/index.js +1 -0
- package/build/devserver/db/types.d.ts +1 -0
- package/build/devserver/db/types.js +1 -0
- package/build/devserver/http/proxy.d.ts +5 -1
- package/build/devserver/http/proxy.js +39 -36
- package/build/devserver/http/runtime.d.ts +62 -0
- package/build/devserver/http/runtime.js +194 -0
- package/build/devserver/index.d.ts +2 -0
- package/build/devserver/index.js +1 -0
- package/build/devserver/production-ipc.d.ts +50 -0
- package/build/devserver/production-ipc.js +21 -0
- package/build/devserver/production-worker.d.ts +1 -0
- package/build/devserver/production-worker.js +73 -0
- package/build/devserver/production.d.ts +35 -0
- package/build/devserver/production.js +502 -0
- package/build/devserver/runtime/module.d.ts +5 -0
- package/build/devserver/runtime/module.js +47 -1
- package/build/devserver/server.d.ts +1 -0
- package/build/devserver/server.js +32 -145
- package/build/devserver/ssr.d.ts +2 -0
- package/build/devserver/ssr.js +19 -2
- package/build/devserver/stream/catalog.d.ts +20 -0
- package/build/devserver/stream/catalog.js +54 -0
- package/build/devserver/stream/host.d.ts +9 -0
- package/build/devserver/stream/host.js +15 -0
- package/build/devserver/stream/index.d.ts +37 -0
- package/build/devserver/stream/index.js +220 -0
- package/build/devserver/stream/manager.d.ts +34 -0
- package/build/devserver/stream/manager.js +103 -0
- package/build/devserver/stream/router.d.ts +25 -0
- package/build/devserver/stream/router.js +64 -0
- package/build/devserver/stream/wire.d.ts +5 -0
- package/build/devserver/stream/wire.js +33 -0
- package/build/devserver/stream/ws.d.ts +18 -0
- package/build/devserver/stream/ws.js +46 -0
- package/docs/cli.md +3 -1
- package/docs/derive.md +159 -0
- package/docs/getting-started.md +7 -7
- package/docs/index.md +1 -1
- package/docs/streams.md +46 -14
- package/examples/basic/server/routes/Guestbook.ts +38 -13
- package/package.json +2 -2
- package/src/cli/index.ts +14 -1
- package/src/client/index.ts +2 -0
- package/src/client/rpc.ts +25 -1
- package/src/client/stream/client.ts +109 -0
- package/src/compiler/config.ts +15 -7
- package/src/compiler/index.ts +24 -5
- package/src/compiler/toil-docs.generated.ts +5 -4
- package/src/devserver/daemon/runtime.ts +48 -0
- package/src/devserver/db/database.ts +14 -0
- package/src/devserver/db/derives.ts +121 -0
- package/src/devserver/db/index.ts +1 -0
- package/src/devserver/db/types.ts +6 -0
- package/src/devserver/http/proxy.ts +53 -39
- package/src/devserver/http/runtime.ts +287 -0
- package/src/devserver/index.ts +2 -0
- package/src/devserver/production-ipc.ts +63 -0
- package/src/devserver/production-worker.ts +83 -0
- package/src/devserver/production.ts +706 -0
- package/src/devserver/runtime/module.ts +95 -1
- package/src/devserver/server.ts +52 -201
- package/src/devserver/ssr.ts +23 -3
- package/src/devserver/stream/catalog.ts +106 -0
- package/src/devserver/stream/host.ts +42 -0
- package/src/devserver/stream/index.ts +308 -0
- package/src/devserver/stream/manager.ts +163 -0
- package/src/devserver/stream/router.ts +101 -0
- package/src/devserver/stream/wire.ts +58 -0
- package/src/devserver/stream/ws.ts +76 -0
- package/test/built-ssr.test.ts +98 -0
- package/test/devserver.test.ts +20 -4
- package/test/example-guestbook.test.ts +8 -5
- package/test/fixtures/stream-echo.ts +26 -0
- package/test/fixtures/stream-gate.ts +24 -0
- package/test/fixtures/stream-trap.ts +18 -0
- package/test/fixtures/stream-typed.ts +41 -0
- package/test/stream-emulation.test.ts +433 -0
|
@@ -13,7 +13,14 @@
|
|
|
13
13
|
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
|
|
16
|
-
import {
|
|
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)
|
package/src/devserver/server.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
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
|
-
|
|
396
|
-
daemonHost?.close();
|
|
247
|
+
daemon?.close();
|
|
397
248
|
await app.shutdown();
|
|
398
249
|
},
|
|
399
250
|
};
|
package/src/devserver/ssr.ts
CHANGED
|
@@ -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) => ({
|
|
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
|
-
|
|
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
|
+
}
|