Add IRC WebSocket adapter package
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).
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(-)README.mdmodified
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.### IRC over WebSocketThe 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.## Usage### Client (high-level convenience)```scheme(import (sigil irc))(import (sigil irc) (sigil irc websocket))(define conn (make-irc-connection server: "irc.libera.chat"(irc-run conn)```### Browser/WASM IRC over WebSocket```scheme(import (sigil irc))(define session (irc-ws-connect url: "wss://enclave.example/irc" nick: "web-client"))(irc-ws-register session)(irc-ws-privmsg session "#sigil" "hello")(let ((msg (irc-ws-receive session))) (when (irc-message? msg) (display (irc-message-command msg)) (newline)))```### Server (consumer drives I/O)```scheme- sigil-socket (TCP)- sigil-tls (TLS for the convenience client)- sigil-crypto v0.15.0+ (base64, HMAC, PBKDF2 — SCRAM needs the v0.15 SHA-256 byte primitives)The optional `sigil-irc-websocket` package also depends on:- sigil-irc- sigil-websocket## Building / testingdev-redirects.sglmodified
(for-repo url: "codeberg:sigil/sigil-lang" use: (from-path dir: "../sigil-lang")) (for-repo url: "codeberg:sigil/sigil-websocket" use: (from-path dir: "../sigil-websocket")) (for-repo url: "codeberg:sigil/sigil-socket" use: (from-path dir: "../sigil-socket"))package.sglmodified
;;; sigil-irc - IRC protocol library;;; sigil-irc workspace;;;;;; Provides IRC parser, types, capability descriptors, numeric constants,;;; and state-machine helpers covering both client and server flows for;;; modern IRCv3 (Tier 1 ratified caps + Tier 2 high-value drafts:;;; chathistory, draft/read-marker, MONITOR, SCRAM-SHA-256). Includes a;;; convenience client API with TLS support and cooperative non-blocking I/O.;;; - sigil-irc: protocol library plus the legacy native TCP/TLS client.;;; - sigil-irc-websocket: optional IRC-over-WebSocket adapter for browser;;; clients such as Enclave.(package(workspace name: "sigil-irc" version: "0.16.0" sigil: "^0.16" description: "IRC protocol library — both directions, IRCv3 + drafts" description: "IRC protocol libraries for Sigil" url: "https://codeberg.org/sigil/sigil-irc" license: "BSD-3-Clause" authors: (list "David Wilson <[email protected]>") default-package: "sigil-irc" configs: (list (config name: 'dev optimize: 2 features: '(web wasm))) dependencies: (list (from-git url: "codeberg:sigil/sigil-socket" version: "^0.16") (from-git url: "codeberg:sigil/sigil-tls" version: "^0.16") (from-git url: "codeberg:sigil/sigil-crypto" version: "^0.16")) dev-dependencies: (list (from-git url: "codeberg:sigil/sigil-test" package: "sigil-test" version: "^0.16") (from-git url: "codeberg:sigil/sigil-test" package: "sigil-test-runner" version: "^0.16")) tasks: (list (task name: 'build description: "Compile sigil-irc modules" steps: (list (compile-sigil-modules sources: "src/**/*.sgl" output-dir: (config-output-subdir "lib")))))) packages: (list "packages/sigil-irc" "packages/sigil-irc-websocket"))packages/sigil-irc-websocket/package.sgladded
;;; sigil-irc-websocket - IRC-over-WebSocket adapter;;;;;; Composes sigil-irc protocol parsing/serialization with sigil-websocket;;; transport for browser/WASM clients. Kept separate from sigil-irc so;;; desktop IRC clients do not inherit a WebSocket dependency.(package name: "sigil-irc-websocket" sigil: "^0.16" description: "IRC-over-WebSocket adapter for Sigil browser clients" url: "https://codeberg.org/sigil/sigil-irc" license: "BSD-3-Clause" authors: (list "David Wilson <[email protected]>") dependencies: (list (from-workspace name: "sigil-irc") (from-git url: "codeberg:sigil/sigil-websocket" version: "^0.16")) tasks: (list (task name: 'build description: "Compile sigil-irc-websocket modules" steps: (list (compile-sigil-modules sources: "src/**/*.sgl" output-dir: (config-output-subdir "lib"))))))packages/sigil-irc-websocket/src/sigil/irc/websocket.sgladded
;;; (sigil irc websocket) - IRC-over-WebSocket client/session helper;;;;;; This module composes the public `(sigil websocket)` client API with the;;; protocol-only `(sigil irc message)` layer. It intentionally does not import;;; the native IRC connection, socket, TLS, crypto, or SASL modules, so browser;;; WASM builds can use it as a small Enclave-facing transport adapter.(define-library (sigil irc websocket) (import (sigil core) (sigil string) (sigil struct) (sigil websocket) (sigil irc message)) (export irc-ws-session make-irc-ws-session irc-ws-session? irc-ws-session-url irc-ws-session-nick irc-ws-session-user irc-ws-session-realname irc-ws-session-websocket irc-ws-session-state irc-ws-connect irc-ws-close irc-ws-connected? irc-ws-register irc-ws-send irc-ws-command irc-ws-privmsg irc-ws-notice irc-ws-join irc-ws-part irc-ws-quit irc-ws-receive) (begin (define-struct irc-ws-session (url) (nick default: #f) (user default: #f) (realname default: "") (websocket default: #f mutable: #t) (state default: 'disconnected mutable: #t) (buffer default: "" mutable: #t) (queue default: '() mutable: #t)) (define (make-irc-ws-session (keys: (url #f) (nick #f) (user #f) (realname ""))) (: (url: any?) (nick: any?) (user: any?) (realname: string?) -> irc-ws-session?) (unless url (error "make-irc-ws-session: url: is required")) (irc-ws-session url: url nick: nick user: (or user nick) realname: (if (and nick (string=? realname "")) nick realname))) (define (line-ended? text) (let ((len (string-length text))) (and (> len 0) (char=? (string-ref text (- len 1)) #\newline)))) (define (strip-line-ending line) (let ((len (string-length line))) (cond ((and (> len 0) (char=? (string-ref line (- len 1)) #\newline)) (let ((end (- len 1))) (if (and (> end 0) (char=? (string-ref line (- end 1)) #\return)) (substring line 0 (- end 1)) (substring line 0 end)))) (else line)))) (define (append-line-ending line) (if (line-ended? line) line (string-append line "\r\n"))) (define (contains-line-ending? text) (if (string-index text (lambda (c) (char=? c #\newline))) #t #f)) (define (enqueue-lines! session text) (let ((combined (string-append (irc-ws-session-buffer session) text))) (let loop ((buffer combined) (lines '())) (let ((newline-pos (string-index buffer (lambda (c) (char=? c #\newline))))) (if newline-pos (let ((line (substring buffer 0 (+ newline-pos 1))) (rest (substring buffer (+ newline-pos 1) (string-length buffer)))) (loop rest (cons (strip-line-ending line) lines))) (begin (set-irc-ws-session-buffer! session buffer) (when (pair? lines) (set-irc-ws-session-queue! session (append (irc-ws-session-queue session) (reverse lines)))))))))) (define (enqueue-message! session text) (if (or (contains-line-ending? text) (not (string=? (irc-ws-session-buffer session) ""))) (enqueue-lines! session text) (set-irc-ws-session-queue! session (append (irc-ws-session-queue session) (list text))))) (define (pop-message! session) (let ((queue (irc-ws-session-queue session))) (if (null? queue) #f (let ((line (car queue))) (set-irc-ws-session-queue! session (cdr queue)) (parse-irc-message line))))) (define (irc-ws-register session) (: irc-ws-session? -> void?) (let ((nick (irc-ws-session-nick session))) (unless nick (error "irc-ws-register: session has no nick")) (irc-ws-command session "NICK" nick) (irc-ws-command session "USER" (or (irc-ws-session-user session) nick) "0" "*" (or (irc-ws-session-realname session) nick)))) (define (irc-ws-connect . args) (: any? ... -> any?) (let ((session (if (and (pair? args) (irc-ws-session? (car args))) (car args) (apply make-irc-ws-session args)))) (when (not (eq? (irc-ws-session-state session) 'disconnected)) (error "irc-ws-connect: already connected or connecting")) (set-irc-ws-session-state! session 'connecting) (let ((websocket (ws-connect (irc-ws-session-url session)))) (if (not websocket) (begin (set-irc-ws-session-state! session 'disconnected) #f) (begin (set-irc-ws-session-websocket! session websocket) (set-irc-ws-session-state! session 'connected) session))))) (define (irc-ws-connected? session) (: irc-ws-session? -> boolean?) (and (eq? (irc-ws-session-state session) 'connected) (irc-ws-session-websocket session) (ws-connected? (irc-ws-session-websocket session)))) (define (irc-ws-close session) (: irc-ws-session? -> void?) (when (irc-ws-session-websocket session) (ws-close (irc-ws-session-websocket session))) (set-irc-ws-session-websocket! session #f) (set-irc-ws-session-state! session 'disconnected) (set-irc-ws-session-buffer! session "") (set-irc-ws-session-queue! session '())) (define (irc-ws-send session line) (: irc-ws-session? string? -> void?) (unless (irc-ws-connected? session) (error "irc-ws-send: session is not connected")) (ws-send (irc-ws-session-websocket session) (append-line-ending line))) (define (irc-ws-command session command . args) (: irc-ws-session? string? string? ... -> void?) (irc-ws-send session (apply make-irc-command command args))) (define (irc-ws-privmsg session target text) (: irc-ws-session? string? string? -> void?) (irc-ws-send session (make-trailing-command "PRIVMSG" target text))) (define (irc-ws-notice session target text) (: irc-ws-session? string? string? -> void?) (irc-ws-send session (make-trailing-command "NOTICE" target text))) (define (irc-ws-join session channel . key) (: irc-ws-session? string? string? ... -> void?) (if (pair? key) (irc-ws-command session "JOIN" channel (car key)) (irc-ws-command session "JOIN" channel))) (define (irc-ws-part session channel . message) (: irc-ws-session? string? string? ... -> void?) (if (pair? message) (irc-ws-send session (make-trailing-command "PART" channel (car message))) (irc-ws-command session "PART" channel))) (define (irc-ws-quit session . message) (: irc-ws-session? string? ... -> void?) (when (irc-ws-connected? session) (if (pair? message) (irc-ws-send session (make-trailing-command "QUIT" (car message))) (irc-ws-command session "QUIT"))) (irc-ws-close session)) (define (irc-ws-receive session) (: irc-ws-session? -> any?) (let ((queued (pop-message! session))) (if queued queued (let ((websocket (irc-ws-session-websocket session))) (if (not websocket) 'closed (let ((message (ws-receive websocket))) (cond ((ws-message? message) (if (eq? (ws-message-type message) 'text) (begin (enqueue-message! session (ws-message-data message)) (pop-message! session)) #f)) ((eq? message 'closed) (set-irc-ws-session-state! session 'disconnected) 'closed) (else message))))))) )))packages/sigil-irc/package.sgladded
;;; sigil-irc - IRC protocol library;;;;;; Provides IRC parser, types, capability descriptors, numeric constants,;;; and state-machine helpers covering both client and server flows for;;; modern IRCv3 (Tier 1 ratified caps + Tier 2 high-value drafts:;;; chathistory, draft/read-marker, MONITOR, SCRAM-SHA-256). Includes a;;; convenience client API with TLS support and cooperative non-blocking I/O.(package name: "sigil-irc" sigil: "^0.16" description: "IRC protocol library — both directions, IRCv3 + drafts" url: "https://codeberg.org/sigil/sigil-irc" license: "BSD-3-Clause" authors: (list "David Wilson <[email protected]>") dependencies: (list (from-git url: "codeberg:sigil/sigil-socket" version: "^0.16") (from-git url: "codeberg:sigil/sigil-tls" version: "^0.16") (from-git url: "codeberg:sigil/sigil-crypto" version: "^0.16")) dev-dependencies: (list (from-git url: "codeberg:sigil/sigil-test" package: "sigil-test" version: "^0.16") (from-git url: "codeberg:sigil/sigil-test" package: "sigil-test-runner" version: "^0.16")) tasks: (list (task name: 'build description: "Compile sigil-irc modules" steps: (list (compile-sigil-modules sources: "../../src/**/*.sgl" output-dir: (config-output-subdir "lib"))))))test/integration/apps/wasm-irc-websocket-smoke/package.sglmodified
(from-path dir: "../../../../../sigil" package: "sigil-wasm-runtime") (from-path dir: "../../../../../sigil" package: "sigil-wasm-net") (from-path dir: "../../../../../sigil-websocket" package: "sigil-websocket") (from-path dir: "../../../.." package: "sigil-irc")) (from-path dir: "../../../.." package: "sigil-irc") (from-path dir: "../../../.." package: "sigil-irc-websocket")) tasks: (list (tasktest/integration/apps/wasm-irc-websocket-smoke/src/wasm-irc-websocket-smoke/main.sglmodified
(define-library (wasm-irc-websocket-smoke main) (import (sigil core) (sigil websocket) (sigil irc)) (sigil irc) (sigil irc websocket)) (export main)test/integration/test-wasm-irc-websocket.mjsmodified
return Buffer.concat([Buffer.from(header), payload]);}function decodeClientFrame(buffer) { const opcode = buffer[0] & 0x0f; let length = buffer[1] & 0x7f; let offset = 2;function decodeClientFrame(buffer, start = 0) { const opcode = buffer[start] & 0x0f; let length = buffer[start + 1] & 0x7f; let offset = start + 2; if (length === 126) { length = buffer.readUInt16BE(offset); offset += 2; for (let i = 0; i < length; i += 1) { decoded[i] = payload[i] ^ mask[i % 4]; } return { opcode, text: decoded.toString("utf8") }; return { opcode, text: decoded.toString("utf8"), bytesConsumed: offset + length - start };}function decodeClientFrames(buffer) { const frames = []; let offset = 0; while (offset < buffer.length) { const frame = decodeClientFrame(buffer, offset); frames.push(frame); offset += frame.bytesConsumed; } return frames;}function run(command, args, options = {}) { "", ].join("\r\n")); socket.on("data", (buffer) => { const frame = decodeClientFrame(buffer); if (frame.opcode === 1) { messages.push(frame.text); socket.write(encodeServerFrame(frame.text, 1)); for (const frame of decodeClientFrames(buffer)) { if (frame.opcode === 1) { messages.push(frame.text); if (frame.text.startsWith("PRIVMSG ")) { socket.write(encodeServerFrame(frame.text, 1)); } } } }); }); wasi.start(instance); callWithString(instance, instance.exports.sigil_wasm_eval, ` (import (sigil websocket) (sigil irc)) (define conn (ws-connect "ws://127.0.0.1:${port}")) (if (not conn) (error "ws-connect failed")) (display "irc-ws-connect-ok") (newline) (import (sigil irc) (sigil irc websocket)) (define session (irc-ws-connect url: "ws://127.0.0.1:${port}" nick: "wasm-irc")) (if (not session) (error "irc-ws-connect failed")) (if (not (irc-ws-connected? session)) (error "irc-ws session not connected")) (display "irc-ws-helper-connect-ok") (newline) `); await new Promise((resolve) => setTimeout(resolve, 300)); callWithString(instance, instance.exports.sigil_wasm_eval, ` (ws-receive conn) (define outbound (make-irc-command "PRIVMSG" "#sigil" "hello from wasm irc")) (ws-send conn outbound) (display "irc-ws-send-ok") (newline) (irc-ws-receive session) (irc-ws-register session) (display "irc-ws-helper-register-ok") (newline) `); await new Promise((resolve) => setTimeout(resolve, 300)); if (!messages.includes("NICK wasm-irc\r\n")) { throw new Error(`echo server did not receive NICK registration; saw ${JSON.stringify(messages)}`); } if (!messages.includes("USER wasm-irc 0 * wasm-irc\r\n")) { throw new Error(`echo server did not receive USER registration; saw ${JSON.stringify(messages)}`); } callWithString(instance, instance.exports.sigil_wasm_eval, ` (irc-ws-receive session) (irc-ws-privmsg session "#sigil" "hello from wasm irc") (display "irc-ws-helper-send-ok") (newline) `); await new Promise((resolve) => setTimeout(resolve, 300)); callWithString(instance, instance.exports.sigil_wasm_eval, ` (define reply (ws-receive conn)) (if (not (ws-message? reply)) (error "missing websocket reply")) (define parsed (parse-irc-message (ws-message-data reply))) (if (and parsed (define parsed (irc-ws-receive session)) (if (and (irc-message? parsed) (eq? (irc-message-command parsed) 'PRIVMSG) (string=? (irc-message-target parsed) "#sigil") (string=? (irc-message-text parsed) "hello from wasm irc")) (display "irc-parse-roundtrip-ok") (error "bad irc websocket roundtrip")) (error "bad irc websocket helper roundtrip")) (newline) (ws-close conn) (irc-ws-close session) `); if (!messages.includes("PRIVMSG #sigil :hello from wasm irc")) { if (!messages.includes("PRIVMSG #sigil :hello from wasm irc\r\n")) { throw new Error(`echo server did not receive IRC PRIVMSG; saw ${JSON.stringify(messages)}`); } console.log("irc-websocket-wasm-ok");