AtlatestRepositorysigil-websocket

sigil-websocket / tree / test / integrationtest-wasm-websocket-client.mjs

1#!/usr/bin/env node
2
3import crypto from "node:crypto";
4import { spawnSync } from "node:child_process";
5import fs from "node:fs";
6import http from "node:http";
7import net from "node:net";
8import path from "node:path";
9import { fileURLToPath } from "node:url";
10import vm from "node:vm";
11import { WASI } from "node:wasi";
13const scriptDir = path.dirname(fileURLToPath(import.meta.url));
14const repoRoot = path.resolve(scriptDir, "../..");
15const workspaceRoot = path.resolve(repoRoot, "..");
16const sigilRoot = path.join(workspaceRoot, "sigil");
17const appDir = path.join(repoRoot, "test/integration/apps/wasm-websocket-client-smoke");
18const buildDir = path.join(appDir, "build/web");
19const wasmPath = path.join(buildDir, "wasm-websocket-client-smoke.wasm");
20const bridgePath = path.join(buildDir, "assets/sigil-wasm-net.js");
21const manifestPath = path.join(buildDir, "sigil-wasm-bridges.json");
22const libPath = path.join(buildDir, "lib");
23const sigilBin = path.join(sigilRoot, "build/dev/bin/sigil");
24const withZig = path.join(sigilRoot, "scripts/with-zig");
25const redirectsPath = path.join(repoRoot, "dev-redirects.sgl");
27function encodeServerFrame(data, opcode) {
28 const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);
29 const header = [0x80 | opcode];
30 if (payload.length < 126) {
31 header.push(payload.length);
32 } else if (payload.length < 65536) {
33 header.push(126, (payload.length >> 8) & 0xff, payload.length & 0xff);
34 } else {
35 throw new Error("large frames are not needed for this smoke test");
36 }
37 return Buffer.concat([Buffer.from(header), payload]);
40function encodeClientFrame(data, opcode) {
41 const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);
42 const mask = crypto.randomBytes(4);
43 const header = [0x80 | opcode];
44 if (payload.length < 126) {
45 header.push(0x80 | payload.length);
46 } else if (payload.length < 65536) {
47 header.push(0x80 | 126, (payload.length >> 8) & 0xff, payload.length & 0xff);
48 } else {
49 throw new Error("large frames are not needed for this smoke test");
50 }
51 const masked = Buffer.alloc(payload.length);
52 for (let i = 0; i < payload.length; i += 1) {
53 masked[i] = payload[i] ^ mask[i % 4];
54 }
55 return Buffer.concat([Buffer.from(header), mask, masked]);
58class LoopbackWebSocket {
59 static CONNECTING = 0;
60 static OPEN = 1;
61 static CLOSING = 2;
62 static CLOSED = 3;
64 constructor(url) {
65 const parsed = new URL(url);
66 if (parsed.protocol !== "ws:") {
67 throw new Error(`Unsupported test WebSocket URL: ${url}`);
68 }
69 this.binaryType = "arraybuffer";
70 this.readyState = LoopbackWebSocket.CONNECTING;
71 this._listeners = new Map();
72 this._buffer = Buffer.alloc(0);
73 this._handshakeComplete = false;
74 this._socket = net.createConnection({
75 host: parsed.hostname,
76 port: Number(parsed.port || 80),
77 });
78 const key = crypto.randomBytes(16).toString("base64");
79 this._socket.on("connect", () => {
80 this._socket.write([
81 `GET ${parsed.pathname || "/"} HTTP/1.1`,
82 `Host: ${parsed.host}`,
83 "Upgrade: websocket",
84 "Connection: Upgrade",
85 `Sec-WebSocket-Key: ${key}`,
86 "Sec-WebSocket-Version: 13",
87 "",
88 "",
89 ].join("\r\n"));
90 });
91 this._socket.on("data", (chunk) => this._onData(chunk));
92 this._socket.on("error", () => this._emit("error", {}));
93 this._socket.on("close", () => {
94 this.readyState = LoopbackWebSocket.CLOSED;
95 this._emit("close", { code: 1000, reason: "", wasClean: true });
96 });
97 }
99 addEventListener(type, listener) {
100 const listeners = this._listeners.get(type) || [];
101 listeners.push(listener);
102 this._listeners.set(type, listeners);
103 }
105 send(data) {
106 if (this.readyState !== LoopbackWebSocket.OPEN) {
107 throw new Error("WebSocket is not open");
108 }
109 if (typeof data === "string") {
110 this._socket.write(encodeClientFrame(data, 1));
111 } else {
112 this._socket.write(encodeClientFrame(Buffer.from(data), 2));
113 }
114 }
116 close(code = 1000, reason = "") {
117 if (this.readyState >= LoopbackWebSocket.CLOSING) return;
118 this.readyState = LoopbackWebSocket.CLOSING;
119 const payload = Buffer.alloc(2 + Buffer.byteLength(reason));
120 payload.writeUInt16BE(code, 0);
121 payload.write(reason, 2);
122 this._socket.write(encodeClientFrame(payload, 8), () => this._socket.end());
123 }
125 _emit(type, event) {
126 for (const listener of this._listeners.get(type) || []) {
127 listener(event);
128 }
129 }
131 _onData(chunk) {
132 this._buffer = Buffer.concat([this._buffer, chunk]);
133 if (!this._handshakeComplete) {
134 const headerEnd = this._buffer.indexOf("\r\n\r\n");
135 if (headerEnd < 0) return;
136 const header = this._buffer.subarray(0, headerEnd).toString("utf8");
137 if (!header.startsWith("HTTP/1.1 101")) {
138 this._emit("error", {});
139 this._socket.destroy();
140 return;
141 }
142 this._buffer = this._buffer.subarray(headerEnd + 4);
143 this._handshakeComplete = true;
144 this.readyState = LoopbackWebSocket.OPEN;
145 this._emit("open", {});
146 }
147 this._readFrames();
148 }
150 _readFrames() {
151 while (this._buffer.length >= 2) {
152 const opcode = this._buffer[0] & 0x0f;
153 let length = this._buffer[1] & 0x7f;
154 let offset = 2;
155 if (length === 126) {
156 if (this._buffer.length < offset + 2) return;
157 length = this._buffer.readUInt16BE(offset);
158 offset += 2;
159 } else if (length === 127) {
160 throw new Error("large frames are not needed for this smoke test");
161 }
162 if (this._buffer.length < offset + length) return;
163 const payload = this._buffer.subarray(offset, offset + length);
164 this._buffer = this._buffer.subarray(offset + length);
165 if (opcode === 1) {
166 this._emit("message", { data: payload.toString("utf8") });
167 } else if (opcode === 2) {
168 this._emit("message", {
169 data: payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength),
170 });
171 } else if (opcode === 8) {
172 this._socket.end();
173 }
174 }
175 }
178function run(command, args, options = {}) {
179 const result = spawnSync(command, args, { encoding: "utf8", ...options });
180 if (result.error) throw result.error;
181 if (result.stdout) process.stdout.write(result.stdout);
182 if (result.stderr) process.stderr.write(result.stderr);
183 if (result.status !== 0) {
184 console.error("Command failed:");
185 console.error(` cwd: ${options.cwd || process.cwd()}`);
186 console.error(` command: ${command} ${args.join(" ")}`);
187 throw new Error(`${command} ${args.join(" ")} exited with ${result.status}`);
188 }
191function requireFile(file) {
192 if (!fs.existsSync(file)) throw new Error(`Expected file: ${file}`);
195function decodeClientFrame(buffer) {
196 const opcode = buffer[0] & 0x0f;
197 let length = buffer[1] & 0x7f;
198 let offset = 2;
199 if (length === 126) {
200 length = buffer.readUInt16BE(offset);
201 offset += 2;
202 } else if (length === 127) {
203 throw new Error("large frames are not needed for this smoke test");
204 }
205 if ((buffer[1] & 0x80) === 0) {
206 throw new Error("client frame was not masked");
207 }
208 const mask = buffer.subarray(offset, offset + 4);
209 offset += 4;
210 const payload = buffer.subarray(offset, offset + length);
211 const decoded = Buffer.alloc(length);
212 for (let i = 0; i < length; i += 1) {
213 decoded[i] = payload[i] ^ mask[i % 4];
214 }
215 return { opcode, text: decoded.toString("utf8") };
218async function startEchoServer() {
219 const messages = [];
220 const sockets = new Set();
221 const server = http.createServer();
222 server.on("upgrade", (req, socket) => {
223 sockets.add(socket);
224 socket.on("close", () => sockets.delete(socket));
225 const key = req.headers["sec-websocket-key"];
226 const accept = crypto
227 .createHash("sha1")
228 .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
229 .digest("base64");
230 socket.write([
231 "HTTP/1.1 101 Switching Protocols",
232 "Upgrade: websocket",
233 "Connection: Upgrade",
234 `Sec-WebSocket-Accept: ${accept}`,
235 "",
236 "",
237 ].join("\r\n"));
238 socket.on("data", (buffer) => {
239 const frame = decodeClientFrame(buffer);
240 if (frame.opcode === 1) {
241 messages.push(frame.text);
242 socket.write(encodeServerFrame(frame.text, 1));
243 }
244 });
245 });
246 await new Promise((resolve, reject) => {
247 server.once("error", reject);
248 server.listen(0, "127.0.0.1", resolve);
249 });
250 return { server, sockets, messages, port: server.address().port };
253function callWithString(instance, fn, code) {
254 const bytes = new TextEncoder().encode(`${code}\0`);
255 const ptr = instance.exports.malloc(bytes.length);
256 new Uint8Array(instance.exports.memory.buffer, ptr, bytes.length).set(bytes);
257 const result = fn(ptr);
258 instance.exports.free(ptr);
259 if (result !== 0) throw new Error(`Sigil eval failed with status ${result}`);
262async function main() {
263 if (typeof WebSocket !== "function") {
264 globalThis.WebSocket = LoopbackWebSocket;
265 }
266 requireFile(sigilBin);
267 requireFile(withZig);
268 requireFile(redirectsPath);
270 run(withZig, [
271 sigilBin,
272 "build",
273 "--redirects",
274 redirectsPath,
275 "--config",
276 "web",
277 "--force",
278 "--no-bundle",
279 ], { cwd: appDir });
281 requireFile(wasmPath);
282 requireFile(bridgePath);
283 requireFile(manifestPath);
285 const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
286 if (!manifest.bridges?.some((bridge) => bridge.name === "net")) {
287 throw new Error("Bridge manifest does not include net bridge");
288 }
290 const { server, sockets, messages, port } = await startEchoServer();
291 try {
292 vm.runInThisContext(fs.readFileSync(bridgePath, "utf8"), { filename: bridgePath });
294 const wasi = new WASI({
295 version: "preview1",
296 args: ["wasm-websocket-client-smoke"],
297 env: {},
298 preopens: { "/lib": libPath },
299 });
300 const bridgeImports = globalThis.SigilWasmNet.createImports();
301 const wasm = await WebAssembly.compile(fs.readFileSync(wasmPath));
302 const instance = await WebAssembly.instantiate(wasm, {
303 wasi_snapshot_preview1: wasi.wasiImport,
304 ...bridgeImports,
305 });
307 globalThis.SigilWasmNet.setInstance(instance);
308 wasi.start(instance);
310 callWithString(instance, instance.exports.sigil_wasm_eval, `
311 (import (sigil websocket))
312 (define conn (ws-connect "ws://127.0.0.1:${port}"))
313 (if (not conn) (error "ws-connect failed"))
314 (display "ws-connect-ok") (newline)
315 `);
317 await new Promise((resolve) => setTimeout(resolve, 300));
319 callWithString(instance, instance.exports.sigil_wasm_eval, `
320 (ws-receive conn)
321 (ws-send conn "public-api-ping")
322 (display "ws-send-ok") (newline)
323 `);
325 await new Promise((resolve) => setTimeout(resolve, 300));
327 callWithString(instance, instance.exports.sigil_wasm_eval, `
328 (define msg (ws-receive conn))
329 (if (and (ws-message? msg)
330 (eq? (ws-message-type msg) 'text)
331 (string=? (ws-message-data msg) "public-api-ping"))
332 (display "ws-receive-ok")
333 (error "bad ws-receive result"))
334 (newline)
335 (ws-close conn)
336 `);
338 if (!messages.includes("public-api-ping")) {
339 throw new Error(`echo server did not receive public-api-ping; saw ${JSON.stringify(messages)}`);
340 }
341 console.log("websocket-public-api-wasm-ok");
342 } finally {
343 for (const socket of sockets) socket.destroy();
344 await new Promise((resolve) => server.close(resolve));
345 }
348try {
349 await main();
350} finally {
351 if (process.env.SIGIL_KEEP_WASM_WEBSOCKET_SMOKE_BUILD !== "1") {
352 fs.rmSync(path.join(appDir, "build"), { force: true, recursive: true });
353 }