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