AtlatestRepositorysigil-irc
1
;;; (sigil irc websocket) - IRC-over-WebSocket client/session helper2
;;;3
;;; This module composes the public `(sigil websocket)` client API with the4
;;; protocol-only `(sigil irc message)` layer. IRCv3 WebSocket transports carry5
;;; one IRC line per frame without CRLF. This module intentionally does not6
;;; import the native IRC connection, socket, TLS, crypto, or SASL modules, so7
;;; browser WASM builds can use it as a small Enclave-facing transport adapter.9
(define-library (sigil irc websocket)10
(import (sigil core)11
(sigil math)12
(sigil string)13
(sigil struct)14
(sigil websocket connection)15
(sigil irc message))17
(export18
irc-ws-session19
make-irc-ws-session20
irc-ws-session?21
irc-ws-session-url22
irc-ws-session-nick23
irc-ws-session-user24
irc-ws-session-realname25
irc-ws-session-connect-timeout-ms26
irc-ws-session-handshake-timeout-ms27
irc-ws-session-upgrade-timeout-ms28
irc-ws-session-websocket29
irc-ws-session-state30
irc-ws-session-server-caps31
irc-ws-session-enabled-caps32
irc-ws-session-sasl-result34
irc-ws-connect35
irc-ws-close36
irc-ws-connected?37
irc-ws-register38
irc-ws-negotiate-caps39
irc-ws-authenticate-plain40
irc-ws-register-with-caps41
irc-ws-cap-ls42
irc-ws-cap-req-from-ls43
irc-ws-cap-ack44
irc-ws-cap-end45
irc-ws-authenticate-plain-start46
irc-ws-authenticate-plain-payload47
irc-ws-authenticate-finish49
irc-ws-send50
irc-ws-command51
irc-ws-privmsg52
irc-ws-notice53
irc-ws-join54
irc-ws-part55
irc-ws-quit57
irc-ws-receive58
irc-ws-receive-line)60
(begin62
(define-struct irc-ws-session63
(url)64
(nick default: #f)65
(user default: #f)66
(realname default: "")67
(connect-timeout-ms default: #f)68
(handshake-timeout-ms default: #f)69
(upgrade-timeout-ms default: #f)70
(websocket default: #f mutable: #t)71
(state default: 'disconnected mutable: #t)72
(buffer default: "" mutable: #t)73
(queue default: '() mutable: #t)74
(server-caps default: '() mutable: #t)75
(enabled-caps default: '() mutable: #t)76
(sasl-result default: #f mutable: #t))78
(define (make-irc-ws-session (keys: (url #f)79
(nick #f)80
(user #f)81
(realname "")82
(connect-timeout-ms #f)83
(handshake-timeout-ms #f)84
(upgrade-timeout-ms #f)))85
(: (url: any?) (nick: any?) (user: any?) (realname: string?)86
(connect-timeout-ms: any?) (handshake-timeout-ms: any?)87
(upgrade-timeout-ms: any?) -> irc-ws-session?)88
(unless url89
(error "make-irc-ws-session: url: is required"))90
(irc-ws-session91
url: url92
nick: nick93
user: (or user nick)94
realname: (if (and nick (string=? realname "")) nick realname)95
connect-timeout-ms: connect-timeout-ms96
handshake-timeout-ms: handshake-timeout-ms97
upgrade-timeout-ms: upgrade-timeout-ms))99
(define (strip-line-ending line)100
(let ((len (string-length line)))101
(cond102
((and (> len 0)103
(char=? (string-ref line (- len 1)) #\newline))104
(let ((end (- len 1)))105
(if (and (> end 0)106
(char=? (string-ref line (- end 1)) #\return))107
(substring line 0 (- end 1))108
(substring line 0 end))))109
(else line))))111
(define (contains-line-ending? text)112
(if (string-index text (lambda (c) (char=? c #\newline)))113
#t114
#f))116
(define (enqueue-lines! session text)117
(let ((combined (string-append (irc-ws-session-buffer session) text)))118
(let loop ((buffer combined)119
(lines '()))120
(let ((newline-pos (string-index buffer (lambda (c) (char=? c #\newline)))))121
(if newline-pos122
(let ((line (substring buffer 0 (+ newline-pos 1)))123
(rest (substring buffer (+ newline-pos 1) (string-length buffer))))124
(loop rest (cons (strip-line-ending line) lines)))125
(begin126
(set-irc-ws-session-buffer! session buffer)127
(when (pair? lines)128
(set-irc-ws-session-queue!129
session130
(append (irc-ws-session-queue session)131
(reverse lines))))))))))133
(define (enqueue-message! session text)134
(if (or (contains-line-ending? text)135
(not (string=? (irc-ws-session-buffer session) "")))136
(enqueue-lines! session text)137
(set-irc-ws-session-queue!138
session139
(append (irc-ws-session-queue session)140
(list text)))))142
(define (pop-message! session)143
(let ((queue (irc-ws-session-queue session)))144
(if (null? queue)145
#f146
(let ((line (car queue)))147
(set-irc-ws-session-queue! session (cdr queue))148
(parse-irc-message line)))))150
(define base64-alphabet151
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")153
(define (base64-char n)154
(string (string-ref base64-alphabet n)))156
(define (base64-encode-string input)157
(let ((len (string-length input)))158
(let loop ((i 0) (acc '()))159
(if (>= i len)160
(apply string-append (reverse acc))161
(let* ((remaining (- len i))162
(b1 (char->integer (string-ref input i)))163
(b2 (if (> remaining 1)164
(char->integer (string-ref input (+ i 1)))165
0))166
(b3 (if (> remaining 2)167
(char->integer (string-ref input (+ i 2)))168
0))169
(n (+ (* b1 65536) (* b2 256) b3))170
(s1 (quotient n 262144))171
(s2 (remainder (quotient n 4096) 64))172
(s3 (remainder (quotient n 64) 64))173
(s4 (remainder n 64))174
(chunk175
(cond176
((= remaining 1)177
(string-append (base64-char s1)178
(base64-char s2)179
"=="))180
((= remaining 2)181
(string-append (base64-char s1)182
(base64-char s2)183
(base64-char s3)184
"="))185
(else186
(string-append (base64-char s1)187
(base64-char s2)188
(base64-char s3)189
(base64-char s4))))))190
(loop (+ i 3) (cons chunk acc)))))))192
(define (sasl-plain-payload authcid password . authzid)193
(base64-encode-string194
(string-append195
(if (pair? authzid) (or (car authzid) "") "")196
(string #\null)197
authcid198
(string #\null)199
password)))201
(define (line-without-crlf line)202
(strip-line-ending line))204
(define (send-wire-lines session lines)205
(for-each206
(lambda (line)207
(irc-ws-send session (line-without-crlf line)))208
lines))210
(define (message-command=? msg command)211
(and (irc-message? msg)212
(string=? (symbol->string (irc-message-command msg)) command)))214
(define (cap-subcommand msg)215
(let ((params (irc-message-params msg)))216
(and (pair? params)217
(pair? (cdr params))218
(cadr params))))220
(define (cap-body msg)221
(or (irc-message-trailing msg)222
(let ((params (irc-message-params msg)))223
(if (null? params) "" (car (reverse params))))))225
(define (cap-ls-more? msg)226
(let ((params (irc-message-params msg)))227
(and (>= (length params) 3)228
(string=? (caddr params) "*"))))230
(define (split-caps text)231
(if (string=? text "")232
'()233
(map234
(lambda (entry)235
(let ((eq-pos (string-index entry (lambda (c) (char=? c #\=)))))236
(if eq-pos237
(substring entry 0 eq-pos)238
entry)))239
(string-split text " "))))241
(define (append-new-caps existing names)242
(let loop ((rest names) (acc existing))243
(cond244
((null? rest) acc)245
((member (car rest) acc) (loop (cdr rest) acc))246
(else (loop (cdr rest) (append acc (list (car rest))))))))248
(define (filter-offered desired offered)249
(let loop ((rest desired) (acc '()))250
(cond251
((null? rest) (reverse acc))252
((member (car rest) offered)253
(loop (cdr rest) (cons (car rest) acc)))254
(else (loop (cdr rest) acc)))))256
(define (join-caps caps)257
(string-join caps " "))259
(define (read-next-irc-message session max-lines)260
(let loop ((n 0))261
(if (>= n max-lines)262
#f263
(let ((msg (irc-ws-receive session)))264
(if (irc-message? msg)265
msg266
(loop (+ n 1)))))))268
(define (read-until-command session command max-lines)269
(let loop ((n 0))270
(if (>= n max-lines)271
#f272
(let ((msg (irc-ws-receive session)))273
(cond274
((not (irc-message? msg)) (loop (+ n 1)))275
((message-command=? msg command) msg)276
(else (loop (+ n 1))))))))278
(define (read-cap-ls session max-lines)279
(let loop ((n 0) (offered '()))280
(if (>= n max-lines)281
#f282
(let ((msg (read-next-irc-message session 1)))283
(cond284
((not msg) (loop (+ n 1) offered))285
((and (message-command=? msg "CAP")286
(equal? (cap-subcommand msg) "LS"))287
(let ((next (append-new-caps offered (split-caps (cap-body msg)))))288
(if (cap-ls-more? msg)289
(loop (+ n 1) next)290
next)))291
(else (loop (+ n 1) offered)))))))293
(define (read-cap-ack-or-nak session max-lines)294
(let loop ((n 0))295
(if (>= n max-lines)296
#f297
(let ((msg (read-next-irc-message session 1)))298
(cond299
((not msg) (loop (+ n 1)))300
((and (message-command=? msg "CAP")301
(or (equal? (cap-subcommand msg) "ACK")302
(equal? (cap-subcommand msg) "NAK")))303
msg)304
(else (loop (+ n 1))))))))306
(define (irc-ws-register session)307
(: irc-ws-session? -> void?)308
(let ((nick (irc-ws-session-nick session)))309
(unless nick310
(error "irc-ws-register: session has no nick"))311
(irc-ws-command session "NICK" nick)312
(irc-ws-command session313
"USER"314
(or (irc-ws-session-user session) nick)315
"0"316
"*"317
(or (irc-ws-session-realname session) nick))))319
(define (irc-ws-authenticate-plain session authcid password . authzid)320
(: irc-ws-session? string? string? any? ... -> boolean?)321
(irc-ws-authenticate-plain-start session)322
(if (not (apply irc-ws-authenticate-plain-payload323
session324
authcid325
password326
authzid))327
#f328
(irc-ws-authenticate-finish session)))330
(define (irc-ws-cap-ls session)331
(: irc-ws-session? -> void?)332
(irc-ws-command session "CAP" "LS" "302"))334
(define (irc-ws-cap-req-from-ls session desired . opts)335
(: irc-ws-session? list? any? ... -> list?)336
(let parse-opts ((rest opts)337
(include-sasl? #f)338
(max-lines 80))339
(cond340
((null? rest)341
(let ((offered (read-cap-ls session max-lines)))342
(if (not offered)343
#f344
(let* ((want (if include-sasl?345
(append-new-caps desired (list "sasl"))346
desired))347
(requested (filter-offered want offered)))348
(set-irc-ws-session-server-caps! session offered)349
(if (null? requested)350
(begin351
(irc-ws-cap-end session)352
'())353
(begin354
(irc-ws-command session "CAP" "REQ" (join-caps requested))355
requested))))))356
((and (pair? rest) (eq? (car rest) include-sasl?:))357
(parse-opts (cddr rest) (cadr rest) max-lines))358
((and (pair? rest) (eq? (car rest) max-lines:))359
(parse-opts (cddr rest) include-sasl? (cadr rest)))360
(else361
(error "irc-ws-cap-req-from-ls: unknown option")))))363
(define (irc-ws-cap-ack session . opts)364
(: irc-ws-session? any? ... -> list?)365
(let parse-opts ((rest opts)366
(max-lines 80))367
(cond368
((null? rest)369
(let ((reply (read-cap-ack-or-nak session max-lines)))370
(if (not reply)371
#f372
(let ((acked (if (equal? (cap-subcommand reply) "ACK")373
(split-caps (cap-body reply))374
'())))375
(set-irc-ws-session-enabled-caps! session acked)376
acked))))377
((and (pair? rest) (eq? (car rest) max-lines:))378
(parse-opts (cddr rest) (cadr rest)))379
(else380
(error "irc-ws-cap-ack: unknown option")))))382
(define (irc-ws-cap-end session)383
(: irc-ws-session? -> void?)384
(irc-ws-command session "CAP" "END"))386
(define (irc-ws-authenticate-plain-start session)387
(: irc-ws-session? -> void?)388
(irc-ws-command session "AUTHENTICATE" "PLAIN"))390
(define (irc-ws-authenticate-plain-payload session authcid password . authzid)391
(: irc-ws-session? string? string? any? ... -> boolean?)392
(let ((prompt (read-until-command session "AUTHENTICATE" 40)))393
(if (not prompt)394
#f395
(let ((arg (let ((params (irc-message-params prompt)))396
(and (pair? params) (car params)))))397
(if (not (equal? arg "+"))398
#f399
(begin400
(irc-ws-command session401
"AUTHENTICATE"402
(apply sasl-plain-payload403
authcid404
password405
authzid))406
#t))))))408
(define (irc-ws-authenticate-finish session . opts)409
(: irc-ws-session? any? ... -> boolean?)410
(let parse-opts ((rest opts)411
(max-lines 80))412
(cond413
((null? rest)414
(let ((result415
(let loop ((n 0))416
(if (>= n max-lines)417
#f418
(let ((msg (read-next-irc-message session 1)))419
(cond420
((not msg) (loop (+ n 1)))421
((message-command=? msg "903") msg)422
((or (message-command=? msg "904")423
(message-command=? msg "905")424
(message-command=? msg "906")425
(message-command=? msg "907"))426
msg)427
(else (loop (+ n 1)))))))))428
(cond429
((and result (message-command=? result "903"))430
(set-irc-ws-session-sasl-result! session 'success)431
#t)432
((irc-message? result)433
(set-irc-ws-session-sasl-result!434
session435
(irc-message-command result))436
#f)437
(else #f))))438
((and (pair? rest) (eq? (car rest) max-lines:))439
(parse-opts (cddr rest) (cadr rest)))440
(else441
(error "irc-ws-authenticate-finish: unknown option")))))443
(define (irc-ws-negotiate-caps session desired . opts)444
(: irc-ws-session? list? any? ... -> list?)445
(let parse-opts ((rest opts)446
(sasl-authcid #f)447
(sasl-password #f)448
(max-lines 80))449
(cond450
((null? rest)451
(irc-ws-command session "CAP" "LS" "302")452
(let ((offered (read-cap-ls session max-lines)))453
(if (not offered)454
#f455
(let* ((want (if sasl-password456
(append-new-caps desired (list "sasl"))457
desired))458
(requested (filter-offered want offered)))459
(set-irc-ws-session-server-caps! session offered)460
(if (null? requested)461
(begin462
(irc-ws-command session "CAP" "END")463
'())464
(begin465
(irc-ws-command session "CAP" "REQ" (join-caps requested))466
(let ((reply (read-cap-ack-or-nak session max-lines)))467
(if (not reply)468
#f469
(let ((acked (if (equal? (cap-subcommand reply) "ACK")470
(split-caps (cap-body reply))471
'())))472
(set-irc-ws-session-enabled-caps! session acked)473
(cond474
((and sasl-password (member "sasl" acked))475
(let ((ok (irc-ws-authenticate-plain476
session477
(or sasl-authcid478
(irc-ws-session-nick session))479
sasl-password)))480
(irc-ws-command session "CAP" "END")481
(if ok acked #f)))482
(else483
(irc-ws-command session "CAP" "END")484
acked)))))))))))485
((and (pair? rest) (eq? (car rest) sasl-authcid:))486
(parse-opts (cddr rest) (cadr rest) sasl-password max-lines))487
((and (pair? rest) (eq? (car rest) sasl-password:))488
(parse-opts (cddr rest) sasl-authcid (cadr rest) max-lines))489
((and (pair? rest) (eq? (car rest) max-lines:))490
(parse-opts (cddr rest) sasl-authcid sasl-password (cadr rest)))491
(else492
(error "irc-ws-negotiate-caps: unknown option")))))494
(define (irc-ws-register-with-caps session desired . opts)495
(: irc-ws-session? list? any? ... -> list?)496
(let ((acked (apply irc-ws-negotiate-caps session desired opts)))497
(if (not acked)498
#f499
(begin500
(irc-ws-register session)501
acked))))503
(define (irc-ws-connect . args)504
(: any? ... -> any?)505
(let ((session506
(if (and (pair? args) (irc-ws-session? (car args)))507
(car args)508
(apply make-irc-ws-session args))))509
(when (not (eq? (irc-ws-session-state session) 'disconnected))510
(error "irc-ws-connect: already connected or connecting"))511
(set-irc-ws-session-state! session 'connecting)512
(let ((websocket513
(ws-connect (irc-ws-session-url session)514
connect-timeout-ms: (irc-ws-session-connect-timeout-ms session)515
handshake-timeout-ms: (irc-ws-session-handshake-timeout-ms session)516
upgrade-timeout-ms: (irc-ws-session-upgrade-timeout-ms session))))517
(if (not websocket)518
(begin519
(set-irc-ws-session-state! session 'disconnected)520
#f)521
(begin522
(set-irc-ws-session-websocket! session websocket)523
(set-irc-ws-session-state! session 'connected)524
session)))))526
(define (irc-ws-connected? session)527
(: irc-ws-session? -> boolean?)528
(and (eq? (irc-ws-session-state session) 'connected)529
(irc-ws-session-websocket session)530
(ws-connected? (irc-ws-session-websocket session))))532
(define (irc-ws-close session)533
(: irc-ws-session? -> void?)534
(when (irc-ws-session-websocket session)535
(ws-close (irc-ws-session-websocket session)))536
(set-irc-ws-session-websocket! session #f)537
(set-irc-ws-session-state! session 'disconnected)538
(set-irc-ws-session-buffer! session "")539
(set-irc-ws-session-queue! session '()))541
(define (irc-ws-send session line)542
(: irc-ws-session? string? -> void?)543
(unless (irc-ws-connected? session)544
(error "irc-ws-send: session is not connected"))545
(ws-send (irc-ws-session-websocket session) line))547
(define (irc-ws-command session command . args)548
(: irc-ws-session? string? string? ... -> void?)549
(irc-ws-send session550
(apply make-irc-command command args)))552
(define (irc-ws-privmsg session target text)553
(: irc-ws-session? string? string? -> void?)554
(irc-ws-send session555
(make-trailing-command "PRIVMSG" target text)))557
(define (irc-ws-notice session target text)558
(: irc-ws-session? string? string? -> void?)559
(irc-ws-send session560
(make-trailing-command "NOTICE" target text)))562
(define (irc-ws-join session channel . key)563
(: irc-ws-session? string? string? ... -> void?)564
(if (pair? key)565
(irc-ws-command session "JOIN" channel (car key))566
(irc-ws-command session "JOIN" channel)))568
(define (irc-ws-part session channel . message)569
(: irc-ws-session? string? string? ... -> void?)570
(if (pair? message)571
(irc-ws-send session572
(make-trailing-command "PART" channel (car message)))573
(irc-ws-command session "PART" channel)))575
(define (irc-ws-quit session . message)576
(: irc-ws-session? string? ... -> void?)577
(when (irc-ws-connected? session)578
(if (pair? message)579
(irc-ws-send session (make-trailing-command "QUIT" (car message)))580
(irc-ws-command session "QUIT")))581
(irc-ws-close session))583
(define (irc-ws-receive-line session)584
(: irc-ws-session? -> any?)585
;; Enclave sends one IRC command per WebSocket text message. Return that586
;; transport payload directly: routing it through the parsed IRC queue is587
;; both unnecessary and unsafe for opaque stream tickets in native builds.588
(let ((websocket (irc-ws-session-websocket session)))589
(if (not websocket)590
'closed591
(let ((message (ws-receive websocket)))592
(cond593
;; This is the raw transport API. Preserve the WebSocket text594
;; payload exactly; callers that consume IRC lines already own595
;; CRLF splitting. Besides avoiding duplicate work, this keeps596
;; opaque Enclave stream tickets out of the generic line helper.597
;; ws-receive is back on the MESSAGE path (binary frames must598
;; flow for the terminal stream relay), so unwrap text here.599
((and (ws-message? message)600
(eq? (ws-message-type message) 'text))601
(ws-message-data message))602
((string? message) message)603
;; Latch the session dead ONLY on a genuine close. This relies604
;; on ws-receive returning #f while an async socket is opening.605
((eq? message 'closed)606
(set-irc-ws-session-state! session 'disconnected)607
'closed)608
(else message))))))610
(define (irc-ws-receive session)611
(: irc-ws-session? -> any?)612
(let ((queued (pop-message! session)))613
(if queued614
queued615
(let ((websocket (irc-ws-session-websocket session)))616
(if (not websocket)617
'closed618
(let ((message (ws-receive websocket)))619
(cond620
((ws-message? message)621
(if (eq? (ws-message-type message) 'text)622
(begin623
(enqueue-message! session (ws-message-data message))624
(pop-message! session))625
#f))626
;; Latch the session dead ONLY on a genuine close. This627
;; relies on ws-receive returning #f (not 'closed) while a628
;; browser socket is still in its async CONNECTING window629
;; — otherwise the first poll during that window would630
;; kill a connection that is merely opening slowly (the631
;; browser slow-open bug fixed in (sigil websocket)632
;; ws-receive). Do not relax that invariant without633
;; distinguishing connecting from closed here too.634
((eq? message 'closed)635
(set-irc-ws-session-state! session 'disconnected)636
'closed)637
(else message)))))))639
)))