Commit56d11562Recorded6 May 2026Repositorysigil-websocket

Add WASM websocket client smoke

Message

Make the client connection module avoid native socket/TLS/crypto/frame imports on the wasm feature path so browser builds can use the WebSocket bridge without native-only modules. Adjust the WASM receive and close binding order surfaced by the smoke.

Verification:

  • sh test/integration/test-wasm-websocket-client.sh
  • ../sigil/build/dev/bin/sigil -L build/dev/lib eval '(load "test/test-websocket.sgl")'
  • ../sigil/build/dev/bin/sigil -L build/dev/lib eval '(load "test/test-server.sgl")'
Changed
 src/sigil/websocket/connection.sgl                                                         |  84 ++++++++++++------------
 test/integration/apps/wasm-websocket-client-smoke/package.sgl                              |  40 ++++++++++++
 test/integration/apps/wasm-websocket-client-smoke/src/wasm-websocket-client-smoke/main.sgl |  10 +++
 test/integration/test-wasm-websocket-client.mjs                                            | 354 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/integration/test-wasm-websocket-client.sh                                             |  19 ++++++
 5 files changed, 466 insertions(+), 41 deletions(-)
Diff
src/sigil/websocket/connection.sglmodified
@@ -8,15 +8,15 @@
8
(sigil string)
9
(sigil io)
10
(sigil struct)
11
(sigil socket)
12
(sigil tls)
13
(sigil crypto)
14
(sigil async)
15
(sigil websocket frame))
+11
(sigil async))
12
(cond-expand
13
(wasm
14
(import (sigil wasm net)))
19
(else))
+15
(else
+16
(import (sigil socket)
+17
(sigil tls)
+18
(sigil crypto)
+19
(sigil websocket frame))))
20
21
(export
22
;; Connection record
@@ -354,6 +354,40 @@
354
frame
355
(utf8->string frame))))))))
356
+357
;;; Close the WebSocket connection
+358
(define (ws-close conn)
+359
(: ws-connection? -> void?)
+360
(when (ws-connected? conn)
+361
(set-ws-connection-state! conn 'closing)
+362
(cond-expand
+363
(wasm
+364
#t)
+365
(else
+366
;; Send close frame
+367
(let ((close-frame (encode-close-frame 1000))) ; 1000 = normal closure
+368
(conn-write (ws-connection-socket conn)
+369
(utf8->string close-frame)))))
+370
;; Close socket
+371
(conn-close (ws-connection-socket conn))
+372
(set-ws-connection-state! conn 'closed)))
+373
+374
(define (process-wasm-event conn event)
+375
(cond
+376
((not event)
+377
'need-more)
+378
((eq? (car event) 'open)
+379
'need-more)
+380
((eq? (car event) 'message)
+381
(ws-message type: (cadr event)
+382
data: (caddr event)))
+383
((eq? (car event) 'close)
+384
(set-ws-connection-state! conn 'closed)
+385
'closed)
+386
((eq? (car event) 'error)
+387
#f)
+388
(else
+389
#f)))
+390
391
;;; Receive a message (with async I/O support)
392
;;; Returns ws-message, 'closed, or #f on error
393
;;; When running in async scheduler, yields while waiting for data.
@@ -363,7 +397,9 @@
397
'closed
398
(cond-expand
399
(wasm
366
(let ((result (receive-message conn)))
+400
(let ((result (process-wasm-event
+401
conn
+402
(conn-read (ws-connection-socket conn) 4096))))
403
(if (eq? result 'need-more)
404
#f
405
result)))
@@ -431,23 +467,6 @@
467
(bytevector-append buffer chunk-bv))
468
(try-decode-message conn))))))))
469
434
(define (process-wasm-event conn event)
435
(cond
436
((not event)
437
'need-more)
438
((eq? (car event) 'open)
439
'need-more)
440
((eq? (car event) 'message)
441
(ws-message type: (cadr event)
442
data: (caddr event)))
443
((eq? (car event) 'close)
444
(set-ws-connection-state! conn 'closed)
445
'closed)
446
((eq? (car event) 'error)
447
#f)
448
(else
449
#f)))
450
470
;;; Try to decode a complete message from buffer
471
;;; Returns ws-message, 'closed, 'need-more, or #f on error
472
(define (try-decode-message conn)
@@ -546,21 +565,4 @@
565
(else
566
'need-more))))
567
549
;;; Close the WebSocket connection
550
(define (ws-close conn)
551
(: ws-connection? -> void?)
552
(when (ws-connected? conn)
553
(set-ws-connection-state! conn 'closing)
554
(cond-expand
555
(wasm
556
#t)
557
(else
558
;; Send close frame
559
(let ((close-frame (encode-close-frame 1000))) ; 1000 = normal closure
560
(conn-write (ws-connection-socket conn)
561
(utf8->string close-frame)))))
562
;; Close socket
563
(conn-close (ws-connection-socket conn))
564
(set-ws-connection-state! conn 'closed)))
565
568
)
test/integration/apps/wasm-websocket-client-smoke/package.sgladded
@@ -0,0 +1,40 @@
+1
;;; wasm-websocket-client-smoke - Browser smoke fixture for sigil-websocket.
+2
+3
(package
+4
name: "wasm-websocket-client-smoke"
+5
version: "0.1.0"
+6
sigil: "^0.15"
+7
entry: '(wasm-websocket-client-smoke main)
+8
+9
configs: (list
+10
(config
+11
name: 'web
+12
output-dir: "build/web"
+13
toolchain: 'zig
+14
target: "wasm32-wasi"
+15
debug?: #f
+16
optimize: 2
+17
c-flags: (with-sigil-c-flags '("-std=c99" "-D_GNU_SOURCE" "-D__SIGIL_WASM__" "-Wall" "-O2"))
+18
link-flags: '("-Wl,--export=sigil_wasm_init"
+19
"-Wl,--export=sigil_wasm_start"
+20
"-Wl,--export=sigil_wasm_eval"
+21
"-Wl,--export=sigil_wasm_input"
+22
"-Wl,--export=malloc"
+23
"-Wl,--export=free"
+24
"-lc")
+25
features: '(web wasm)))
+26
+27
dependencies: (list
+28
(from-path dir: "../../../../../sigil" package: "sigil-lib")
+29
(from-path dir: "../../../../../sigil" package: "sigil-stdlib")
+30
(from-path dir: "../../../../../sigil" package: "sigil-wasm-runtime")
+31
(from-path dir: "../../../../../sigil" package: "sigil-wasm-net")
+32
(from-path dir: "../../../.." package: "sigil-websocket"))
+33
+34
tasks: (list
+35
(task
+36
name: 'build
+37
description: "Build the WASM websocket client smoke fixture"
+38
steps: (list
+39
(compile-sigil-modules sources: "src/**/*.sgl")
+40
(link-web-application name: "wasm-websocket-client-smoke")))))
test/integration/apps/wasm-websocket-client-smoke/src/wasm-websocket-client-smoke/main.sgladded
@@ -0,0 +1,10 @@
+1
(define-library (wasm-websocket-client-smoke main)
+2
(import (sigil core)
+3
(sigil websocket connection))
+4
+5
(export main)
+6
+7
(begin
+8
(define (main)
+9
(display "wasm-websocket-client-smoke")
+10
(newline))))
test/integration/test-wasm-websocket-client.mjsadded
@@ -0,0 +1,354 @@
+1
#!/usr/bin/env node
+2
+3
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";
+12
+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");
+26
+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
}
+39
+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
}
+57
+58
class LoopbackWebSocket {
+59
static CONNECTING = 0;
+60
static OPEN = 1;
+61
static CLOSING = 2;
+62
static CLOSED = 3;
+63
+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
}
+98
+99
addEventListener(type, listener) {
+100
const listeners = this._listeners.get(type) || [];
+101
listeners.push(listener);
+102
this._listeners.set(type, listeners);
+103
}
+104
+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
}
+115
+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
}
+124
+125
_emit(type, event) {
+126
for (const listener of this._listeners.get(type) || []) {
+127
listener(event);
+128
}
+129
}
+130
+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
}
+149
+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
}
+177
+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
}
+190
+191
function requireFile(file) {
+192
if (!fs.existsSync(file)) throw new Error(`Expected file: ${file}`);
+193
}
+194
+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
}
+217
+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 = 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 };
+251
}
+252
+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
}
+261
+262
async function main() {
+263
if (typeof WebSocket !== "function") {
+264
globalThis.WebSocket = LoopbackWebSocket;
+265
}
+266
requireFile(sigilBin);
+267
requireFile(withZig);
+268
requireFile(redirectsPath);
+269
+270
run(withZig, [
+271
sigilBin,
+272
"build",
+273
"--redirects",
+274
redirectsPath,
+275
"--config",
+276
"web",
+277
"--force",
+278
"--no-bundle",
+279
], { cwd: appDir });
+280
+281
requireFile(wasmPath);
+282
requireFile(bridgePath);
+283
requireFile(manifestPath);
+284
+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
}
+289
+290
const { server, sockets, messages, port } = await startEchoServer();
+291
try {
+292
vm.runInThisContext(fs.readFileSync(bridgePath, "utf8"), { filename: bridgePath });
+293
+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
});
+306
+307
globalThis.SigilWasmNet.setInstance(instance);
+308
wasi.start(instance);
+309
+310
callWithString(instance, instance.exports.sigil_wasm_eval, `
+311
(import (sigil websocket connection))
+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
`);
+316
+317
await new Promise((resolve) => setTimeout(resolve, 300));
+318
+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
`);
+324
+325
await new Promise((resolve) => setTimeout(resolve, 300));
+326
+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
`);
+337
+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
}
+347
+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
}
test/integration/test-wasm-websocket-client.shadded
@@ -0,0 +1,19 @@
+1
#!/usr/bin/env sh
+2
set -eu
+3
+4
if ! command -v node >/dev/null 2>&1; then
+5
echo "SKIP: node is not installed; skipping WASM websocket client smoke"
+6
exit 0
+7
fi
+8
+9
NODE_FLAGS="--experimental-wasi-unstable-preview1"
+10
if ! node -e 'process.exit(typeof WebSocket === "function" ? 0 : 1)' >/dev/null 2>&1; then
+11
if node --experimental-websocket -e 'process.exit(typeof WebSocket === "function" ? 0 : 1)' >/dev/null 2>&1; then
+12
NODE_FLAGS="$NODE_FLAGS --experimental-websocket"
+13
else
+14
echo "SKIP: node with global WebSocket is required for WASM websocket client smoke"
+15
exit 0
+16
fi
+17
fi
+18
+19
exec node $NODE_FLAGS test/integration/test-wasm-websocket-client.mjs