toiljs 0.0.25 → 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.
- package/CHANGELOG.md +18 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/index.js +72 -22
- package/build/devserver/.tsbuildinfo +1 -0
- package/build/devserver/envelope.d.ts +18 -0
- package/build/devserver/envelope.js +88 -0
- package/build/devserver/host.d.ts +14 -0
- package/build/devserver/host.js +71 -0
- package/build/devserver/index.d.ts +22 -0
- package/build/devserver/index.js +144 -0
- package/build/devserver/module.d.ts +21 -0
- package/build/devserver/module.js +81 -0
- package/build/devserver/proxy.d.ts +7 -0
- package/build/devserver/proxy.js +98 -0
- package/build/io/.tsbuildinfo +1 -1
- package/build/io/codec.d.ts +1 -1
- package/examples/basic/server/HelloHandler.ts +4 -1
- package/package.json +9 -3
- package/server/runtime/handlers/ToilHandler.ts +4 -3
- package/server/runtime/index.ts +1 -1
- package/server/runtime/response.ts +23 -0
- package/server/runtime/rest/RestHandler.ts +2 -2
- package/src/cli/create.ts +1 -1
- package/src/compiler/index.ts +109 -28
- package/src/devserver/envelope.ts +154 -0
- package/src/devserver/host.ts +137 -0
- package/src/devserver/index.ts +242 -0
- package/src/devserver/module.ts +167 -0
- package/src/devserver/proxy.ts +155 -0
- package/src/io/codec.ts +4 -2
- package/test/devserver.test.ts +199 -0
- package/tsconfig.devserver.json +13 -0
|
@@ -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
|
-
|
|
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
|
+
}
|