Commit543888f8Recorded6 May 2026Repositorysigil-irc
Add WASM IRC websocket smoke
Message
Add a Zig/WASI integration smoke that drives IRC message formatting and parsing through the public websocket facade and browser WebSocket bridge. Add a web config for sigil-irc, expose a WASM-safe protocol subset from the aggregate, and avoid importing crypto from batch helpers under wasm.nnVerification:n- sh test/integration/test-wasm-irc-websocket.shn- ../sigil/build/dev/bin/sigil build --redirects dev-redirects.sgl --forcen- SIGILFEATURES=wasm ../sigil/build/dev/bin/sigil -L build/dev/lib eval '(import (sigil irc)) (display (make-irc-command "PING" "x")) (newline)'n- SIGILFEATURES=wasm ../sigil/build/dev/bin/sigil -L build/dev/lib eval '(import (sigil irc batch)) (display (batch-start-line "r" "t"))'
Changed
package.sgl | 8 +++-
src/sigil/irc.sgl | 165 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
src/sigil/irc/batch.sgl | 24 +++++++-----
test/integration/apps/wasm-irc-websocket-smoke/package.sgl | 41 +++++++++++++++++++++
test/integration/apps/wasm-irc-websocket-smoke/src/wasm-irc-websocket-smoke/main.sgl | 11 ++++++
test/integration/test-wasm-irc-websocket.mjs | 216 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/integration/test-wasm-irc-websocket.sh | 19 ++++++++++
7 files changed, 460 insertions(+), 24 deletions(-)Diff
package.sglmodified
@@ -25,7 +25,13 @@
25
name: 'release 26
output-dir: "build/release" 27
debug?: #f−28
optimize: 2))+28
optimize: 2)+29
(config+30
name: 'web+31
output-dir: "build/web"+32
debug?: #f+33
optimize: 2+34
features: '(web wasm))) 35
36
dependencies: (list 37
(from-git url: "codeberg:sigil/sigil-socket" version: "^0.16")src/sigil/irc.sglmodified
@@ -69,19 +69,156 @@
69
;;; ``` 70
71
(define-library (sigil irc)−72
(import (sigil irc message)−73
(sigil irc tags)−74
(sigil irc identity)−75
(sigil irc numerics)−76
(sigil irc capability)−77
(sigil irc cap-negotiation)−78
(sigil irc sasl)−79
(sigil irc sasl-scram)−80
(sigil irc batch)−81
(sigil irc chathistory)−82
(sigil irc read-marker)−83
(sigil irc monitor)−84
(sigil irc connection))+72
(cond-expand+73
(wasm+74
(import (sigil irc message)+75
(sigil irc tags)+76
(sigil irc identity)+77
(sigil irc numerics)+78
(sigil irc capability)+79
(sigil irc cap-negotiation)+80
(sigil irc batch)+81
(sigil irc chathistory)+82
(sigil irc read-marker)+83
(sigil irc monitor))+84
+85
(export+86
;; --- (sigil irc message) ---+87
irc-message+88
irc-message?+89
irc-message-raw+90
irc-message-tags+91
irc-message-prefix+92
irc-message-nick+93
irc-message-user+94
irc-message-host+95
irc-message-command+96
irc-message-params+97
irc-message-trailing+98
irc-message-target+99
irc-message-text+100
irc-message-tag+101
irc-message-has-tag?+102
parse-irc-message+103
parse-irc-tags+104
irc-tag-escape+105
irc-tag-unescape+106
irc-tags->string+107
make-irc-command+108
make-irc-command/tags+109
irc-command->string+110
+111
;; --- (sigil irc tags) ---+112
TAG-TIME TAG-MSGID TAG-ACCOUNT TAG-BATCH TAG-LABEL TAG-REPLY TAG-TYPING+113
msg-time msg-msgid msg-account msg-batch msg-label msg-reply msg-typing+114
with-time with-msgid with-account with-batch with-label+115
+116
;; --- (sigil irc identity) ---+117
irc-identity+118
irc-identity?+119
make-irc-identity+120
irc-identity-nick+121
irc-identity-server+122
irc-identity->canonical+123
irc-identity->display+124
irc-identity=?+125
parse-irc-identity+126
irc-identity-from-prefix+127
irc-display-strip-server+128
+129
;; --- (sigil irc numerics) ---+130
irc-numeric-name+131
irc-numeric->symbol+132
RPL-WELCOME RPL-YOURHOST RPL-CREATED RPL-MYINFO RPL-ISUPPORT+133
RPL-LUSERCLIENT RPL-LUSEROP RPL-LUSERUNKNOWN RPL-LUSERCHANNELS RPL-LUSERME+134
RPL-AWAY RPL-WHOISUSER RPL-WHOISSERVER RPL-WHOISIDLE RPL-ENDOFWHO+135
RPL-ENDOFWHOIS RPL-WHOISCHANNELS RPL-WHOISACCOUNT+136
RPL-LIST RPL-LISTEND RPL-CHANNELMODEIS RPL-NOTOPIC RPL-TOPIC RPL-TOPICWHOTIME+137
RPL-WHOREPLY RPL-NAMREPLY RPL-ENDOFNAMES+138
RPL-MOTDSTART RPL-MOTD RPL-ENDOFMOTD+139
ERR-NOSUCHNICK ERR-NOSUCHSERVER ERR-NOSUCHCHANNEL ERR-CANNOTSENDTOCHAN+140
ERR-UNKNOWNCOMMAND ERR-NOMOTD ERR-NONICKNAMEGIVEN ERR-ERRONEUSNICKNAME+141
ERR-NICKNAMEINUSE ERR-NICKCOLLISION ERR-NOTREGISTERED ERR-NEEDMOREPARAMS+142
ERR-ALREADYREGISTERED ERR-PASSWDMISMATCH ERR-CHANOPRIVSNEEDED+143
ERR-INVITEONLYCHAN ERR-BANNEDFROMCHAN ERR-BADCHANNELKEY+144
RPL-LOGGEDIN RPL-LOGGEDOUT ERR-NICKLOCKED RPL-SASLSUCCESS+145
ERR-SASLFAIL ERR-SASLTOOLONG ERR-SASLABORTED ERR-SASLALREADY RPL-SASLMECHS+146
RPL-MONONLINE RPL-MONOFFLINE RPL-MONLIST RPL-ENDOFMONLIST ERR-MONLISTFULL+147
CMD-FAIL CMD-WARN CMD-NOTE+148
+149
;; --- (sigil irc capability) ---+150
cap cap? make-cap cap-name cap-value+151
cap->string string->cap parse-cap-list+152
CAP-SASL CAP-MESSAGE-TAGS CAP-SERVER-TIME CAP-ACCOUNT-TAG CAP-ACCOUNT-NOTIFY+153
CAP-EXTENDED-JOIN CAP-USERHOST-IN-NAMES CAP-MULTI-PREFIX CAP-AWAY-NOTIFY+154
CAP-CHGHOST CAP-INVITE-NOTIFY CAP-SETNAME CAP-BATCH CAP-LABELED-RESPONSE+155
CAP-ECHO-MESSAGE CAP-CAP-NOTIFY+156
CAP-CHATHISTORY CAP-READ-MARKER CAP-MONITOR+157
tier-1-caps tier-2-caps standard-caps+158
+159
;; --- (sigil irc cap-negotiation) ---+160
cap-client-state cap-client-state?+161
make-cap-client-state+162
cap-client-state-phase cap-client-state-server-caps+163
cap-client-state-requested cap-client-state-acked cap-client-state-nakked+164
cap-client-state-done?+165
cap-client-start cap-client-advance+166
cap-server-state cap-server-state?+167
make-cap-server-state+168
cap-server-state-phase cap-server-state-supported cap-server-state-enabled+169
cap-server-state-cap-302+170
cap-server-state-done?+171
cap-server-advance+172
+173
;; --- (sigil irc batch) ---+174
batch-start-line batch-end-line make-batch-reftag+175
batch-context batch-context? make-batch-context+176
batch-context-open-tags batch-context-types+177
batch-context-open? batch-open! batch-close!+178
batch-tag-of batch-type-of+179
+180
;; --- (sigil irc chathistory) ---+181
CHATHISTORY-BATCH-TYPE+182
CHATHISTORY-BEFORE CHATHISTORY-AFTER CHATHISTORY-LATEST+183
CHATHISTORY-AROUND CHATHISTORY-BETWEEN CHATHISTORY-TARGETS+184
CHATHISTORY-FAIL-INVALID-PARAMS CHATHISTORY-FAIL-INVALID-TARGET+185
CHATHISTORY-FAIL-MESSAGE-ERROR CHATHISTORY-FAIL-NEED-MORE-PARAMS+186
chathistory-selector chathistory-selector?+187
make-chathistory-selector+188
chathistory-selector-kind chathistory-selector-value+189
parse-chathistory-selector chathistory-selector->string+190
chathistory-query chathistory-query?+191
chathistory-query-subcommand chathistory-query-target+192
chathistory-query-selector1 chathistory-query-selector2 chathistory-query-limit+193
parse-chathistory-line chathistory-line+194
+195
;; --- (sigil irc read-marker) ---+196
READ-MARKER-CAP+197
read-marker read-marker? make-read-marker+198
read-marker-target read-marker-timestamp+199
parse-markread-line markread-query-line markread-set-line+200
+201
;; --- (sigil irc monitor) ---+202
MONITOR-ADD MONITOR-REMOVE MONITOR-CLEAR MONITOR-LIST MONITOR-STATUS+203
monitor-command monitor-command?+204
monitor-command-subcommand monitor-command-targets+205
parse-monitor-line monitor-line+206
monitor-online-line monitor-offline-line+207
monitor-list-line monitor-end-of-list-line monitor-list-full-line))+208
(else+209
(import (sigil irc message)+210
(sigil irc tags)+211
(sigil irc identity)+212
(sigil irc numerics)+213
(sigil irc capability)+214
(sigil irc cap-negotiation)+215
(sigil irc sasl)+216
(sigil irc sasl-scram)+217
(sigil irc batch)+218
(sigil irc chathistory)+219
(sigil irc read-marker)+220
(sigil irc monitor)+221
(sigil irc connection)) 222
223
(export 224
;; --- (sigil irc message) ---@@ -261,4 +398,4 @@
398
irc-mode 399
irc-nickserv-identify 400
irc-run−264
irc-tick))+401
irc-tick))))src/sigil/irc/batch.sglmodified
@@ -25,8 +25,10 @@
25
(import (sigil core) 26
(sigil string) 27
(sigil struct)−28
(sigil crypto) 28
(sigil irc message))+29
(cond-expand+30
((not wasm)+31
(import (sigil crypto)))) 32
33
(export 34
;; Wire helpers@@ -89,14 +91,18 @@
91
;;; yielding 11 URL-safe-ish chars. 92
(define (make-batch-reftag) 93
(: -> string?)−92
(let* ((bytes (random-bytes 8))−93
(encoded (base64-encode bytes)))−94
;; Strip any padding `=` and replace `+` / `/` with safe chars.−95
(string-replace−96
(string-replace−97
(string-replace encoded "=" "")−98
"+" "x")−99
"/" "y")))+94
(cond-expand+95
(wasm+96
(error "make-batch-reftag: random crypto is not available under wasm"))+97
(else+98
(let* ((bytes (random-bytes 8))+99
(encoded (base64-encode bytes)))+100
;; Strip any padding `=` and replace `+` / `/` with safe chars.+101
(string-replace+102
(string-replace+103
(string-replace encoded "=" "")+104
"+" "x")+105
"/" "y"))))) 106
107
108
;; ============================================================test/integration/apps/wasm-irc-websocket-smoke/package.sgladded
@@ -0,0 +1,41 @@
+1
;;; wasm-irc-websocket-smoke - Browser WebSocket transport smoke for sigil-irc.+2
+3
(package+4
name: "wasm-irc-websocket-smoke"+5
version: "0.1.0"+6
sigil: "^0.16"+7
entry: '(wasm-irc-websocket-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: "../../../../../sigil-websocket" package: "sigil-websocket")+33
(from-path dir: "../../../.." package: "sigil-irc"))+34
+35
tasks: (list+36
(task+37
name: 'build+38
description: "Build the WASM IRC over WebSocket smoke fixture"+39
steps: (list+40
(compile-sigil-modules sources: "src/**/*.sgl")+41
(link-web-application name: "wasm-irc-websocket-smoke")))))test/integration/apps/wasm-irc-websocket-smoke/src/wasm-irc-websocket-smoke/main.sgladded
@@ -0,0 +1,11 @@
+1
(define-library (wasm-irc-websocket-smoke main)+2
(import (sigil core)+3
(sigil websocket)+4
(sigil irc))+5
+6
(export main)+7
+8
(begin+9
(define (main)+10
(display "wasm-irc-websocket-smoke")+11
(newline))))test/integration/test-wasm-irc-websocket.mjsadded
@@ -0,0 +1,216 @@
+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 path from "node:path";+8
import { fileURLToPath } from "node:url";+9
import vm from "node:vm";+10
import { WASI } from "node:wasi";+11
+12
const scriptDir = path.dirname(fileURLToPath(import.meta.url));+13
const repoRoot = path.resolve(scriptDir, "../..");+14
const workspaceRoot = path.resolve(repoRoot, "..");+15
const sigilRoot = path.join(workspaceRoot, "sigil");+16
const appDir = path.join(repoRoot, "test/integration/apps/wasm-irc-websocket-smoke");+17
const buildDir = path.join(appDir, "build/web");+18
const wasmPath = path.join(buildDir, "wasm-irc-websocket-smoke.wasm");+19
const bridgePath = path.join(buildDir, "assets/sigil-wasm-net.js");+20
const manifestPath = path.join(buildDir, "sigil-wasm-bridges.json");+21
const libPath = path.join(buildDir, "lib");+22
const sigilBin = path.join(sigilRoot, "build/dev/bin/sigil");+23
const withZig = path.join(sigilRoot, "scripts/with-zig");+24
+25
function encodeServerFrame(data, opcode) {+26
const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);+27
const header = [0x80 | opcode];+28
if (payload.length < 126) {+29
header.push(payload.length);+30
} else if (payload.length < 65536) {+31
header.push(126, (payload.length >> 8) & 0xff, payload.length & 0xff);+32
} else {+33
throw new Error("large frames are not needed for this smoke test");+34
}+35
return Buffer.concat([Buffer.from(header), payload]);+36
}+37
+38
function decodeClientFrame(buffer) {+39
const opcode = buffer[0] & 0x0f;+40
let length = buffer[1] & 0x7f;+41
let offset = 2;+42
if (length === 126) {+43
length = buffer.readUInt16BE(offset);+44
offset += 2;+45
} else if (length === 127) {+46
throw new Error("large frames are not needed for this smoke test");+47
}+48
if ((buffer[1] & 0x80) === 0) {+49
throw new Error("client frame was not masked");+50
}+51
const mask = buffer.subarray(offset, offset + 4);+52
offset += 4;+53
const payload = buffer.subarray(offset, offset + length);+54
const decoded = Buffer.alloc(length);+55
for (let i = 0; i < length; i += 1) {+56
decoded[i] = payload[i] ^ mask[i % 4];+57
}+58
return { opcode, text: decoded.toString("utf8") };+59
}+60
+61
function run(command, args, options = {}) {+62
const result = spawnSync(command, args, { encoding: "utf8", ...options });+63
if (result.error) throw result.error;+64
if (result.stdout) process.stdout.write(result.stdout);+65
if (result.stderr) process.stderr.write(result.stderr);+66
if (result.status !== 0) {+67
console.error("Command failed:");+68
console.error(` cwd: ${options.cwd || process.cwd()}`);+69
console.error(` command: ${command} ${args.join(" ")}`);+70
throw new Error(`${command} ${args.join(" ")} exited with ${result.status}`);+71
}+72
}+73
+74
function requireFile(file) {+75
if (!fs.existsSync(file)) throw new Error(`Expected file: ${file}`);+76
}+77
+78
async function startEchoServer() {+79
const messages = [];+80
const sockets = new Set();+81
const server = http.createServer();+82
server.on("upgrade", (req, socket) => {+83
sockets.add(socket);+84
socket.on("close", () => sockets.delete(socket));+85
const key = req.headers["sec-websocket-key"];+86
const accept = crypto+87
.createHash("sha1")+88
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)+89
.digest("base64");+90
socket.write([+91
"HTTP/1.1 101 Switching Protocols",+92
"Upgrade: websocket",+93
"Connection: Upgrade",+94
`Sec-WebSocket-Accept: ${accept}`,+95
"",+96
"",+97
].join("\r\n"));+98
socket.on("data", (buffer) => {+99
const frame = decodeClientFrame(buffer);+100
if (frame.opcode === 1) {+101
messages.push(frame.text);+102
socket.write(encodeServerFrame(frame.text, 1));+103
}+104
});+105
});+106
await new Promise((resolve, reject) => {+107
server.once("error", reject);+108
server.listen(0, "127.0.0.1", resolve);+109
});+110
return { server, sockets, messages, port: server.address().port };+111
}+112
+113
function callWithString(instance, fn, code) {+114
const bytes = new TextEncoder().encode(`${code}\0`);+115
const ptr = instance.exports.malloc(bytes.length);+116
new Uint8Array(instance.exports.memory.buffer, ptr, bytes.length).set(bytes);+117
const result = fn(ptr);+118
instance.exports.free(ptr);+119
if (result !== 0) throw new Error(`Sigil eval failed with status ${result}`);+120
}+121
+122
async function main() {+123
requireFile(sigilBin);+124
requireFile(withZig);+125
+126
run(withZig, [+127
sigilBin,+128
"build",+129
"--config",+130
"web",+131
"--force",+132
"--no-bundle",+133
], {+134
cwd: appDir,+135
env: { ...process.env, SIGIL_FEATURES: "web,wasm" },+136
});+137
+138
requireFile(wasmPath);+139
requireFile(bridgePath);+140
requireFile(manifestPath);+141
+142
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));+143
if (!manifest.bridges?.some((bridge) => bridge.name === "net")) {+144
throw new Error("Bridge manifest does not include net bridge");+145
}+146
+147
const { server, sockets, messages, port } = await startEchoServer();+148
try {+149
vm.runInThisContext(fs.readFileSync(bridgePath, "utf8"), { filename: bridgePath });+150
+151
const wasi = new WASI({+152
version: "preview1",+153
args: ["wasm-irc-websocket-smoke"],+154
env: { SIGIL_FEATURES: "web,wasm" },+155
preopens: { "/lib": libPath },+156
});+157
const bridgeImports = globalThis.SigilWasmNet.createImports();+158
const wasm = await WebAssembly.compile(fs.readFileSync(wasmPath));+159
const instance = await WebAssembly.instantiate(wasm, {+160
wasi_snapshot_preview1: wasi.wasiImport,+161
...bridgeImports,+162
});+163
+164
globalThis.SigilWasmNet.setInstance(instance);+165
wasi.start(instance);+166
+167
callWithString(instance, instance.exports.sigil_wasm_eval, `+168
(import (sigil websocket)+169
(sigil irc))+170
(define conn (ws-connect "ws://127.0.0.1:${port}"))+171
(if (not conn) (error "ws-connect failed"))+172
(display "irc-ws-connect-ok") (newline)+173
`);+174
+175
await new Promise((resolve) => setTimeout(resolve, 300));+176
+177
callWithString(instance, instance.exports.sigil_wasm_eval, `+178
(ws-receive conn)+179
(define outbound (make-irc-command "PRIVMSG" "#sigil" "hello from wasm irc"))+180
(ws-send conn outbound)+181
(display "irc-ws-send-ok") (newline)+182
`);+183
+184
await new Promise((resolve) => setTimeout(resolve, 300));+185
+186
callWithString(instance, instance.exports.sigil_wasm_eval, `+187
(define reply (ws-receive conn))+188
(if (not (ws-message? reply)) (error "missing websocket reply"))+189
(define parsed (parse-irc-message (ws-message-data reply)))+190
(if (and parsed+191
(eq? (irc-message-command parsed) 'PRIVMSG)+192
(string=? (irc-message-target parsed) "#sigil")+193
(string=? (irc-message-text parsed) "hello from wasm irc"))+194
(display "irc-parse-roundtrip-ok")+195
(error "bad irc websocket roundtrip"))+196
(newline)+197
(ws-close conn)+198
`);+199
+200
if (!messages.includes("PRIVMSG #sigil :hello from wasm irc")) {+201
throw new Error(`echo server did not receive IRC PRIVMSG; saw ${JSON.stringify(messages)}`);+202
}+203
console.log("irc-websocket-wasm-ok");+204
} finally {+205
for (const socket of sockets) socket.destroy();+206
await new Promise((resolve) => server.close(resolve));+207
}+208
}+209
+210
try {+211
await main();+212
} finally {+213
if (process.env.SIGIL_KEEP_WASM_IRC_WEBSOCKET_SMOKE_BUILD !== "1") {+214
fs.rmSync(path.join(appDir, "build"), { force: true, recursive: true });+215
}+216
}test/integration/test-wasm-irc-websocket.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 IRC websocket 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 IRC websocket smoke"+15
exit 0+16
fi+17
fi+18
+19
exec node $NODE_FLAGS test/integration/test-wasm-irc-websocket.mjs