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