AtlatestRepositorysigil-websocket
sigil-websocket / tree / test / integrationtest-wasm-websocket-client.mjs
1
#!/usr/bin/env node3
import crypto from "node:crypto";4
import { spawnSync } from "node:child_process";5
import fs from "node:fs";6
import http from "node:http";7
import net from "node:net";8
import path from "node:path";9
import { fileURLToPath } from "node:url";10
import vm from "node:vm";11
import { WASI } from "node:wasi";13
const scriptDir = path.dirname(fileURLToPath(import.meta.url));14
const repoRoot = path.resolve(scriptDir, "../..");15
const workspaceRoot = path.resolve(repoRoot, "..");16
const sigilRoot = path.join(workspaceRoot, "sigil");17
const appDir = path.join(repoRoot, "test/integration/apps/wasm-websocket-client-smoke");18
const buildDir = path.join(appDir, "build/web");19
const wasmPath = path.join(buildDir, "wasm-websocket-client-smoke.wasm");20
const bridgePath = path.join(buildDir, "assets/sigil-wasm-net.js");21
const manifestPath = path.join(buildDir, "sigil-wasm-bridges.json");22
const libPath = path.join(buildDir, "lib");23
const sigilBin = path.join(sigilRoot, "build/dev/bin/sigil");24
const withZig = path.join(sigilRoot, "scripts/with-zig");25
const redirectsPath = path.join(repoRoot, "dev-redirects.sgl");27
function 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]);38
}40
function 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]);56
}58
class 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
}176
}178
function 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
}189
}191
function requireFile(file) {192
if (!fs.existsSync(file)) throw new Error(`Expected file: ${file}`);193
}195
function 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") };216
}218
async 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 = crypto227
.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 };251
}253
function 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}`);260
}262
async 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
}346
}348
try {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
}354
}