toiljs 0.0.59 → 0.0.61

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 (158) hide show
  1. package/.github/workflows/ci.yml +31 -0
  2. package/CHANGELOG.md +15 -0
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +311 -118
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/index.d.ts +1 -1
  7. package/build/client/index.js +1 -1
  8. package/build/client/routing/mount.js +12 -1
  9. package/build/client/ssr/markers.d.ts +1 -0
  10. package/build/client/ssr/markers.js +3 -0
  11. package/build/compiler/.tsbuildinfo +1 -1
  12. package/build/compiler/config.d.ts +21 -0
  13. package/build/compiler/config.js +35 -0
  14. package/build/compiler/docs.d.ts +2 -1
  15. package/build/compiler/docs.js +33 -304
  16. package/build/compiler/index.d.ts +13 -0
  17. package/build/compiler/index.js +113 -21
  18. package/build/compiler/template-build.d.ts +21 -1
  19. package/build/compiler/template-build.js +110 -26
  20. package/build/compiler/toil-docs.generated.d.ts +1 -0
  21. package/build/compiler/toil-docs.generated.js +20 -0
  22. package/build/devserver/.tsbuildinfo +1 -1
  23. package/build/devserver/daemon/catalog.d.ts +26 -0
  24. package/build/devserver/daemon/catalog.js +48 -0
  25. package/build/devserver/daemon/cron.d.ts +4 -0
  26. package/build/devserver/daemon/cron.js +50 -0
  27. package/build/devserver/daemon/host.d.ts +37 -0
  28. package/build/devserver/daemon/host.js +94 -0
  29. package/build/devserver/daemon/index.d.ts +34 -0
  30. package/build/devserver/daemon/index.js +241 -0
  31. package/build/devserver/db/catalog.d.ts +2 -0
  32. package/build/devserver/db/catalog.js +80 -0
  33. package/build/devserver/db/database.d.ts +80 -0
  34. package/build/devserver/db/database.js +1032 -0
  35. package/build/devserver/db/index.d.ts +3 -0
  36. package/build/devserver/db/index.js +3 -0
  37. package/build/devserver/db/routeKinds.d.ts +8 -0
  38. package/build/devserver/db/routeKinds.js +139 -0
  39. package/build/devserver/db/types.d.ts +121 -0
  40. package/build/devserver/db/types.js +52 -0
  41. package/build/devserver/email/index.js +1 -1
  42. package/build/devserver/index.d.ts +19 -24
  43. package/build/devserver/index.js +11 -165
  44. package/build/devserver/mstore/store.d.ts +18 -0
  45. package/build/devserver/mstore/store.js +82 -0
  46. package/build/devserver/{host.d.ts → runtime/host.d.ts} +7 -1
  47. package/build/devserver/{host.js → runtime/host.js} +51 -7
  48. package/build/devserver/{module.d.ts → runtime/module.d.ts} +2 -1
  49. package/build/devserver/{module.js → runtime/module.js} +34 -1
  50. package/build/devserver/server.d.ts +23 -0
  51. package/build/devserver/server.js +223 -0
  52. package/build/devserver/ssr.d.ts +25 -0
  53. package/build/devserver/ssr.js +114 -0
  54. package/build/devserver/wasm/sections.d.ts +2 -0
  55. package/build/devserver/wasm/sections.js +42 -0
  56. package/build/devserver/wasm/surface.d.ts +18 -0
  57. package/build/devserver/wasm/surface.js +41 -0
  58. package/docs/README.md +4 -4
  59. package/docs/auth-todo.md +6 -6
  60. package/docs/caching.md +5 -5
  61. package/docs/cli.md +15 -0
  62. package/docs/client.md +40 -0
  63. package/docs/crypto.md +4 -4
  64. package/docs/data.md +6 -6
  65. package/docs/email.md +28 -28
  66. package/docs/environment.md +10 -10
  67. package/docs/index.md +26 -0
  68. package/docs/ratelimit.md +10 -10
  69. package/docs/routing.md +2 -2
  70. package/docs/server.md +61 -0
  71. package/docs/ssr.md +561 -113
  72. package/docs/styling.md +22 -0
  73. package/docs/time.md +3 -3
  74. package/eslint.config.js +10 -1
  75. package/examples/basic/client/components/Header.tsx +3 -0
  76. package/examples/basic/client/routes/features/actions.tsx +0 -2
  77. package/examples/basic/client/routes/hello.tsx +89 -19
  78. package/examples/basic/client/styles/main.css +48 -0
  79. package/examples/basic/server/SsrHelloRender.ts +97 -0
  80. package/examples/basic/server/main.ts +5 -0
  81. package/examples/basic/server/migrations/GuestEntry.migration.ts +39 -0
  82. package/examples/basic/server/streams/Echo.ts +49 -0
  83. package/package.json +12 -10
  84. package/scripts/gen-toil-docs.mjs +96 -0
  85. package/server/runtime/time.ts +3 -3
  86. package/src/cli/create.ts +40 -3
  87. package/src/cli/db.ts +158 -0
  88. package/src/cli/diagnostics.ts +19 -0
  89. package/src/cli/doctor.ts +20 -0
  90. package/src/cli/index.ts +10 -0
  91. package/src/cli/update.ts +58 -0
  92. package/src/client/index.ts +1 -1
  93. package/src/client/routing/mount.tsx +18 -2
  94. package/src/client/ssr/markers.tsx +22 -0
  95. package/src/compiler/config.ts +88 -2
  96. package/src/compiler/docs.ts +47 -308
  97. package/src/compiler/index.ts +236 -32
  98. package/src/compiler/ssr-codegen.ts +1 -1
  99. package/src/compiler/template-build.ts +247 -46
  100. package/src/compiler/toil-docs.generated.ts +26 -0
  101. package/src/devserver/daemon/catalog.ts +120 -0
  102. package/src/devserver/daemon/cron.ts +87 -0
  103. package/src/devserver/daemon/host.ts +224 -0
  104. package/src/devserver/daemon/index.ts +349 -0
  105. package/src/devserver/db/catalog.ts +108 -0
  106. package/src/devserver/db/database.ts +1633 -0
  107. package/src/devserver/db/index.ts +18 -0
  108. package/src/devserver/db/routeKinds.ts +147 -0
  109. package/src/devserver/db/types.ts +139 -0
  110. package/src/devserver/email/index.ts +1 -1
  111. package/src/devserver/index.ts +31 -287
  112. package/src/devserver/mstore/store.ts +121 -0
  113. package/src/devserver/{host.ts → runtime/host.ts} +98 -7
  114. package/src/devserver/{module.ts → runtime/module.ts} +47 -1
  115. package/src/devserver/server.ts +393 -0
  116. package/src/devserver/ssr.ts +166 -0
  117. package/src/devserver/wasm/sections.ts +59 -0
  118. package/src/devserver/wasm/surface.ts +88 -0
  119. package/test/daemon-build.test.ts +198 -0
  120. package/test/daemon-catalog.test.ts +265 -0
  121. package/test/daemon-emulation.test.ts +216 -0
  122. package/test/db.test.ts +0 -0
  123. package/test/devserver-database.test.ts +510 -14
  124. package/test/devserver-pqauth.test.ts +1 -1
  125. package/test/devserver-secrets.test.ts +5 -1
  126. package/test/doctor.test.ts +13 -0
  127. package/test/email-preview.test.ts +6 -1
  128. package/test/example-guestbook.test.ts +43 -1
  129. package/test/fixtures/daemon-app.ts +56 -0
  130. package/test/global-setup.ts +17 -0
  131. package/test/pqauth-e2e.test.ts +1 -1
  132. package/test/ssr-render.test.ts +94 -27
  133. package/test/ssr-template.test.tsx +44 -1
  134. package/vitest.config.ts +3 -0
  135. package/build/devserver/database.d.ts +0 -8
  136. package/build/devserver/database.js +0 -418
  137. package/src/devserver/database.ts +0 -618
  138. /package/build/devserver/{dotenv.d.ts → config/dotenv.d.ts} +0 -0
  139. /package/build/devserver/{dotenv.js → config/dotenv.js} +0 -0
  140. /package/build/devserver/{env.d.ts → config/env.d.ts} +0 -0
  141. /package/build/devserver/{env.js → config/env.js} +0 -0
  142. /package/build/devserver/{ratelimit.d.ts → config/ratelimit.d.ts} +0 -0
  143. /package/build/devserver/{ratelimit.js → config/ratelimit.js} +0 -0
  144. /package/build/devserver/{cache.d.ts → http/cache.d.ts} +0 -0
  145. /package/build/devserver/{cache.js → http/cache.js} +0 -0
  146. /package/build/devserver/{envelope.d.ts → http/envelope.d.ts} +0 -0
  147. /package/build/devserver/{envelope.js → http/envelope.js} +0 -0
  148. /package/build/devserver/{proxy.d.ts → http/proxy.d.ts} +0 -0
  149. /package/build/devserver/{proxy.js → http/proxy.js} +0 -0
  150. /package/build/devserver/{crypto.d.ts → runtime/crypto.d.ts} +0 -0
  151. /package/build/devserver/{crypto.js → runtime/crypto.js} +0 -0
  152. /package/src/devserver/{dotenv.ts → config/dotenv.ts} +0 -0
  153. /package/src/devserver/{env.ts → config/env.ts} +0 -0
  154. /package/src/devserver/{ratelimit.ts → config/ratelimit.ts} +0 -0
  155. /package/src/devserver/{cache.ts → http/cache.ts} +0 -0
  156. /package/src/devserver/{envelope.ts → http/envelope.ts} +0 -0
  157. /package/src/devserver/{proxy.ts → http/proxy.ts} +0 -0
  158. /package/src/devserver/{crypto.ts → runtime/crypto.ts} +0 -0
@@ -0,0 +1,393 @@
1
+ /**
2
+ * The toiljs WASM dev server: a uWebSockets.js front (via @dacely/hyper-express,
3
+ * the same stack as `toiljs/backend`) that dispatches HTTP requests into the
4
+ * ToilScript-compiled server wasm exactly like the production edge does, and
5
+ * proxies everything the server does not claim to an internal Vite dev server,
6
+ * so dev keeps 100% of Vite's behavior (HMR, transforms, toolbar endpoints,
7
+ * public assets, SPA fallback).
8
+ *
9
+ * Request flow:
10
+ *
11
+ * browser ── uWS :port ──► wasm `handle()` (fresh instance, envelope ABI)
12
+ * │ │
13
+ * │ └─ "unhandled" marker (no route matched)
14
+ * ▼ │
15
+ * Vite dev server (loopback) ◄──────────────┘
16
+ *
17
+ * Dev intentionally skips the edge's metering, gas, pooling and snapshot-reset
18
+ * machinery; the ABI (envelope layout, `handle(ofs, len) -> i64`, host import
19
+ * surface, trap isolation) is identical so a server that runs here runs there.
20
+ */
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+
25
+ import { type Request, type Response, Server } from '@dacely/hyper-express';
26
+ import pc from 'picocolors';
27
+
28
+ import type { EmailBackendConfig } from 'toiljs/shared';
29
+
30
+ import { DaemonHost, daemonEmulationEnabled } from './daemon/index.js';
31
+ import type { ResolvedDaemonConfig } from './daemon/host.js';
32
+ import { configureDbPersistence } from './db/index.js';
33
+ 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
+ import { proxyToVite, type ViteTarget, wireWebsocketProxy } from './http/proxy.js';
37
+ import { WasmServerModule } from './runtime/module.js';
38
+ 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;
47
+
48
+ /**
49
+ * Paths that are Vite's own by construction; skipping the wasm round-trip for
50
+ * them keeps the hot path of module serving untouched. Everything else is
51
+ * offered to the server first (it answers or yields via the unhandled marker).
52
+ */
53
+ const VITE_PREFIXES = ['/@', '/node_modules/', '/__toil/'];
54
+
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
+ /** Options for {@link startDevServer}. */
76
+ export interface DevServerOptions {
77
+ /** Project root; `respond_file` paths resolve against it (and may not escape it). */
78
+ readonly root: string;
79
+ /** Public listening port (the one the browser opens). */
80
+ readonly port: number;
81
+ /** Bind host. Default `127.0.0.1`. */
82
+ readonly host?: string;
83
+ /** Absolute path to the ToilScript server wasm (toilconfig `targets.release.outFile`). */
84
+ readonly wasmFile: string;
85
+ /**
86
+ * Absolute path to the cold daemon artifact (`release-cold.wasm`). When present and the cold
87
+ * artifact declares a daemon surface, the dev daemon emulator drives its `@scheduled` tasks
88
+ * (per `nodeMode`). Omit for a project with no `@daemon` (the file is never built).
89
+ */
90
+ readonly coldWasmFile?: string;
91
+ /** Which layer the dev process emulates (gates the daemon emulator). Default `all`. */
92
+ readonly nodeMode?: string;
93
+ /** Daemon (L4) config mirror (drives the dev scheduler's budgets/caps). */
94
+ readonly daemon?: ResolvedDaemonConfig;
95
+ /** The internal Vite dev server to proxy unclaimed traffic to. */
96
+ readonly vite: ViteTarget;
97
+ /** Max request body bytes. Default 8 MB. */
98
+ readonly maxBodyLength?: number;
99
+ /**
100
+ * The `toil.config.ts` `server.email` section (non-secret). When set (and the
101
+ * API key is in `.env.secrets`), `EmailService.send` really sends in dev;
102
+ * otherwise it stays a log-only mock. See `./email`.
103
+ */
104
+ readonly email?: EmailBackendConfig;
105
+ /**
106
+ * Edge-SSR templates (one per `ssr = true` route), extracted at dev startup
107
+ * against the live dev shell. When a GET/HEAD matches a route the dev server
108
+ * runs the guest `render`, splices the values, and serves the SSR HTML (same
109
+ * path as the prod edge). Omit / empty for a project with no SSR route.
110
+ */
111
+ readonly ssrTemplates?: readonly DevSsrTemplate[];
112
+ }
113
+
114
+ /** A running dev server. */
115
+ export interface RunningDevServer {
116
+ readonly port: number;
117
+ readonly host: string;
118
+ /** Gracefully shuts the front server down (the Vite server is owned by the caller). */
119
+ close(): Promise<void>;
120
+ }
121
+
122
+ /** True for requests that belong to Vite by construction (never offered to the wasm). */
123
+ function isViteInternal(url: string): boolean {
124
+ return VITE_PREFIXES.some((p) => url.startsWith(p));
125
+ }
126
+
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
+ /**
214
+ * Starts the front server. The caller owns the Vite dev server (start it on a
215
+ * loopback port first) and the toilscript rebuild watcher; this watches only
216
+ * the wasm artifact and hot-swaps the compiled module when it changes.
217
+ */
218
+ export async function startDevServer(options: DevServerOptions): Promise<RunningDevServer> {
219
+ const host = options.host ?? '127.0.0.1';
220
+ const root = path.resolve(options.root);
221
+
222
+ // Wire the email service from toil.config `server.email` + `.env.secrets`
223
+ // (TOIL_EMAIL_*). Configured -> real sends; otherwise the import stays a
224
+ // log-only mock. A partial-but-invalid config logs why it stayed off.
225
+ const emailInit = initEmailService(root, options.email);
226
+ if (emailInit.service !== null) {
227
+ process.stdout.write(pc.dim(` ✉ email enabled: ${emailInit.note}`) + '\n');
228
+ } else if (emailInit.note !== null) {
229
+ process.stdout.write(pc.yellow(' ! ') + pc.dim(`email off: ${emailInit.note}`) + '\n');
230
+ }
231
+
232
+ const module = new WasmServerModule(options.wasmFile);
233
+
234
+ // Edge-SSR routes (extracted against the live dev shell at startup). When a
235
+ // GET/HEAD matches one, the dev server runs the guest `render`, splices the
236
+ // values into the template, and serves the SSR HTML (prod-edge parity).
237
+ const ssrRoutes = buildSsrRoutes(options.ssrTemplates ?? []);
238
+ if (ssrRoutes.length > 0) {
239
+ process.stdout.write(
240
+ pc.dim(` edge SSR: ${String(ssrRoutes.length)} route(s) served server-side`) + '\n',
241
+ );
242
+ }
243
+
244
+ // Persist dev DB data under the project's .toil/ so records, events, and their
245
+ // schema_versions survive restarts (delete .toil/devdata.json to reset). Only
246
+ // the running dev server persists; tests that construct WasmServerModule
247
+ // directly stay purely in-memory.
248
+ configureDbPersistence(path.join(root, '.toil', 'devdata.json'));
249
+
250
+ let warnedMissing = false;
251
+ let loadedOnce = false;
252
+ const refresh = (): void => {
253
+ try {
254
+ if (module.refresh() && loadedOnce) {
255
+ process.stdout.write(pc.green(' ✓ ') + pc.dim('server module reloaded') + '\n');
256
+ }
257
+ loadedOnce ||= module.available;
258
+ } catch (e) {
259
+ process.stdout.write(pc.red(` ✗ server wasm failed to load: ${String(e)}`) + '\n');
260
+ }
261
+ if (!module.available && !warnedMissing) {
262
+ warnedMissing = true;
263
+ process.stdout.write(
264
+ pc.yellow(' ! ') +
265
+ pc.dim(`server wasm not found at ${options.wasmFile}; serving client only`) +
266
+ '\n',
267
+ );
268
+ }
269
+ };
270
+ refresh();
271
+
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);
286
+
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
+ 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?.();
306
+ }
307
+
308
+ app.any('/*', async (request: Request, response: Response) => {
309
+ response.removeHeader('uWebSockets');
310
+
311
+ const dispatchable =
312
+ !isViteInternal(request.url) && METHOD_CODES[request.method] !== undefined;
313
+ if (dispatchable) refresh();
314
+
315
+ 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');
350
+ return;
351
+ }
352
+
353
+ // Edge SSR: handle() did not claim this path; if it matches an
354
+ // `ssr = true` route, run the guest `render`, splice the values, and
355
+ // serve the server-rendered HTML. A fail-safe envelope (no renderer
356
+ // matched / malformed) returns null, so we fall through to Vite (the
357
+ // route then client-renders, same as before).
358
+ if (
359
+ (request.method === 'GET' || request.method === 'HEAD') &&
360
+ ssrRoutes.length > 0
361
+ ) {
362
+ const route = ssrRoutes.find((r) => r.test(pathnameOf(request.url)));
363
+ if (route) {
364
+ try {
365
+ const out = assembleSsr(route, module.dispatchRender(envelopeReq));
366
+ if (out !== null) {
367
+ sendSsr(response, out, request.method === 'HEAD');
368
+ return;
369
+ }
370
+ } catch (e) {
371
+ process.stdout.write(
372
+ pc.red(` ✗ SSR ${request.path}: ${String(e)}`) + '\n',
373
+ );
374
+ }
375
+ }
376
+ }
377
+ }
378
+
379
+ await proxyToVite(request, response, options.vite);
380
+ });
381
+
382
+ await app.listen(options.port, host);
383
+
384
+ return {
385
+ port: options.port,
386
+ host,
387
+ close: async (): Promise<void> => {
388
+ if (daemonTimer !== null) clearInterval(daemonTimer);
389
+ daemonHost?.close();
390
+ await app.shutdown();
391
+ },
392
+ };
393
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Dev-server edge SSR: splice the guest `render` values into a route's
3
+ * template-with-holes and serve real server-rendered HTML, mirroring the
4
+ * production edge (`toil-backend/src/host/template/assemble.rs`).
5
+ *
6
+ * The templates are extracted once at dev startup against the LIVE (Vite-
7
+ * transformed) dev shell (see `compiler/template-build.ts extractDevSsrTemplates`)
8
+ * so the served markup boots the dev client and hydrates in place. At request
9
+ * time the dev server runs the real `render` export (`WasmServerModule.
10
+ * dispatchRender`), this module decodes the values envelope and splices each
11
+ * value at its manifest-fixed offset. The hash-coherence guard the prod edge
12
+ * enforces is skipped in dev: the guest and the template are built together
13
+ * here, so there is no deploy skew to catch — only fail-safe envelopes (status
14
+ * >= 500 or no slots) fall back to client rendering.
15
+ */
16
+
17
+ /** One SSR route's spliceable template + its slot insertion points. */
18
+ export interface DevSsrTemplate {
19
+ pattern: string;
20
+ name: string;
21
+ tmpl: Uint8Array;
22
+ entries: { id: number; offset: number }[];
23
+ }
24
+
25
+ /** A matchable SSR route. */
26
+ export interface SsrRoute {
27
+ /** Matches a request pathname (no query) to this route's template. */
28
+ test: (pathname: string) => boolean;
29
+ tmpl: Uint8Array;
30
+ entries: { id: number; offset: number }[];
31
+ }
32
+
33
+ /** The pathname of a request URL (strip the query string). */
34
+ export function pathnameOf(url: string): string {
35
+ const q = url.indexOf('?');
36
+ return q < 0 ? url : url.slice(0, q);
37
+ }
38
+
39
+ /** Compile a route pattern (`/hello`, `/u/:name`, `/blog/[id]`, `/files/[...path]`,
40
+ * `/*`) to a pathname matcher. Dynamic segments match one path segment; catch-all
41
+ * (`[...x]` / `*`) matches the rest. Trailing slashes are ignored. */
42
+ function patternToTest(pattern: string): (pathname: string) => boolean {
43
+ const norm = (p: string): string => (p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p);
44
+ let re = '';
45
+ // Tokenise into literal runs and dynamic/catch-all holes.
46
+ let i = 0;
47
+ while (i < pattern.length) {
48
+ const ch = pattern[i];
49
+ if (ch === ':') {
50
+ // `:name` — one segment.
51
+ i++;
52
+ while (i < pattern.length && /[A-Za-z0-9_]/.test(pattern[i])) i++;
53
+ re += '[^/]+';
54
+ } else if (ch === '*') {
55
+ re += '.*';
56
+ i++;
57
+ } else if (ch === '[') {
58
+ const end = pattern.indexOf(']', i);
59
+ const inner = end < 0 ? '' : pattern.slice(i + 1, end);
60
+ re += inner.startsWith('...') ? '.*' : '[^/]+';
61
+ i = end < 0 ? pattern.length : end + 1;
62
+ } else {
63
+ // Literal char, regex-escaped.
64
+ re += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
65
+ i++;
66
+ }
67
+ }
68
+ const compiled = new RegExp(`^${re}$`);
69
+ return (pathname: string): boolean => compiled.test(norm(pathname));
70
+ }
71
+
72
+ /** Build matchable SSR routes from the extracted dev templates. */
73
+ export function buildSsrRoutes(templates: readonly DevSsrTemplate[]): SsrRoute[] {
74
+ return templates.map((t) => ({ test: patternToTest(t.pattern), tmpl: t.tmpl, entries: t.entries }));
75
+ }
76
+
77
+ /** A decoded guest values envelope. */
78
+ interface DecodedValues {
79
+ status: number;
80
+ headers: [string, string][];
81
+ /** Slot value bytes keyed by numeric slot id. */
82
+ values: Map<number, Uint8Array>;
83
+ }
84
+
85
+ /** Decode the guest values envelope (mirrors the prod host `decode_values`). All
86
+ * fields little-endian, no padding. Returns null on a malformed/short buffer. */
87
+ function decodeValues(buf: Uint8Array): DecodedValues | null {
88
+ const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
89
+ let o = 0;
90
+ const need = (n: number): boolean => o + n <= buf.byteLength;
91
+ try {
92
+ if (!need(2 + 32 + 2)) return null;
93
+ const status = dv.getUint16(o, true);
94
+ o += 2;
95
+ o += 32; // template hash (coherence is built together in dev; not checked)
96
+ const nHeaders = dv.getUint16(o, true);
97
+ o += 2;
98
+ const headers: [string, string][] = [];
99
+ const dec = new TextDecoder();
100
+ for (let i = 0; i < nHeaders; i++) {
101
+ if (!need(4)) return null;
102
+ const nameLen = dv.getUint16(o, true);
103
+ const valLen = dv.getUint16(o + 2, true);
104
+ o += 4;
105
+ if (!need(nameLen + valLen)) return null;
106
+ const name = dec.decode(buf.subarray(o, o + nameLen));
107
+ o += nameLen;
108
+ const val = dec.decode(buf.subarray(o, o + valLen));
109
+ o += valLen;
110
+ headers.push([name, val]);
111
+ }
112
+ if (!need(2)) return null;
113
+ const nSlots = dv.getUint16(o, true);
114
+ o += 2;
115
+ const values = new Map<number, Uint8Array>();
116
+ for (let i = 0; i < nSlots; i++) {
117
+ if (!need(2 + 1 + 4)) return null;
118
+ const id = dv.getUint16(o, true);
119
+ o += 2;
120
+ o += 1; // kind (the splice is kind-agnostic; the guest pre-escaped/stamped)
121
+ const len = dv.getUint32(o, true);
122
+ o += 4;
123
+ if (!need(len)) return null;
124
+ values.set(id, buf.subarray(o, o + len));
125
+ o += len;
126
+ }
127
+ return { status, headers, values };
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /** Splice ascending-offset inserts into the template (mirrors the host `assemble`). */
134
+ function splice(tmpl: Uint8Array, inserts: { offset: number; value: Uint8Array }[]): Uint8Array {
135
+ const parts: Uint8Array[] = [];
136
+ let prev = 0;
137
+ for (const ins of inserts) {
138
+ if (ins.offset > prev) parts.push(tmpl.subarray(prev, ins.offset));
139
+ if (ins.value.length > 0) parts.push(ins.value);
140
+ prev = ins.offset;
141
+ }
142
+ if (tmpl.length > prev) parts.push(tmpl.subarray(prev));
143
+ return Buffer.concat(parts.map((p) => Buffer.from(p.buffer, p.byteOffset, p.byteLength)));
144
+ }
145
+
146
+ /** A spliced SSR response. */
147
+ export interface SsrResult {
148
+ status: number;
149
+ headers: [string, string][];
150
+ html: Uint8Array;
151
+ }
152
+
153
+ /**
154
+ * Decode the guest envelope and splice it into `route`'s template. Returns null
155
+ * to fall back to client rendering: a fail-safe envelope (status >= 500, e.g. no
156
+ * renderer matched), no slots, or a decode error.
157
+ */
158
+ export function assembleSsr(route: SsrRoute, envelope: Uint8Array): SsrResult | null {
159
+ const decoded = decodeValues(envelope);
160
+ if (decoded === null) return null;
161
+ if (decoded.status >= 500 || decoded.values.size === 0) return null;
162
+ const inserts = route.entries
163
+ .map((e) => ({ offset: e.offset, value: decoded.values.get(e.id) ?? new Uint8Array(0) }))
164
+ .sort((a, b) => a.offset - b.offset);
165
+ return { status: decoded.status, headers: decoded.headers, html: splice(route.tmpl, inserts) };
166
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Shared, bounds-checked wasm custom-section walker. Factored out of
3
+ * `db/catalog.ts` so the three Toil section parsers (`toildb.catalog`,
4
+ * `toilstream.catalog`, `toildaemon.catalog`, `toil.surface`) share ONE
5
+ * magic-skipping loop instead of drifting copies.
6
+ *
7
+ * Every input is a tenant-built, possibly mid-rebuild wasm, so the walker must
8
+ * never read past the buffer: a truncated/garbage section table returns `null`
9
+ * (treated by callers as "no section"). Mirrors the host-side walker
10
+ * (`toil-backend` custom-section reader) and the toilscript-side test walker.
11
+ */
12
+
13
+ /** Read a LEB128 from `buf` at `pos`; throws on overrun so a truncated module
14
+ * can never over-read (the caller catches and treats it as "no section"). */
15
+ export function leb(buf: Buffer, pos: number): [number, number] {
16
+ let result = 0;
17
+ let shift = 0;
18
+ let p = pos;
19
+ for (;;) {
20
+ if (p >= buf.length) throw new RangeError('leb128 past end of buffer');
21
+ const b = buf[p++];
22
+ result |= (b & 0x7f) << shift;
23
+ if ((b & 0x80) === 0) break;
24
+ shift += 7;
25
+ if (shift > 35) throw new RangeError('leb128 too long');
26
+ }
27
+ return [result >>> 0, p];
28
+ }
29
+
30
+ /** The bytes of the named wasm custom section, or `null` if absent. Bounds-checked
31
+ * so a truncated/garbage module can never read past the buffer. */
32
+ export function customSection(wasm: Buffer, want: string): Buffer | null {
33
+ if (
34
+ wasm.length < 8 ||
35
+ wasm[0] !== 0x00 ||
36
+ wasm[1] !== 0x61 ||
37
+ wasm[2] !== 0x73 ||
38
+ wasm[3] !== 0x6d
39
+ )
40
+ return null;
41
+ let pos = 8; // skip the 8-byte magic + version header
42
+ while (pos < wasm.length) {
43
+ const id = wasm[pos++];
44
+ let size: number;
45
+ [size, pos] = leb(wasm, pos);
46
+ const end = pos + size;
47
+ if (end > wasm.length || end < pos) return null; // truncated section table
48
+ if (id === 0) {
49
+ const [nameLen, namePos] = leb(wasm, pos);
50
+ if (
51
+ namePos + nameLen <= end &&
52
+ wasm.toString('latin1', namePos, namePos + nameLen) === want
53
+ )
54
+ return wasm.subarray(namePos + nameLen, end);
55
+ }
56
+ pos = end;
57
+ }
58
+ return null;
59
+ }