toiljs 0.0.26 → 0.0.27

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.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Loads the ToilScript-compiled server wasm and dispatches request envelopes
3
+ * into it, the dev-mode equivalent of the edge's fresh dispatcher
4
+ * (`toil-backend/src/wasm/dispatcher/fresh.rs`): one fresh instance per
5
+ * request, so guest state can never leak between requests and a trapped
6
+ * instance is simply dropped. Instantiation of a compiled module is
7
+ * microseconds, irrelevant next to dev-mode I/O.
8
+ *
9
+ * The compiled module is cached and transparently recompiled when the wasm
10
+ * file's mtime changes (the `toiljs dev` watcher rebuilds it via toilscript on
11
+ * server-source edits), which is the server-side hot reload.
12
+ */
13
+
14
+ import fs from 'node:fs';
15
+
16
+ import {
17
+ decodeResponseEnvelope,
18
+ encodeRequestEnvelope,
19
+ unpackHandleResult,
20
+ type EnvelopeRequest,
21
+ } from './envelope.js';
22
+ import { buildHostImports, freshDispatchState, type MemoryRef } from './host.js';
23
+
24
+ export { WasmAbortError } from './host.js';
25
+
26
+ /**
27
+ * Marker header the server runtime puts on its fallback 404 (no `@rest` route
28
+ * matched and no custom handler produced a response). The dev server strips it
29
+ * and falls through to Vite; a deliberate `Response.notFound()` does not carry
30
+ * it and is sent to the client as-is. Mirrors `TOIL_UNHANDLED_HEADER` in
31
+ * `server/runtime/response.ts`.
32
+ */
33
+ export const UNHANDLED_HEADER = 'x-toil-unhandled';
34
+
35
+ const WASM_PAGE = 65536;
36
+
37
+ /** The shaped outcome of one guest dispatch. */
38
+ export interface WasmDispatchResult {
39
+ readonly status: number;
40
+ readonly headers: readonly (readonly [string, string])[];
41
+ readonly body: Uint8Array;
42
+ /** Path from a guest `respond_file` call; when set it replaces `body`. */
43
+ readonly sendfile: string | null;
44
+ /** True when the guest reported "no route matched" (the {@link UNHANDLED_HEADER} marker). */
45
+ readonly unhandled: boolean;
46
+ }
47
+
48
+ interface HandleExports {
49
+ readonly memory: WebAssembly.Memory;
50
+ readonly handle: (reqOfs: number, reqLen: number) => bigint;
51
+ }
52
+
53
+ /** Host functions the dev server provides under `env` (see `host.ts`). */
54
+ const PROVIDED_IMPORTS = new Set(['abort', 'set_status', 'set_header', 'respond_file', 'thread_spawn']);
55
+
56
+ export class WasmServerModule {
57
+ private module: WebAssembly.Module | null = null;
58
+ private loadedMtimeMs = -1;
59
+
60
+ constructor(private readonly wasmPath: string) {}
61
+
62
+ /** Whether a compiled module is currently available to dispatch into. */
63
+ get available(): boolean {
64
+ return this.module !== null;
65
+ }
66
+
67
+ /**
68
+ * (Re)compile when the wasm file appeared or changed since the last load.
69
+ * Returns `true` when a (re)compile happened, `false` when the cached
70
+ * module is still current or the file is missing (`available` tells the
71
+ * caller which).
72
+ */
73
+ refresh(): boolean {
74
+ let mtimeMs: number;
75
+ try {
76
+ mtimeMs = fs.statSync(this.wasmPath).mtimeMs;
77
+ } catch {
78
+ this.module = null;
79
+ this.loadedMtimeMs = -1;
80
+ return false;
81
+ }
82
+ if (this.module !== null && mtimeMs === this.loadedMtimeMs) return false;
83
+
84
+ const bytes = fs.readFileSync(this.wasmPath);
85
+ const module = new WebAssembly.Module(bytes);
86
+ this.assertImportSurface(module);
87
+ this.assertExportSurface(module);
88
+ this.module = module;
89
+ this.loadedMtimeMs = mtimeMs;
90
+ return true;
91
+ }
92
+
93
+ /**
94
+ * Run one request through a fresh guest instance. Throws on a guest trap
95
+ * (including ToilScript `abort`), a malformed response envelope, or a
96
+ * missing module; the caller shapes those into a 500.
97
+ */
98
+ dispatch(req: EnvelopeRequest): WasmDispatchResult {
99
+ if (this.module === null) throw new Error(`server wasm not loaded (${this.wasmPath})`);
100
+
101
+ const envelope = encodeRequestEnvelope(req);
102
+
103
+ const ref: MemoryRef = { memory: null };
104
+ const state = freshDispatchState();
105
+ const instance = new WebAssembly.Instance(this.module, buildHostImports(ref, state));
106
+ const exports = instance.exports as unknown as HandleExports;
107
+ ref.memory = exports.memory;
108
+
109
+ // The edge writes the envelope at offset 0, which only holds for tiny
110
+ // requests: ToilScript static data starts at offset 1024, so anything
111
+ // larger would corrupt the guest. `handle(req_ofs, req_len)` takes the
112
+ // offset as a parameter, so we stay ABI-compatible and write past the
113
+ // current end of linear memory instead (grown to fit). The guest heap
114
+ // grows upward from its data section and copies the envelope into
115
+ // managed objects on decode, well before it could reach this region.
116
+ const reqOfs = exports.memory.buffer.byteLength;
117
+ exports.memory.grow(Math.ceil(envelope.length / WASM_PAGE) || 1);
118
+ Buffer.from(exports.memory.buffer).set(envelope, reqOfs);
119
+
120
+ const packed = exports.handle(reqOfs, envelope.length);
121
+ const { ptr, len } = unpackHandleResult(packed);
122
+
123
+ // Same bounds validation as the edge: never trust the guest's pointer.
124
+ const memSize = exports.memory.buffer.byteLength;
125
+ if (len > memSize || ptr + len > memSize)
126
+ throw new Error(
127
+ `guest returned an out-of-bounds response: ptr=${String(ptr)} len=${String(len)}`,
128
+ );
129
+
130
+ const resp = decodeResponseEnvelope(new Uint8Array(exports.memory.buffer, ptr, len));
131
+
132
+ // Merge the imperative host-import state on top of the envelope (a
133
+ // toiljs guest answers fully in-band, so this is usually a no-op).
134
+ const headers: (readonly [string, string])[] = [...resp.headers, ...state.headers];
135
+ const status = state.status ?? resp.status;
136
+
137
+ const unhandled = headers.some(([n]) => n.toLowerCase() === UNHANDLED_HEADER);
138
+
139
+ return {
140
+ status,
141
+ headers: headers.filter(([n]) => n.toLowerCase() !== UNHANDLED_HEADER),
142
+ body: resp.body,
143
+ sendfile: state.sendfile,
144
+ unhandled,
145
+ };
146
+ }
147
+
148
+ /** Fail instantiation up front, with names, when the guest needs imports we do not provide. */
149
+ private assertImportSurface(module: WebAssembly.Module): void {
150
+ const missing = WebAssembly.Module.imports(module)
151
+ .filter((i) => i.kind === 'function' && (i.module !== 'env' || !PROVIDED_IMPORTS.has(i.name)))
152
+ .map((i) => `${i.module}.${i.name}`);
153
+ if (missing.length > 0)
154
+ throw new Error(
155
+ `server wasm imports unsupported host functions: ${missing.join(', ')}`,
156
+ );
157
+ }
158
+
159
+ /** The dispatcher needs the `handle` entrypoint and the exported linear memory. */
160
+ private assertExportSurface(module: WebAssembly.Module): void {
161
+ const names = new Set(WebAssembly.Module.exports(module).map((e) => e.name));
162
+ for (const required of ['handle', 'memory']) {
163
+ if (!names.has(required))
164
+ throw new Error(`server wasm does not export \`${required}\``);
165
+ }
166
+ }
167
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Transparent proxy from the uWebSockets.js front server to the internal Vite
3
+ * dev server, so every Vite dev feature (module transforms, HMR websocket,
4
+ * `/__toil/*` toolbar endpoints, public assets, SPA fallback) keeps working
5
+ * unchanged behind the WASM dispatcher. HTTP goes through `fetch`, the HMR
6
+ * websocket through Node's built-in `WebSocket` client, both loopback-only.
7
+ */
8
+
9
+ import { Readable } from 'node:stream';
10
+
11
+ import {
12
+ type Request,
13
+ type Response,
14
+ type Server,
15
+ type Websocket,
16
+ } from '@btc-vision/hyper-express';
17
+
18
+ /** Where the internal Vite dev server listens (always loopback). */
19
+ export interface ViteTarget {
20
+ readonly host: string;
21
+ readonly port: number;
22
+ }
23
+
24
+ /**
25
+ * Hop-by-hop request headers (RFC 9110 §7.6.1) plus headers `fetch` manages
26
+ * itself; everything else is forwarded verbatim. `accept-encoding` is dropped
27
+ * so Vite answers identity-encoded and bytes can be piped through untouched.
28
+ */
29
+ const SKIP_REQUEST_HEADERS = new Set([
30
+ 'connection',
31
+ 'keep-alive',
32
+ 'proxy-authenticate',
33
+ 'proxy-authorization',
34
+ 'te',
35
+ 'trailer',
36
+ 'transfer-encoding',
37
+ 'upgrade',
38
+ 'content-length',
39
+ 'accept-encoding',
40
+ ]);
41
+
42
+ /** Response headers owned by the front server's own HTTP framing. */
43
+ const SKIP_RESPONSE_HEADERS = new Set([
44
+ 'connection',
45
+ 'keep-alive',
46
+ 'content-length',
47
+ 'content-encoding',
48
+ 'transfer-encoding',
49
+ ]);
50
+
51
+ /** Forwards one HTTP request to Vite and streams the answer back. */
52
+ export async function proxyToVite(
53
+ request: Request,
54
+ response: Response,
55
+ target: ViteTarget,
56
+ ): Promise<void> {
57
+ const url = `http://${target.host}:${String(target.port)}${request.url}`;
58
+
59
+ const headers = new Headers();
60
+ for (const [name, value] of Object.entries(request.headers)) {
61
+ if (!SKIP_REQUEST_HEADERS.has(name.toLowerCase())) headers.set(name, value);
62
+ }
63
+
64
+ const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
65
+ const body = hasBody ? await request.buffer() : undefined;
66
+
67
+ const upstream = await fetch(url, {
68
+ method: request.method,
69
+ headers,
70
+ body: body && body.length > 0 ? new Uint8Array(body) : undefined,
71
+ // The browser follows redirects itself; pass them through untouched.
72
+ redirect: 'manual',
73
+ });
74
+
75
+ response.status(upstream.status);
76
+ upstream.headers.forEach((value, name) => {
77
+ if (!SKIP_RESPONSE_HEADERS.has(name)) response.header(name, value);
78
+ });
79
+
80
+ if (upstream.body === null) {
81
+ response.send();
82
+ return;
83
+ }
84
+ await response.stream(Readable.fromWeb(upstream.body));
85
+ }
86
+
87
+ /**
88
+ * A uWS message as something `WebSocket.send` accepts: text stays a string,
89
+ * binary is copied into a plain `ArrayBuffer`-backed view (a `Buffer` may sit
90
+ * on a shared pool slab, which the WebSocket types reject).
91
+ */
92
+ function toUpstreamMessage(data: Buffer, binary: boolean): string | Uint8Array<ArrayBuffer> {
93
+ if (!binary) return data.toString('utf8');
94
+ const copy = new Uint8Array(data.length);
95
+ copy.set(data);
96
+ return copy;
97
+ }
98
+
99
+ /**
100
+ * Wires a catch-all websocket route that pipes every upgrade (Vite's HMR
101
+ * socket connects to the page origin at `/`, subprotocol `vite-hmr`) to the
102
+ * internal Vite server. Messages are queued until the upstream socket opens,
103
+ * closes propagate in both directions.
104
+ */
105
+ export function wireWebsocketProxy(app: Server, target: ViteTarget): void {
106
+ app.upgrade('/*', (request: Request, response: Response) => {
107
+ response.upgrade({
108
+ url: request.url,
109
+ protocol: request.headers['sec-websocket-protocol'] ?? '',
110
+ });
111
+ });
112
+
113
+ app.ws(
114
+ '/*',
115
+ { message_type: 'Buffer', idle_timeout: 120, max_payload_length: 16 * 1024 * 1024 },
116
+ (ws: Websocket) => {
117
+ const { url, protocol } = ws.context as { url: string; protocol: string };
118
+ const upstream = new WebSocket(
119
+ `ws://${target.host}:${String(target.port)}${url}`,
120
+ protocol ? protocol.split(',').map((p) => p.trim()) : [],
121
+ );
122
+ upstream.binaryType = 'arraybuffer';
123
+
124
+ const pending: (string | Uint8Array<ArrayBuffer>)[] = [];
125
+ let open = false;
126
+
127
+ upstream.onopen = (): void => {
128
+ open = true;
129
+ for (const m of pending) upstream.send(m);
130
+ pending.length = 0;
131
+ };
132
+ upstream.onmessage = (event: MessageEvent): void => {
133
+ if (typeof event.data === 'string') ws.send(event.data);
134
+ else ws.send(Buffer.from(event.data as ArrayBuffer), true);
135
+ };
136
+ upstream.onclose = (event: CloseEvent): void => {
137
+ ws.close(event.code, event.reason);
138
+ };
139
+ upstream.onerror = (): void => {
140
+ ws.close();
141
+ };
142
+
143
+ ws.on('message', (message: Buffer, isBinary: boolean) => {
144
+ const m = toUpstreamMessage(message, isBinary);
145
+ if (open) upstream.send(m);
146
+ else pending.push(m);
147
+ });
148
+ ws.on('close', () => {
149
+ if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
150
+ upstream.close();
151
+ }
152
+ });
153
+ },
154
+ );
155
+ }
package/src/io/codec.ts CHANGED
@@ -22,7 +22,9 @@ const utf8Decoder = new TextDecoder();
22
22
  * {@link toBytes} for an exact-length copy of what was written.
23
23
  */
24
24
  export class DataWriter {
25
- private buf: Uint8Array;
25
+ // `ArrayBuffer`-backed (not the `ArrayBufferLike` default) so `toBytes()` yields a
26
+ // `Uint8Array<ArrayBuffer>`, which `fetch`/`Blob`/`Response` accept as a `BodyInit`.
27
+ private buf: Uint8Array<ArrayBuffer>;
26
28
  private view: DataView;
27
29
  private off = 0;
28
30
 
@@ -120,7 +122,7 @@ export class DataWriter {
120
122
  length(): number { return this.off; }
121
123
 
122
124
  /** A fresh copy of exactly the bytes written. */
123
- toBytes(): Uint8Array { return this.buf.slice(0, this.off); }
125
+ toBytes(): Uint8Array<ArrayBuffer> { return this.buf.slice(0, this.off); }
124
126
  }
125
127
 
126
128
  /**
@@ -0,0 +1,199 @@
1
+ /**
2
+ * WASM dev server: envelope codec (byte-for-byte against the ABI shared with
3
+ * `server/runtime/envelope.ts` and the edge's `envelope.rs`) and real dispatch
4
+ * into the example project's ToilScript-compiled server wasm.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+
12
+ import {
13
+ decodeResponseEnvelope,
14
+ encodeRequestEnvelope,
15
+ unpackHandleResult,
16
+ WasmServerModule,
17
+ } from '../src/devserver/index.js';
18
+
19
+ const EXAMPLE_WASM = path.resolve(
20
+ path.dirname(fileURLToPath(import.meta.url)),
21
+ '../examples/basic/build/server/release.wasm',
22
+ );
23
+
24
+ describe('request envelope encoding', () => {
25
+ it('encodes a minimal GET / exactly like the edge', () => {
26
+ const out = encodeRequestEnvelope({
27
+ method: 'GET',
28
+ path: '/',
29
+ headers: [],
30
+ body: new Uint8Array(0),
31
+ });
32
+ // Mirrors envelope.rs `encode_minimal_get`.
33
+ expect(out.length).toBe(10);
34
+ expect(out[0]).toBe(0); // method GET
35
+ expect(out.readUInt16LE(1)).toBe(1); // path_len
36
+ expect(out.toString('utf8', 3, 4)).toBe('/');
37
+ expect(out.readUInt16LE(4)).toBe(0); // n_headers
38
+ expect(out.readUInt32LE(6)).toBe(0); // body_len
39
+ });
40
+
41
+ it('encodes a POST with a header and body exactly like the edge', () => {
42
+ const out = encodeRequestEnvelope({
43
+ method: 'POST',
44
+ path: '/api',
45
+ headers: [['host', 'example.com']],
46
+ body: new TextEncoder().encode('hi'),
47
+ });
48
+ // Mirrors envelope.rs `encode_post_with_body_and_host`.
49
+ expect(out[0]).toBe(1); // method POST
50
+ expect(out.readUInt16LE(1)).toBe(4);
51
+ expect(out.toString('utf8', 3, 7)).toBe('/api');
52
+ expect(out.readUInt16LE(7)).toBe(1); // n_headers
53
+ expect(out.readUInt16LE(9)).toBe(4); // name_len
54
+ expect(out.readUInt16LE(11)).toBe(11); // val_len
55
+ expect(out.toString('utf8', 13, 17)).toBe('host');
56
+ expect(out.toString('utf8', 17, 28)).toBe('example.com');
57
+ expect(out.readUInt32LE(28)).toBe(2); // body_len
58
+ expect(out.toString('utf8', 32, 34)).toBe('hi');
59
+ });
60
+
61
+ it('rejects an unsupported method and oversized fields', () => {
62
+ const base = { path: '/', headers: [], body: new Uint8Array(0) } as const;
63
+ expect(() => encodeRequestEnvelope({ ...base, method: 'TRACE' })).toThrow(/unsupported/);
64
+ expect(() =>
65
+ encodeRequestEnvelope({ ...base, method: 'GET', path: '/'.repeat(0x10000) }),
66
+ ).toThrow(/path too long/);
67
+ expect(() =>
68
+ encodeRequestEnvelope({
69
+ ...base,
70
+ method: 'GET',
71
+ headers: [['x', 'y'.repeat(0x10000)]],
72
+ }),
73
+ ).toThrow(/header too long/);
74
+ });
75
+ });
76
+
77
+ /** Hand-builds a response envelope (the layout the guest writes). */
78
+ function buildResponse(status: number, headers: [string, string][], body: Uint8Array): Buffer {
79
+ const parts: Buffer[] = [];
80
+ const u16 = (v: number): Buffer => {
81
+ const b = Buffer.allocUnsafe(2);
82
+ b.writeUInt16LE(v);
83
+ return b;
84
+ };
85
+ const u32 = (v: number): Buffer => {
86
+ const b = Buffer.allocUnsafe(4);
87
+ b.writeUInt32LE(v);
88
+ return b;
89
+ };
90
+ parts.push(u16(status), u16(headers.length));
91
+ for (const [n, v] of headers) {
92
+ parts.push(u16(Buffer.byteLength(n)), u16(Buffer.byteLength(v)));
93
+ parts.push(Buffer.from(n), Buffer.from(v));
94
+ }
95
+ parts.push(u32(body.length), Buffer.from(body));
96
+ return Buffer.concat(parts);
97
+ }
98
+
99
+ describe('response envelope decoding', () => {
100
+ it('round-trips status, headers and body', () => {
101
+ const buf = buildResponse(
102
+ 201,
103
+ [
104
+ ['content-type', 'application/json'],
105
+ ['x-trace', 'abc'],
106
+ ],
107
+ new TextEncoder().encode('{"ok":true}'),
108
+ );
109
+ const resp = decodeResponseEnvelope(buf);
110
+ expect(resp.status).toBe(201);
111
+ expect(resp.headers).toEqual([
112
+ ['content-type', 'application/json'],
113
+ ['x-trace', 'abc'],
114
+ ]);
115
+ expect(Buffer.from(resp.body).toString()).toBe('{"ok":true}');
116
+ });
117
+
118
+ it('rejects truncation, zero status, and overflowing lengths', () => {
119
+ expect(() => decodeResponseEnvelope(new Uint8Array([1]))).toThrow(/truncated/);
120
+ expect(() => decodeResponseEnvelope(buildResponse(0, [], new Uint8Array(0)))).toThrow(
121
+ /status 0/,
122
+ );
123
+ // Claim a huge header name with too few bytes behind it.
124
+ const bad = Buffer.concat([
125
+ Buffer.from([200, 0, 1, 0, 255, 255, 0, 0]),
126
+ Buffer.from('hi'),
127
+ ]);
128
+ expect(() => decodeResponseEnvelope(bad)).toThrow(/truncated/);
129
+ });
130
+ });
131
+
132
+ describe('handle() result unpacking', () => {
133
+ it('splits the packed i64 into pointer and length', () => {
134
+ expect(unpackHandleResult((65536n << 32n) | 8n)).toEqual({ ptr: 65536, len: 8 });
135
+ expect(unpackHandleResult(0n)).toEqual({ ptr: 0, len: 0 });
136
+ expect(unpackHandleResult(0xffffffff_ffffffffn)).toEqual({
137
+ ptr: 0xffffffff,
138
+ len: 0xffffffff,
139
+ });
140
+ });
141
+ });
142
+
143
+ describe.skipIf(!fs.existsSync(EXAMPLE_WASM))('dispatch into the example server wasm', () => {
144
+ const load = (): WasmServerModule => {
145
+ const m = new WasmServerModule(EXAMPLE_WASM);
146
+ m.refresh();
147
+ return m;
148
+ };
149
+ const get = (m: WasmServerModule, p: string) =>
150
+ m.dispatch({
151
+ method: 'GET',
152
+ path: p,
153
+ headers: [['host', 'localhost:3000']],
154
+ body: new Uint8Array(0),
155
+ });
156
+
157
+ it('serves a plain route', () => {
158
+ const r = get(load(), '/');
159
+ expect(r.status).toBe(200);
160
+ expect(r.unhandled).toBe(false);
161
+ expect(Buffer.from(r.body).toString()).toBe('hello from toiljs\n');
162
+ });
163
+
164
+ it('serves a @rest route with its content-type', () => {
165
+ const r = get(load(), '/leaderboard');
166
+ expect(r.status).toBe(200);
167
+ expect(r.headers.some(([n, v]) => n === 'content-type' && v.includes('json'))).toBe(true);
168
+ });
169
+
170
+ it('marks a route miss as unhandled and strips the marker header', () => {
171
+ const r = get(load(), '/definitely-missing');
172
+ expect(r.status).toBe(404);
173
+ expect(r.unhandled).toBe(true);
174
+ expect(r.headers.some(([n]) => n === 'x-toil-unhandled')).toBe(false);
175
+ });
176
+
177
+ it('dispatches a POST body through the envelope', () => {
178
+ const m = load();
179
+ const r = m.dispatch({
180
+ method: 'POST',
181
+ path: '/players',
182
+ headers: [
183
+ ['host', 'localhost:3000'],
184
+ ['content-type', 'application/json'],
185
+ ],
186
+ body: new TextEncoder().encode('{"name":"ada"}'),
187
+ });
188
+ expect(r.unhandled).toBe(false);
189
+ expect(r.status).toBeGreaterThanOrEqual(200);
190
+ expect(r.status).toBeLessThan(500);
191
+ });
192
+
193
+ it('keeps requests isolated across instances (fresh state per dispatch)', () => {
194
+ const m = load();
195
+ const a = get(m, '/json');
196
+ const b = get(m, '/json');
197
+ expect(Buffer.from(a.body).toString()).toBe(Buffer.from(b.body).toString());
198
+ });
199
+ });
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "./tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "ESNext",
5
+ "target": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "rootDir": "src/devserver",
8
+ "outDir": "build/devserver",
9
+ "types": ["node"],
10
+ "tsBuildInfoFile": "build/devserver/.tsbuildinfo"
11
+ },
12
+ "include": ["src/devserver/**/*"]
13
+ }