serverless-ircd 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -215,6 +215,24 @@ describe('local-cli TCP transport — IRC flows', () => {
215
215
  await client.close();
216
216
  });
217
217
 
218
+ it('skips an empty line delivered over TCP without dispatching it', async () => {
219
+ // The LineScanner yields an empty string for a bare CRLF; the data
220
+ // handler's `if (line.length === 0) continue` must skip it without
221
+ // feeding an empty IRC line to the actor (server.ts:539).
222
+ const client = new TcpTestClient('127.0.0.1', server.tcpPort as number);
223
+ await client.opened();
224
+ await client.send('NICK blanker');
225
+ await client.send('USER blanker 0 * :Blanker');
226
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
227
+
228
+ // A bare CRLF (blank line) followed by a valid PING: the blank line is
229
+ // ignored and the PING still resolves to a PONG.
230
+ await client.writeRaw('\r\n');
231
+ await client.send('PING :after-blank');
232
+ await client.waitFor((l) => l === 'PONG :after-blank');
233
+ await client.close();
234
+ });
235
+
218
236
  it('processes multiple IRC lines delivered in a single TCP write', async () => {
219
237
  const client = new TcpTestClient('127.0.0.1', server.tcpPort as number);
220
238
  await client.opened();
@@ -367,6 +385,31 @@ describe('local-cli TCP transport — shutdown', () => {
367
385
  expect(client.socket.destroyed).toBe(true);
368
386
  });
369
387
 
388
+ it('close() destroys a still-live registered TCP connection via the close sweep', async () => {
389
+ // Registering the client (awaiting 001) guarantees the server-side
390
+ // connection handler has run and the socket is in `tcpConnections`, so
391
+ // `close()` deterministically enters the close sweep (server.ts:765) and
392
+ // destroys the live socket. (Connecting without registering races the
393
+ // handler under coverage instrumentation.)
394
+ const srv = await startLocalServer({
395
+ port: 0,
396
+ tcpPort: 0,
397
+ hostname: '127.0.0.1',
398
+ serverName: 'irc.local.example.com',
399
+ networkName: 'LocalNet',
400
+ });
401
+ const client = new TcpTestClient('127.0.0.1', srv.tcpPort as number);
402
+ await client.opened();
403
+ await client.send('NICK sweepUser');
404
+ await client.send('USER sweepUser 0 * :Sweep');
405
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
406
+
407
+ const closed = new Promise<void>((resolve) => client.socket.once('close', () => resolve()));
408
+ await srv.close();
409
+ await closed;
410
+ expect(client.socket.destroyed).toBe(true);
411
+ });
412
+
370
413
  it('close() is a no-op for TCP when tcpPort was never enabled', async () => {
371
414
  const srv = await startLocalServer({
372
415
  port: 0,
@@ -408,3 +451,49 @@ describe('local-cli TCP transport — shutdown', () => {
408
451
  await srv.close();
409
452
  });
410
453
  });
454
+
455
+ describe('local-cli TCP transport — admission gate', () => {
456
+ it('emits ERROR :Closing link and destroys the socket when admitConnection denies', async () => {
457
+ // Tight per-IP cap with a permissive rate limit so only the per-IP
458
+ // simultaneous-connection gate refuses the second connection.
459
+ const srv = await startLocalServer({
460
+ port: 0,
461
+ tcpPort: 0,
462
+ hostname: '127.0.0.1',
463
+ serverName: 'admit.example.com',
464
+ networkName: 'AdmitNet',
465
+ maxConnectionsPerIp: 1,
466
+ maxConnectionsPerUser: 1_000,
467
+ perIpConnectionRate: { max: 1_000, windowMs: 60_000 },
468
+ });
469
+
470
+ try {
471
+ // First connection fills the per-IP cap (maxConnectionsPerIp: 1).
472
+ const first = new TcpTestClient('127.0.0.1', srv.tcpPort as number);
473
+ await first.opened();
474
+
475
+ // Second connection from the same IP is denied at the TCP admission
476
+ // gate BEFORE an actor is attached. The server writes the RFC-style
477
+ // ERROR notice then destroys the socket (server.ts 511-517).
478
+ const rejected = new TcpTestClient('127.0.0.1', srv.tcpPort as number);
479
+ const errorLine = await rejected.waitFor((l) => l.startsWith('ERROR :Closing link'), 2_000);
480
+ expect(errorLine).toContain('connection limit reached');
481
+ expect(errorLine).toContain('max_connections_per_ip');
482
+
483
+ // The server destroys the socket after the ERROR notice.
484
+ await new Promise<void>((resolve) => {
485
+ if (rejected.socket.destroyed) resolve();
486
+ else rejected.socket.once('close', () => resolve());
487
+ });
488
+ expect(rejected.socket.destroyed).toBe(true);
489
+
490
+ // The denied socket is removed from the server's tracked set so the
491
+ // runtime admission counters don't leak it.
492
+ expect(srv.testTcpSockets?.has(rejected.socket)).toBe(false);
493
+
494
+ await first.close();
495
+ } finally {
496
+ await srv.close();
497
+ }
498
+ });
499
+ });
@@ -171,6 +171,20 @@ describe('local-cli IRCv3 WebSocket subprotocol', () => {
171
171
  await client.close();
172
172
  });
173
173
 
174
+ it('falls back to legacy framing when only unsupported protocols are offered', async () => {
175
+ // `selectSubprotocol` returns undefined for an all-unsupported offer, so
176
+ // `handleProtocols` returns false and the ws library completes the
177
+ // handshake with no negotiated subprotocol. Exercises the
178
+ // `?? false` fallback in handleProtocols. The offering client then
179
+ // aborts ("Server sent no subprotocol"), which is expected — the
180
+ // server-side branch is what this covers.
181
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, ['chat']);
182
+ client.ws.on('error', () => {
183
+ // Expected: server returned no subprotocol for an all-unsupported offer.
184
+ });
185
+ await expect(client.opened()).rejects.toThrow(/no subprotocol/iu);
186
+ });
187
+
174
188
  it('completes registration with one IRC message per WebSocket message (text mode)', async () => {
175
189
  const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
176
190
  await client.opened();
@@ -260,3 +274,81 @@ describe('local-cli IRCv3 WebSocket subprotocol', () => {
260
274
  });
261
275
  }
262
276
  });
277
+
278
+ describe('local-cli WebSocket — transport seams', () => {
279
+ let server: LocalServer;
280
+
281
+ beforeAll(async () => {
282
+ server = await startLocalServer({
283
+ port: 0,
284
+ hostname: '127.0.0.1',
285
+ serverName: 'seam.example.com',
286
+ networkName: 'SeamNet',
287
+ });
288
+ });
289
+
290
+ afterAll(async () => {
291
+ await server.close();
292
+ });
293
+
294
+ it('logs a spec-mode actor failure without crashing the server', async () => {
295
+ // Register normally first so the connection has a nick, then swap the
296
+ // runtime send to throw — the next inbound message's dispatch rejects
297
+ // inside receiveTextFrame and the spec-mode `.catch` (server.ts:453)
298
+ // routes it to logError instead of crashing the server.
299
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
300
+ await client.opened();
301
+ await client.send('NICK specErr');
302
+ await client.send('USER specErr 0 * :Spec Err');
303
+ await client.waitFor((l) => l.startsWith(':seam.example.com 001 '));
304
+
305
+ const original = server.runtime.send.bind(server.runtime);
306
+ server.runtime.send = async () => {
307
+ throw new Error('synthetic spec send failure');
308
+ };
309
+ try {
310
+ // PING dispatches a Send effect → synthetic throw → actor catch.
311
+ await client.send('PING :will-throw');
312
+ await new Promise((r) => setTimeout(r, 60));
313
+ } finally {
314
+ server.runtime.send = original;
315
+ }
316
+
317
+ // The server must still serve fresh spec-mode connections.
318
+ const next = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
319
+ await next.opened();
320
+ await next.send('NICK specRecover');
321
+ await next.send('USER specRecover 0 * :Spec Recover');
322
+ await next.waitFor((l) => l.startsWith(':seam.example.com 001 '), 2_000);
323
+ await next.close();
324
+ await client.close();
325
+ });
326
+
327
+ it('drops an outbound send to a WebSocket that is no longer OPEN', async () => {
328
+ // Register, then transition the server-side ws to CLOSING. The runtime
329
+ // still tracks the connection until the 'close' event fires on a later
330
+ // tick, so a synchronous dispatch hits the `ws.readyState !== OPEN`
331
+ // guard and returns before calling ws.send (server.ts:423).
332
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
333
+ await client.opened();
334
+ await client.send('NICK dropSend');
335
+ await client.send('USER dropSend 0 * :Drop');
336
+ await client.waitFor((l) => l.startsWith(':seam.example.com 001 '));
337
+
338
+ const connId = await server.runtime.lookupNick('dropSend');
339
+ expect(connId).not.toBeNull();
340
+ const serverWs = [...server.testSockets].find((ws) => ws.readyState === WebSocket.OPEN);
341
+ expect(serverWs).toBeDefined();
342
+ serverWs?.close();
343
+ // Synchronous dispatch before the 'close' cleanup runs: the send handler
344
+ // must observe readyState !== OPEN and no-op without throwing.
345
+ await expect(
346
+ server.runtime.send(connId as string, [{ text: ':seam NOTICE dropSend :dropped' }]),
347
+ ).resolves.toBeUndefined();
348
+
349
+ await new Promise<void>((resolve) => {
350
+ client.ws.once('close', () => resolve());
351
+ client.ws.close();
352
+ });
353
+ });
354
+ });
Binary file
@@ -4,12 +4,14 @@
4
4
 
5
5
  Served at `/` by the Worker `[assets]` binding (apps/cf-worker/wrangler.toml).
6
6
  The build (`scripts/build.mjs`) copies this file verbatim to `dist/index.html`.
7
- The Kiwi IRC SPA lives at `/webclient/`; this page links through to it plus
8
- the project docs and source repo. Pure static HTML + inline CSS, no JavaScript.
7
+ The Kiwi IRC SPA lives at `/webclient/`; the rendered docs at `/docs/`
8
+ (built from the `docs/` submodule); this page links through to both plus
9
+ the source repo. Pure static HTML + inline CSS, no JavaScript.
9
10
  -->
10
11
  <html lang="en">
11
12
  <head>
12
13
  <meta charset="utf-8" />
14
+ <link rel="icon" href="/favicon.ico" sizes="any" />
13
15
  <meta name="viewport" content="width=device-width, initial-scale=1" />
14
16
  <title>ServerlessIRCd — Serverless IRC Daemon</title>
15
17
  <meta
@@ -135,6 +137,113 @@
135
137
  border-radius: 4px;
136
138
  font-size: 0.9em;
137
139
  }
140
+ pre {
141
+ font-family: var(--mono);
142
+ background-color: var(--surface);
143
+ border: 1px solid var(--border);
144
+ border-radius: 8px;
145
+ padding: 14px 16px;
146
+ margin: 12px 0;
147
+ overflow-x: auto;
148
+ line-height: 1.5;
149
+ font-size: 0.88rem;
150
+ }
151
+ pre code {
152
+ background: none;
153
+ border: none;
154
+ padding: 0;
155
+ font-size: inherit;
156
+ }
157
+ section.connect {
158
+ margin: 36px 0 8px;
159
+ padding: 24px 0;
160
+ border-top: 1px solid var(--border);
161
+ }
162
+ section.connect h2 {
163
+ margin: 0 0 12px;
164
+ font-size: 1.4rem;
165
+ letter-spacing: -0.01em;
166
+ }
167
+ section.connect h3 {
168
+ margin: 22px 0 6px;
169
+ font-size: 1.08rem;
170
+ color: var(--accent-hover);
171
+ }
172
+ section.connect p {
173
+ margin: 8px 0;
174
+ color: var(--text);
175
+ }
176
+ section.connect p.lead {
177
+ color: var(--muted);
178
+ }
179
+ section.connect .note {
180
+ color: var(--muted);
181
+ font-size: 0.92rem;
182
+ }
183
+ section.connect .cta {
184
+ margin: 14px 0;
185
+ }
186
+ /* Client picker — pure CSS, no JavaScript. Radio inputs hold the
187
+ selected tab; the labels are the visible buttons arranged in a
188
+ horizontal row; `:checked ~` reveals the matching panel and
189
+ highlights its label. See build-smoke "works without JavaScript". */
190
+ .client-tabs {
191
+ margin: 8px 0 4px;
192
+ }
193
+ .client-tabs > input[type="radio"] {
194
+ position: absolute;
195
+ opacity: 0;
196
+ pointer-events: none;
197
+ }
198
+ .client-tabs .tablist {
199
+ display: flex;
200
+ flex-wrap: wrap;
201
+ gap: 8px;
202
+ margin-bottom: 16px;
203
+ }
204
+ .client-tabs .tablist label {
205
+ padding: 10px 16px;
206
+ cursor: pointer;
207
+ border: 1px solid var(--border);
208
+ border-radius: 8px;
209
+ background-color: var(--surface);
210
+ color: var(--muted);
211
+ font-weight: 600;
212
+ font-size: 0.95rem;
213
+ -webkit-tap-highlight-color: transparent;
214
+ transition: border-color 0.15s, color 0.15s, background-color 0.15s;
215
+ }
216
+ .client-tabs .tablist label:hover {
217
+ border-color: var(--accent);
218
+ color: var(--text);
219
+ }
220
+ .client-tabs .panel {
221
+ display: none;
222
+ }
223
+ .client-tabs .panel h3 {
224
+ margin-top: 0;
225
+ }
226
+ #tab-web:checked ~ .tablist label[for="tab-web"],
227
+ #tab-weechat:checked ~ .tablist label[for="tab-weechat"],
228
+ #tab-hexchat:checked ~ .tablist label[for="tab-hexchat"],
229
+ #tab-irccloud:checked ~ .tablist label[for="tab-irccloud"] {
230
+ background-color: var(--bg);
231
+ color: var(--accent-hover);
232
+ border-color: var(--accent);
233
+ }
234
+ #tab-web:focus-visible ~ .tablist label[for="tab-web"],
235
+ #tab-weechat:focus-visible ~ .tablist label[for="tab-weechat"],
236
+ #tab-hexchat:focus-visible ~ .tablist label[for="tab-hexchat"],
237
+ #tab-irccloud:focus-visible ~ .tablist label[for="tab-irccloud"] {
238
+ outline: 2px solid var(--accent);
239
+ outline-offset: 2px;
240
+ }
241
+ #tab-web:checked ~ #panel-web,
242
+ #tab-weechat:checked ~ #panel-weechat,
243
+ #tab-hexchat:checked ~ #panel-hexchat,
244
+ #tab-irccloud:checked ~ #panel-irccloud {
245
+ display: block;
246
+ }
138
247
  .links {
139
248
  margin-top: 28px;
140
249
  padding: 20px 0;
@@ -170,7 +279,7 @@
170
279
  <header>
171
280
  <div class="container">
172
281
  <h1>Serverless<span class="irc">IRCd</span></h1>
173
- <p>An IRC daemon with a serverless deployment model.</p>
282
+ <p>IRC daemon with a serverless deployment model.</p>
174
283
  </div>
175
284
  </header>
176
285
 
@@ -194,7 +303,122 @@
194
303
  <a class="primary" href="/webclient/">Launch Web Client</a>
195
304
  </div>
196
305
 
306
+ <section class="connect">
307
+ <h2>Connect</h2>
308
+ <p class="lead">
309
+ ServerlessIRCd speaks IRC over secure WebSocket
310
+ (<code>wss://</code>, port <code>443</code>) as its primary
311
+ transport, and optionally raw <code>irc+tls</code> on port
312
+ <code>6697</code>. Replace <code>irc.example.com</code> with your
313
+ deployed Worker hostname in the snippets below. This page is
314
+ static and identical across staging and prod.
315
+ </p>
316
+
317
+ <div class="client-tabs">
318
+ <input type="radio" name="client-tab" id="tab-web" checked />
319
+ <input type="radio" name="client-tab" id="tab-weechat" />
320
+ <input type="radio" name="client-tab" id="tab-hexchat" />
321
+ <input type="radio" name="client-tab" id="tab-irccloud" />
322
+
323
+ <div
324
+ class="tablist"
325
+ role="tablist"
326
+ aria-label="IRC client connect instructions"
327
+ >
328
+ <label for="tab-web" class="tab" role="tab">Web client</label>
329
+ <label for="tab-weechat" class="tab" role="tab">WeeChat</label>
330
+ <label for="tab-hexchat" class="tab" role="tab"
331
+ >HexChat / irssi</label
332
+ >
333
+ <label for="tab-irccloud" class="tab" role="tab"
334
+ >IRCCloud / TheLounge</label
335
+ >
336
+ </div>
337
+
338
+ <div
339
+ class="panel"
340
+ id="panel-web"
341
+ role="tabpanel"
342
+ aria-labelledby="tab-web"
343
+ >
344
+ <p>
345
+ <code>wss</code> on port <code>443</code>, same origin as this
346
+ page. No client install or bridge required.
347
+ </p>
348
+ <div class="cta">
349
+ <a class="primary" href="/webclient/">Launch Web Client</a>
350
+ </div>
351
+ </div>
352
+
353
+ <div
354
+ class="panel"
355
+ id="panel-weechat"
356
+ role="tabpanel"
357
+ aria-labelledby="tab-weechat"
358
+ >
359
+ <p>
360
+ Connect over <code>wss</code> (port <code>443</code>) using the
361
+ snippet below, or directly over <code>irc+tls</code> on port
362
+ <code>6697</code> when the TCP+TLS surface is deployed.
363
+ </p>
364
+ <pre><code>/server add ircd irc.example.com/443
365
+ /set irc.server.ircd.ssl on
366
+ /connect ircd
367
+ /join #test</code></pre>
368
+ <p class="note">
369
+ For the <code>irc+tls :6697</code> path, swap the port above for
370
+ <code>6697</code>. The TCP surface is a separate deployment
371
+ (Cloudflare Spectrum + Container); see the
372
+ <a
373
+ href="https://gitea.com/ServerlessIRCd/ServerlessIRCd/wiki/Cloudflare-TCP-Deployment"
374
+ >Cloudflare TCP Deployment Guide</a
375
+ >.
376
+ </p>
377
+ </div>
378
+
379
+ <div
380
+ class="panel"
381
+ id="panel-hexchat"
382
+ role="tabpanel"
383
+ aria-labelledby="tab-hexchat"
384
+ >
385
+ <p>
386
+ These TCP-only IRC clients do not support WebSocket natively.
387
+ Use <code>tools/tcp-ws-forwarder</code> to bridge them: it
388
+ listens on a local TCP port and forwards each IRC line over
389
+ <code>wss</code> to the Worker, negotiating the
390
+ <code>binary.ircv3.net</code> subprotocol upstream.
391
+ </p>
392
+ <pre><code>pnpm --filter tcp-ws-forwarder start \
393
+ --target wss://irc.example.com/ --listen-port 16667</code></pre>
394
+ <p class="note">
395
+ Then point HexChat / irssi at <code>127.0.0.1:16667</code>
396
+ (TLS off &mdash; the bridge is local). Bridging instructions
397
+ are in the
398
+ <a
399
+ href="https://gitea.com/ServerlessIRCd/ServerlessIRCd/wiki/WebClientGuide"
400
+ >Web Client Guide</a
401
+ >.
402
+ </p>
403
+ </div>
404
+
405
+ <div
406
+ class="panel"
407
+ id="panel-irccloud"
408
+ role="tabpanel"
409
+ aria-labelledby="tab-irccloud"
410
+ >
411
+ <p>
412
+ Both support WebSocket natively. Add a network pointing at
413
+ <code>wss://irc.example.com/</code> (port <code>443</code>, TLS
414
+ on); no bridge required.
415
+ </p>
416
+ </div>
417
+ </div>
418
+ </section>
419
+
197
420
  <div class="links">
421
+ <a href="/docs/">Docs</a>
198
422
  <a href="https://gitea.com/ServerlessIRCd/ServerlessIRCd">Source Code (Gitea)</a>
199
423
  <a href="https://gitea.com/ServerlessIRCd/ServerlessIRCd/issues">Issue Tracker</a>
200
424
  <a href="/webclient/">Web IRC Client</a>
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@serverless-ircd/web",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "description": "Web client workspace: Kiwi IRC SPA served at /webclient/ and project landing page at /",
6
6
  "license": "BSD-3-Clause",
7
7
  "type": "module",
8
8
  "scripts": {
9
9
  "build": "node scripts/build.mjs",
10
- "build:staging": "node scripts/build.mjs --env staging",
11
10
  "build:prod": "node scripts/build.mjs --env prod",
11
+ "build:prod-aws": "node scripts/build.mjs --env prod-aws",
12
12
  "typecheck": "tsc -p tsconfig.json --noEmit",
13
13
  "test": "vitest run",
14
14
  "test:watch": "vitest",
@@ -21,6 +21,7 @@
21
21
  "devDependencies": {
22
22
  "@types/node": "^26.1.1",
23
23
  "@vitest/coverage-v8": "^4.1.0",
24
+ "marked": "^18.0.9",
24
25
  "rimraf": "^6.0.0",
25
26
  "typescript": "^5.6.0",
26
27
  "vitest": "^4.1.0"
@@ -3,14 +3,24 @@ import { existsSync } from 'node:fs';
3
3
  import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { resolveConfigFile } from '../src/build-env.ts';
6
+ import {
7
+ hasUnsubstitutedPlaceholder,
8
+ parseApiUrlForKiwi,
9
+ resolveApiUrl,
10
+ resolveConfigFile,
11
+ } from '../src/build-env.ts';
7
12
  import { parseKiwiConfig } from '../src/config-schema.ts';
13
+ import { buildDocs } from '../src/render-docs.ts';
8
14
 
9
15
  const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
16
  const upstream = path.resolve(pkgRoot, 'upstream');
11
17
  const dist = path.resolve(pkgRoot, 'dist');
12
18
  const appDist = path.join(dist, 'webclient');
13
19
  const staticDir = path.join(pkgRoot, 'static');
20
+ // The `docs/` submodule lives at the monorepo root (two levels up from
21
+ // apps/web). Rendered to dist/docs/ by the docs build step (see step 6).
22
+ const docsSrc = path.resolve(pkgRoot, '..', '..', 'docs');
23
+ const docsDist = path.join(dist, 'docs');
14
24
 
15
25
  // The SPA is served at /webclient/ on the Worker (the [assets] binding serves
16
26
  // `dist/`). All hashed asset URLs must be /webclient/-prefixed so they load
@@ -44,6 +54,16 @@ if (!existsSync(upstream)) {
44
54
  );
45
55
  }
46
56
 
57
+ // The `docs/` submodule must be checked out — step 6 renders every docs/*.md
58
+ // to dist/docs/. Mirrors the Kiwi upstream guard above so a stale/partial
59
+ // submodule checkout fails loudly with the recovery command instead of
60
+ // producing an empty docs/ section on the deployed site.
61
+ if (!existsSync(docsSrc)) {
62
+ throw new Error(
63
+ `Docs submodule missing at ${docsSrc}. Run \`git submodule update --init docs\` first.`,
64
+ );
65
+ }
66
+
47
67
  // 1. Install upstream deps. Kiwi ships a yarn.lock; --frozen-lockfile gives a
48
68
  // reproducible tree. Contributors get yarn via `corepack enable`. The
49
69
  // guard checks for the actual binary the build invokes, not just the
@@ -92,11 +112,12 @@ await writeFile(indexDest, indexHtml);
92
112
  // so missing fields, out-of-range ports, and non-`#` channels abort the
93
113
  // build here with a readable error. The validated JSON is re-serialised
94
114
  // (stable key order) so the baked config is identical to the schema output.
95
- const { file: configFileName, env: configEnv } = resolveConfigFile(process.argv.slice(2));
115
+ const argv = process.argv.slice(2);
116
+ const { file: configFileName, env: configEnv } = resolveConfigFile(argv);
96
117
  const configSrcPath = path.join(staticDir, configFileName);
97
118
  if (!existsSync(configSrcPath)) {
98
119
  throw new Error(
99
- `Config file for env "${configEnv}" not found at ${configSrcPath}. Expected one of: config.json, config.staging.json, config.prod.json.`,
120
+ `Config file for env "${configEnv}" not found at ${configSrcPath}. Expected one of: config.json, config.prod.json, config.prod-aws.json.`,
100
121
  );
101
122
  }
102
123
  const configRaw = await readFile(configSrcPath, 'utf8');
@@ -108,6 +129,50 @@ try {
108
129
  // offending env config, not just the schema path.
109
130
  throw new Error(`${configFileName}: ${err.message}`, { cause: err });
110
131
  }
132
+
133
+ // 4b. AWS deploy pipeline override: `--api-url <wss-url>` (read from the
134
+ // deployed stack's `ConnectUrl` CloudFormation output by
135
+ // `scripts/deploy-web-aws.mjs`) replaces the templated `server` value
136
+ // baked into `config.prod-aws.json` with the real API Gateway
137
+ // WebSocket host. No concrete URLs in the repo; substitution happens
138
+ // at deploy time.
139
+ //
140
+ // irc-framework builds the WS URL as `wss://<server>:<port><direct_path>`,
141
+ // prepending `wss://` itself whenever `tls: true`. The override MUST
142
+ // split the URL into a hostname-only `server` and a stage-name
143
+ // `direct_path` (e.g. `/prod`) — assigning the full `wss://...` URL to
144
+ // `server` would make irc-framework prepend a second `wss://`,
145
+ // producing the malformed `wss://wss//<host>...` URL that Firefox
146
+ // rejects. When the flag is absent (Cloudflare deploys, local dev)
147
+ // the config's `server`/`direct_path` fields are left untouched.
148
+ const apiUrlOverride = resolveApiUrl(argv);
149
+ if (apiUrlOverride !== undefined) {
150
+ const { server, directPath } = parseApiUrlForKiwi(apiUrlOverride);
151
+ configParsed.startupOptions.server = server;
152
+ if (directPath !== undefined) {
153
+ configParsed.startupOptions.direct_path = directPath;
154
+ }
155
+ }
156
+
157
+ // 4c. Reject unsubstituted AWS deploy-time placeholders — but ONLY when
158
+ // the selected config is the prod-aws env. The prod-aws config carries
159
+ // `{{API_ID}}`/`{{REGION}}`/`{{STAGE}}` that MUST be substituted by the
160
+ // deploy pipeline via `--api-url`; a baked prod-aws build that left
161
+ // them in place would ship a broken SPA dialling a literal `{{API_ID}}...`
162
+ // URL.
163
+ //
164
+ // The CF envs (`default`, `prod`) use Kiwi's runtime templates
165
+ // (`{{hostname}}`, `{{port}}`) which are resolved in the browser at
166
+ // load time and must survive the bake. Applying the guard to them
167
+ // would break every default `pnpm build` and every `build:prod`, so
168
+ // the helper scopes the check to non-CF envs. See
169
+ // `hasUnsubstitutedPlaceholder` in `src/build-env.ts`.
170
+ if (hasUnsubstitutedPlaceholder(JSON.stringify(configParsed), configEnv)) {
171
+ throw new Error(
172
+ `Refusing to bake ${configFileName} with unsubstituted {{...}} placeholders. Pass --api-url <wss-url> (the deployed stack's ConnectUrl output) to substitute the API Gateway WebSocket URL.`,
173
+ );
174
+ }
175
+
111
176
  await writeFile(
112
177
  path.join(appDist, 'static', 'config.json'),
113
178
  `${JSON.stringify(configParsed, null, 2)}\n`,
@@ -116,8 +181,13 @@ await writeFile(
116
181
 
117
182
  // 5. Landing page: copy the static landing page (landing/index.html) to
118
183
  // dist/index.html so the Worker `[assets]` binding serves it at `/`. Pure
119
- // static HTML + inline CSS (no JS); links to /webclient/ (the SPA), docs/,
120
- // and the source repo.
184
+ // static HTML + inline CSS (no JS); links to /webclient/ (the SPA),
185
+ // /docs/ (the rendered docs built in step 6), and the source repo.
186
+ // Also copies landing/favicon.ico → dist/favicon.ico so both platforms
187
+ // (CF [assets] binding and AWS `s3 sync apps/web/dist`) serve the
188
+ // project-front-door icon at /favicon.ico. The Kiwi SPA has its own
189
+ // icon at /webclient/static/favicon.png (a Kiwi-upstream asset, copied
190
+ // in step 3); this one is for the landing page only.
121
191
  await mkdir(dist, { recursive: true });
122
192
  const landingSrc = path.join(pkgRoot, 'landing', 'index.html');
123
193
  if (!existsSync(landingSrc)) {
@@ -126,7 +196,22 @@ if (!existsSync(landingSrc)) {
126
196
  );
127
197
  }
128
198
  await cp(landingSrc, path.join(dist, 'index.html'));
199
+ const faviconSrc = path.join(pkgRoot, 'landing', 'favicon.ico');
200
+ if (!existsSync(faviconSrc)) {
201
+ throw new Error(
202
+ `Landing page favicon missing at ${faviconSrc}. Expected apps/web/landing/favicon.ico.`,
203
+ );
204
+ }
205
+ await cp(faviconSrc, path.join(dist, 'favicon.ico'));
206
+
207
+ // 6. Docs: render every docs/*.md to standalone HTML under dist/docs/ so the
208
+ // Worker `[assets]` binding serves them at /docs/<name>.html (and /docs/
209
+ // for the index — Home.md becomes index.html). Requires the `docs/`
210
+ // submodule (guarded at step 0). Zero Worker changes — the existing
211
+ // [assets] binding already serves dist/ with not_found_handling = "none".
212
+ const docsResult = await buildDocs({ srcDir: docsSrc, outDir: docsDist });
129
213
 
130
214
  console.log(
131
- `Kiwi SPA built -> dist/webclient/ (publicPath=${PUBLIC_PATH}, config=${configFileName}, env=${configEnv})`,
215
+ `Kiwi SPA built -> dist/webclient/ (publicPath=${PUBLIC_PATH}, config=${configFileName}, env=${configEnv}, apiUrl=${apiUrlOverride === undefined ? 'from-config' : 'overridden'})`,
132
216
  );
217
+ console.log(`Docs rendered -> dist/docs/ (${docsResult.emitted.length} pages, index=index.html)`);