Commit62612841Recorded6 May 2026Repositorysigil-irc

Add IRC WebSocket adapter package

Message

Convert sigil-irc into a two-package workspace so the core sigil-irc package remains free of a sigil-websocket dependency. Keep sigil-irc as the default package and add sigil-irc-websocket as the optional adapter package.

The adapter provides (sigil irc websocket), composing the public sigil-websocket client API with sigil-irc message parsing. It exposes connect, explicit registration, send helpers, close, and parsed receive without importing the legacy native IRC connection, socket/TLS, crypto, or SASL modules.

Update the WASM smoke to depend on both workspace packages and exercise the adapter, including registration, PRIVMSG send, parsed receive, and multi-frame server harness draining.

Verification: sh test/integration/test-wasm-irc-websocket.sh; sigil build --redirects ./dev-redirects.sgl --force; sigil test --redirects ./dev-redirects.sgl (163/163).

Changed
 README.md                                                                            |  30 +++++++++++++-
 dev-redirects.sgl                                                                    |   3 ++
 package.sgl                                                                          |  36 +++++------------
 packages/sigil-irc-websocket/package.sgl                                             |  25 ++++++++++++
 packages/sigil-irc-websocket/src/sigil/irc/websocket.sgl                             | 238 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 packages/sigil-irc/package.sgl                                                       |  32 +++++++++++++++
 test/integration/apps/wasm-irc-websocket-smoke/package.sgl                           |   3 +-
 test/integration/apps/wasm-irc-websocket-smoke/src/wasm-irc-websocket-smoke/main.sgl |   4 +-
 test/integration/test-wasm-irc-websocket.mjs                                         |  80 ++++++++++++++++++++++++------------
 9 files changed, 397 insertions(+), 54 deletions(-)
Diff
README.mdmodified
@@ -36,12 +36,17 @@ The canonical IRC protocol library for the [Sigil](https://codeberg.org/sigil/si
36
37
The `(sigil irc connection)` module retains a high-level client convenience API with TLS, SASL, channel tracking, NickServ identify, and an event loop — backwards compatible with prior sigil-irc releases.
38
+39
### IRC over WebSocket
+40
+41
The sibling `sigil-irc-websocket` package provides `(sigil irc websocket)`, composing the public `sigil-websocket` client API with the IRC message parser for browser/WASM use. It avoids adding a WebSocket dependency to the core `sigil-irc` package used by native/desktop IRC clients.
+42
43
## Usage
44
45
### Client (high-level convenience)
46
47
```scheme
44
(import (sigil irc))
+48
(import (sigil irc)
+49
(sigil irc websocket))
50
51
(define conn (make-irc-connection
52
server: "irc.libera.chat"
@@ -58,6 +63,25 @@ The `(sigil irc connection)` module retains a high-level client convenience API
63
(irc-run conn)
64
```
65
+66
### Browser/WASM IRC over WebSocket
+67
+68
```scheme
+69
(import (sigil irc))
+70
+71
(define session
+72
(irc-ws-connect
+73
url: "wss://enclave.example/irc"
+74
nick: "web-client"))
+75
+76
(irc-ws-register session)
+77
(irc-ws-privmsg session "#sigil" "hello")
+78
+79
(let ((msg (irc-ws-receive session)))
+80
(when (irc-message? msg)
+81
(display (irc-message-command msg))
+82
(newline)))
+83
```
+84
85
### Server (consumer drives I/O)
86
87
```scheme
@@ -128,6 +152,10 @@ This is a federation-readiness constraint: future CrafterNet sovereign-per-serve
152
- sigil-socket (TCP)
153
- sigil-tls (TLS for the convenience client)
154
- sigil-crypto v0.15.0+ (base64, HMAC, PBKDF2 — SCRAM needs the v0.15 SHA-256 byte primitives)
+155
The optional `sigil-irc-websocket` package also depends on:
+156
+157
- sigil-irc
+158
- sigil-websocket
159
160
## Building / testing
161
dev-redirects.sglmodified
@@ -5,6 +5,9 @@
5
(for-repo
6
url: "codeberg:sigil/sigil-lang"
7
use: (from-path dir: "../sigil-lang"))
+8
(for-repo
+9
url: "codeberg:sigil/sigil-websocket"
+10
use: (from-path dir: "../sigil-websocket"))
11
(for-repo
12
url: "codeberg:sigil/sigil-socket"
13
use: (from-path dir: "../sigil-socket"))
package.sglmodified
@@ -1,20 +1,19 @@
1
;;; sigil-irc - IRC protocol library
+1
;;; sigil-irc workspace
2
;;;
3
;;; Provides IRC parser, types, capability descriptors, numeric constants,
4
;;; and state-machine helpers covering both client and server flows for
5
;;; modern IRCv3 (Tier 1 ratified caps + Tier 2 high-value drafts:
6
;;; chathistory, draft/read-marker, MONITOR, SCRAM-SHA-256). Includes a
7
;;; convenience client API with TLS support and cooperative non-blocking I/O.
+3
;;; - sigil-irc: protocol library plus the legacy native TCP/TLS client.
+4
;;; - sigil-irc-websocket: optional IRC-over-WebSocket adapter for browser
+5
;;; clients such as Enclave.
6
9
(package
+7
(workspace
8
name: "sigil-irc"
9
version: "0.16.0"
12
sigil: "^0.16"
13
description: "IRC protocol library — both directions, IRCv3 + drafts"
+10
description: "IRC protocol libraries for Sigil"
11
url: "https://codeberg.org/sigil/sigil-irc"
12
license: "BSD-3-Clause"
13
authors: (list "David Wilson <[email protected]>")
14
+15
default-package: "sigil-irc"
+16
17
configs: (list
18
(config
19
name: 'dev
@@ -33,19 +32,6 @@
32
optimize: 2
33
features: '(web wasm)))
34
36
dependencies: (list
37
(from-git url: "codeberg:sigil/sigil-socket" version: "^0.16")
38
(from-git url: "codeberg:sigil/sigil-tls" version: "^0.16")
39
(from-git url: "codeberg:sigil/sigil-crypto" version: "^0.16"))
40
41
dev-dependencies: (list
42
(from-git url: "codeberg:sigil/sigil-test" package: "sigil-test" version: "^0.16")
43
(from-git url: "codeberg:sigil/sigil-test" package: "sigil-test-runner" version: "^0.16"))
44
45
tasks: (list
46
(task
47
name: 'build
48
description: "Compile sigil-irc modules"
49
steps: (list
50
(compile-sigil-modules sources: "src/**/*.sgl"
51
output-dir: (config-output-subdir "lib"))))))
+35
packages: (list
+36
"packages/sigil-irc"
+37
"packages/sigil-irc-websocket"))
packages/sigil-irc-websocket/package.sgladded
@@ -0,0 +1,25 @@
+1
;;; sigil-irc-websocket - IRC-over-WebSocket adapter
+2
;;;
+3
;;; Composes sigil-irc protocol parsing/serialization with sigil-websocket
+4
;;; transport for browser/WASM clients. Kept separate from sigil-irc so
+5
;;; desktop IRC clients do not inherit a WebSocket dependency.
+6
+7
(package
+8
name: "sigil-irc-websocket"
+9
sigil: "^0.16"
+10
description: "IRC-over-WebSocket adapter for Sigil browser clients"
+11
url: "https://codeberg.org/sigil/sigil-irc"
+12
license: "BSD-3-Clause"
+13
authors: (list "David Wilson <[email protected]>")
+14
+15
dependencies: (list
+16
(from-workspace name: "sigil-irc")
+17
(from-git url: "codeberg:sigil/sigil-websocket" version: "^0.16"))
+18
+19
tasks: (list
+20
(task
+21
name: 'build
+22
description: "Compile sigil-irc-websocket modules"
+23
steps: (list
+24
(compile-sigil-modules sources: "src/**/*.sgl"
+25
output-dir: (config-output-subdir "lib"))))))
packages/sigil-irc-websocket/src/sigil/irc/websocket.sgladded
@@ -0,0 +1,238 @@
+1
;;; (sigil irc websocket) - IRC-over-WebSocket client/session helper
+2
;;;
+3
;;; This module composes the public `(sigil websocket)` client API with the
+4
;;; protocol-only `(sigil irc message)` layer. It intentionally does not import
+5
;;; the native IRC connection, socket, TLS, crypto, or SASL modules, so browser
+6
;;; WASM builds can use it as a small Enclave-facing transport adapter.
+7
+8
(define-library (sigil irc websocket)
+9
(import (sigil core)
+10
(sigil string)
+11
(sigil struct)
+12
(sigil websocket)
+13
(sigil irc message))
+14
+15
(export
+16
irc-ws-session
+17
make-irc-ws-session
+18
irc-ws-session?
+19
irc-ws-session-url
+20
irc-ws-session-nick
+21
irc-ws-session-user
+22
irc-ws-session-realname
+23
irc-ws-session-websocket
+24
irc-ws-session-state
+25
+26
irc-ws-connect
+27
irc-ws-close
+28
irc-ws-connected?
+29
irc-ws-register
+30
+31
irc-ws-send
+32
irc-ws-command
+33
irc-ws-privmsg
+34
irc-ws-notice
+35
irc-ws-join
+36
irc-ws-part
+37
irc-ws-quit
+38
+39
irc-ws-receive)
+40
+41
(begin
+42
+43
(define-struct irc-ws-session
+44
(url)
+45
(nick default: #f)
+46
(user default: #f)
+47
(realname default: "")
+48
(websocket default: #f mutable: #t)
+49
(state default: 'disconnected mutable: #t)
+50
(buffer default: "" mutable: #t)
+51
(queue default: '() mutable: #t))
+52
+53
(define (make-irc-ws-session (keys: (url #f)
+54
(nick #f)
+55
(user #f)
+56
(realname "")))
+57
(: (url: any?) (nick: any?) (user: any?) (realname: string?) -> irc-ws-session?)
+58
(unless url
+59
(error "make-irc-ws-session: url: is required"))
+60
(irc-ws-session
+61
url: url
+62
nick: nick
+63
user: (or user nick)
+64
realname: (if (and nick (string=? realname "")) nick realname)))
+65
+66
(define (line-ended? text)
+67
(let ((len (string-length text)))
+68
(and (> len 0)
+69
(char=? (string-ref text (- len 1)) #\newline))))
+70
+71
(define (strip-line-ending line)
+72
(let ((len (string-length line)))
+73
(cond
+74
((and (> len 0)
+75
(char=? (string-ref line (- len 1)) #\newline))
+76
(let ((end (- len 1)))
+77
(if (and (> end 0)
+78
(char=? (string-ref line (- end 1)) #\return))
+79
(substring line 0 (- end 1))
+80
(substring line 0 end))))
+81
(else line))))
+82
+83
(define (append-line-ending line)
+84
(if (line-ended? line)
+85
line
+86
(string-append line "\r\n")))
+87
+88
(define (contains-line-ending? text)
+89
(if (string-index text (lambda (c) (char=? c #\newline)))
+90
#t
+91
#f))
+92
+93
(define (enqueue-lines! session text)
+94
(let ((combined (string-append (irc-ws-session-buffer session) text)))
+95
(let loop ((buffer combined)
+96
(lines '()))
+97
(let ((newline-pos (string-index buffer (lambda (c) (char=? c #\newline)))))
+98
(if newline-pos
+99
(let ((line (substring buffer 0 (+ newline-pos 1)))
+100
(rest (substring buffer (+ newline-pos 1) (string-length buffer))))
+101
(loop rest (cons (strip-line-ending line) lines)))
+102
(begin
+103
(set-irc-ws-session-buffer! session buffer)
+104
(when (pair? lines)
+105
(set-irc-ws-session-queue!
+106
session
+107
(append (irc-ws-session-queue session)
+108
(reverse lines))))))))))
+109
+110
(define (enqueue-message! session text)
+111
(if (or (contains-line-ending? text)
+112
(not (string=? (irc-ws-session-buffer session) "")))
+113
(enqueue-lines! session text)
+114
(set-irc-ws-session-queue!
+115
session
+116
(append (irc-ws-session-queue session)
+117
(list text)))))
+118
+119
(define (pop-message! session)
+120
(let ((queue (irc-ws-session-queue session)))
+121
(if (null? queue)
+122
#f
+123
(let ((line (car queue)))
+124
(set-irc-ws-session-queue! session (cdr queue))
+125
(parse-irc-message line)))))
+126
+127
(define (irc-ws-register session)
+128
(: irc-ws-session? -> void?)
+129
(let ((nick (irc-ws-session-nick session)))
+130
(unless nick
+131
(error "irc-ws-register: session has no nick"))
+132
(irc-ws-command session "NICK" nick)
+133
(irc-ws-command session
+134
"USER"
+135
(or (irc-ws-session-user session) nick)
+136
"0"
+137
"*"
+138
(or (irc-ws-session-realname session) nick))))
+139
+140
(define (irc-ws-connect . args)
+141
(: any? ... -> any?)
+142
(let ((session
+143
(if (and (pair? args) (irc-ws-session? (car args)))
+144
(car args)
+145
(apply make-irc-ws-session args))))
+146
(when (not (eq? (irc-ws-session-state session) 'disconnected))
+147
(error "irc-ws-connect: already connected or connecting"))
+148
(set-irc-ws-session-state! session 'connecting)
+149
(let ((websocket (ws-connect (irc-ws-session-url session))))
+150
(if (not websocket)
+151
(begin
+152
(set-irc-ws-session-state! session 'disconnected)
+153
#f)
+154
(begin
+155
(set-irc-ws-session-websocket! session websocket)
+156
(set-irc-ws-session-state! session 'connected)
+157
session)))))
+158
+159
(define (irc-ws-connected? session)
+160
(: irc-ws-session? -> boolean?)
+161
(and (eq? (irc-ws-session-state session) 'connected)
+162
(irc-ws-session-websocket session)
+163
(ws-connected? (irc-ws-session-websocket session))))
+164
+165
(define (irc-ws-close session)
+166
(: irc-ws-session? -> void?)
+167
(when (irc-ws-session-websocket session)
+168
(ws-close (irc-ws-session-websocket session)))
+169
(set-irc-ws-session-websocket! session #f)
+170
(set-irc-ws-session-state! session 'disconnected)
+171
(set-irc-ws-session-buffer! session "")
+172
(set-irc-ws-session-queue! session '()))
+173
+174
(define (irc-ws-send session line)
+175
(: irc-ws-session? string? -> void?)
+176
(unless (irc-ws-connected? session)
+177
(error "irc-ws-send: session is not connected"))
+178
(ws-send (irc-ws-session-websocket session)
+179
(append-line-ending line)))
+180
+181
(define (irc-ws-command session command . args)
+182
(: irc-ws-session? string? string? ... -> void?)
+183
(irc-ws-send session
+184
(apply make-irc-command command args)))
+185
+186
(define (irc-ws-privmsg session target text)
+187
(: irc-ws-session? string? string? -> void?)
+188
(irc-ws-send session
+189
(make-trailing-command "PRIVMSG" target text)))
+190
+191
(define (irc-ws-notice session target text)
+192
(: irc-ws-session? string? string? -> void?)
+193
(irc-ws-send session
+194
(make-trailing-command "NOTICE" target text)))
+195
+196
(define (irc-ws-join session channel . key)
+197
(: irc-ws-session? string? string? ... -> void?)
+198
(if (pair? key)
+199
(irc-ws-command session "JOIN" channel (car key))
+200
(irc-ws-command session "JOIN" channel)))
+201
+202
(define (irc-ws-part session channel . message)
+203
(: irc-ws-session? string? string? ... -> void?)
+204
(if (pair? message)
+205
(irc-ws-send session
+206
(make-trailing-command "PART" channel (car message)))
+207
(irc-ws-command session "PART" channel)))
+208
+209
(define (irc-ws-quit session . message)
+210
(: irc-ws-session? string? ... -> void?)
+211
(when (irc-ws-connected? session)
+212
(if (pair? message)
+213
(irc-ws-send session (make-trailing-command "QUIT" (car message)))
+214
(irc-ws-command session "QUIT")))
+215
(irc-ws-close session))
+216
+217
(define (irc-ws-receive session)
+218
(: irc-ws-session? -> any?)
+219
(let ((queued (pop-message! session)))
+220
(if queued
+221
queued
+222
(let ((websocket (irc-ws-session-websocket session)))
+223
(if (not websocket)
+224
'closed
+225
(let ((message (ws-receive websocket)))
+226
(cond
+227
((ws-message? message)
+228
(if (eq? (ws-message-type message) 'text)
+229
(begin
+230
(enqueue-message! session (ws-message-data message))
+231
(pop-message! session))
+232
#f))
+233
((eq? message 'closed)
+234
(set-irc-ws-session-state! session 'disconnected)
+235
'closed)
+236
(else message)))))))
+237
+238
)))
packages/sigil-irc/package.sgladded
@@ -0,0 +1,32 @@
+1
;;; sigil-irc - IRC protocol library
+2
;;;
+3
;;; Provides IRC parser, types, capability descriptors, numeric constants,
+4
;;; and state-machine helpers covering both client and server flows for
+5
;;; modern IRCv3 (Tier 1 ratified caps + Tier 2 high-value drafts:
+6
;;; chathistory, draft/read-marker, MONITOR, SCRAM-SHA-256). Includes a
+7
;;; convenience client API with TLS support and cooperative non-blocking I/O.
+8
+9
(package
+10
name: "sigil-irc"
+11
sigil: "^0.16"
+12
description: "IRC protocol library — both directions, IRCv3 + drafts"
+13
url: "https://codeberg.org/sigil/sigil-irc"
+14
license: "BSD-3-Clause"
+15
authors: (list "David Wilson <[email protected]>")
+16
+17
dependencies: (list
+18
(from-git url: "codeberg:sigil/sigil-socket" version: "^0.16")
+19
(from-git url: "codeberg:sigil/sigil-tls" version: "^0.16")
+20
(from-git url: "codeberg:sigil/sigil-crypto" version: "^0.16"))
+21
+22
dev-dependencies: (list
+23
(from-git url: "codeberg:sigil/sigil-test" package: "sigil-test" version: "^0.16")
+24
(from-git url: "codeberg:sigil/sigil-test" package: "sigil-test-runner" version: "^0.16"))
+25
+26
tasks: (list
+27
(task
+28
name: 'build
+29
description: "Compile sigil-irc modules"
+30
steps: (list
+31
(compile-sigil-modules sources: "../../src/**/*.sgl"
+32
output-dir: (config-output-subdir "lib"))))))
test/integration/apps/wasm-irc-websocket-smoke/package.sglmodified
@@ -30,7 +30,8 @@
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"))
+33
(from-path dir: "../../../.." package: "sigil-irc")
+34
(from-path dir: "../../../.." package: "sigil-irc-websocket"))
35
36
tasks: (list
37
(task
test/integration/apps/wasm-irc-websocket-smoke/src/wasm-irc-websocket-smoke/main.sglmodified
@@ -1,7 +1,7 @@
1
(define-library (wasm-irc-websocket-smoke main)
2
(import (sigil core)
3
(sigil websocket)
4
(sigil irc))
+3
(sigil irc)
+4
(sigil irc websocket))
5
6
(export main)
7
test/integration/test-wasm-irc-websocket.mjsmodified
@@ -35,10 +35,10 @@ function encodeServerFrame(data, opcode) {
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;
+38
function decodeClientFrame(buffer, start = 0) {
+39
const opcode = buffer[start] & 0x0f;
+40
let length = buffer[start + 1] & 0x7f;
+41
let offset = start + 2;
42
if (length === 126) {
43
length = buffer.readUInt16BE(offset);
44
offset += 2;
@@ -55,7 +55,18 @@ function decodeClientFrame(buffer) {
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") };
+58
return { opcode, text: decoded.toString("utf8"), bytesConsumed: offset + length - start };
+59
}
+60
+61
function decodeClientFrames(buffer) {
+62
const frames = [];
+63
let offset = 0;
+64
while (offset < buffer.length) {
+65
const frame = decodeClientFrame(buffer, offset);
+66
frames.push(frame);
+67
offset += frame.bytesConsumed;
+68
}
+69
return frames;
70
}
71
72
function run(command, args, options = {}) {
@@ -96,10 +107,13 @@ async function startEchoServer() {
107
"",
108
].join("\r\n"));
109
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));
+110
for (const frame of decodeClientFrames(buffer)) {
+111
if (frame.opcode === 1) {
+112
messages.push(frame.text);
+113
if (frame.text.startsWith("PRIVMSG ")) {
+114
socket.write(encodeServerFrame(frame.text, 1));
+115
}
+116
}
117
}
118
});
119
});
@@ -165,39 +179,55 @@ async function main() {
179
wasi.start(instance);
180
181
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)
+182
(import (sigil irc)
+183
(sigil irc websocket))
+184
(define session
+185
(irc-ws-connect
+186
url: "ws://127.0.0.1:${port}"
+187
nick: "wasm-irc"))
+188
(if (not session) (error "irc-ws-connect failed"))
+189
(if (not (irc-ws-connected? session)) (error "irc-ws session not connected"))
+190
(display "irc-ws-helper-connect-ok") (newline)
191
`);
192
193
await new Promise((resolve) => setTimeout(resolve, 300));
194
195
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)
+196
(irc-ws-receive session)
+197
(irc-ws-register session)
+198
(display "irc-ws-helper-register-ok") (newline)
+199
`);
+200
+201
await new Promise((resolve) => setTimeout(resolve, 300));
+202
+203
if (!messages.includes("NICK wasm-irc\r\n")) {
+204
throw new Error(`echo server did not receive NICK registration; saw ${JSON.stringify(messages)}`);
+205
}
+206
if (!messages.includes("USER wasm-irc 0 * wasm-irc\r\n")) {
+207
throw new Error(`echo server did not receive USER registration; saw ${JSON.stringify(messages)}`);
+208
}
+209
+210
callWithString(instance, instance.exports.sigil_wasm_eval, `
+211
(irc-ws-receive session)
+212
(irc-ws-privmsg session "#sigil" "hello from wasm irc")
+213
(display "irc-ws-helper-send-ok") (newline)
214
`);
215
216
await new Promise((resolve) => setTimeout(resolve, 300));
217
218
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
+219
(define parsed (irc-ws-receive session))
+220
(if (and (irc-message? parsed)
221
(eq? (irc-message-command parsed) 'PRIVMSG)
222
(string=? (irc-message-target parsed) "#sigil")
223
(string=? (irc-message-text parsed) "hello from wasm irc"))
224
(display "irc-parse-roundtrip-ok")
195
(error "bad irc websocket roundtrip"))
+225
(error "bad irc websocket helper roundtrip"))
226
(newline)
197
(ws-close conn)
+227
(irc-ws-close session)
228
`);
229
200
if (!messages.includes("PRIVMSG #sigil :hello from wasm irc")) {
+230
if (!messages.includes("PRIVMSG #sigil :hello from wasm irc\r\n")) {
231
throw new Error(`echo server did not receive IRC PRIVMSG; saw ${JSON.stringify(messages)}`);
232
}
233
console.log("irc-websocket-wasm-ok");