serverless-ircd 0.7.0 → 0.9.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 (168) hide show
  1. package/.github/workflows/ci.yml +7 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +518 -29
  4. package/README.md +221 -161
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  10. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  11. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  12. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  13. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  14. package/apps/cf-worker/package.json +1 -1
  15. package/apps/cf-worker/src/worker.ts +4 -4
  16. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  17. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  18. package/apps/cf-worker/wrangler.test.toml +6 -6
  19. package/apps/cf-worker/wrangler.toml +7 -5
  20. package/apps/local-cli/package.json +1 -1
  21. package/apps/local-cli/src/config-loader.ts +8 -0
  22. package/apps/local-cli/src/main.ts +16 -0
  23. package/apps/local-cli/src/server.ts +95 -31
  24. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  25. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  26. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  27. package/apps/local-cli/tests/rehash.test.ts +147 -0
  28. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  29. package/apps/local-cli/tests/tcp.test.ts +89 -0
  30. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  31. package/apps/web/landing/index.html +237 -16
  32. package/apps/web/package.json +3 -2
  33. package/apps/web/scripts/build.mjs +46 -16
  34. package/apps/web/src/build-env.ts +1 -1
  35. package/apps/web/src/config-schema.ts +6 -6
  36. package/apps/web/src/render-docs.ts +292 -0
  37. package/apps/web/tests/build-smoke.test.ts +43 -14
  38. package/apps/web/tests/config-schema.test.ts +1 -1
  39. package/apps/web/tests/landing-content.test.ts +103 -0
  40. package/apps/web/tests/render-docs.test.ts +198 -0
  41. package/docs/AWS-Adapter-Architecture.md +3 -2
  42. package/docs/AWS-Deployment.md +21 -8
  43. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  44. package/docs/PlanExtensions.md +113 -3
  45. package/docs/Release-Process.md +23 -13
  46. package/docs/Services.md +578 -0
  47. package/docs/WebClientGuide.md +32 -31
  48. package/package.json +2 -2
  49. package/packages/aws-adapter/package.json +1 -1
  50. package/packages/aws-adapter/src/aws-runtime.ts +20 -1
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  52. package/packages/aws-adapter/src/config-loader.ts +11 -0
  53. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  54. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  56. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  57. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
  59. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  60. package/packages/aws-adapter/src/index.ts +4 -0
  61. package/packages/aws-adapter/src/stats.ts +6 -1
  62. package/packages/aws-adapter/src/tables.ts +34 -4
  63. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  65. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  66. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  67. package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
  68. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  69. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  70. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  72. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  73. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  74. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  76. package/packages/cf-adapter/package.json +1 -1
  77. package/packages/cf-adapter/src/config-loader.ts +11 -0
  78. package/packages/cf-adapter/src/connection-do.ts +130 -8
  79. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  80. package/packages/cf-adapter/src/env.ts +8 -0
  81. package/packages/cf-adapter/src/index.ts +5 -0
  82. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  83. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  84. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  85. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  86. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  87. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  88. package/packages/in-memory-runtime/package.json +1 -1
  89. package/packages/irc-core/package.json +1 -1
  90. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  91. package/packages/irc-core/src/commands/account-auth.ts +200 -0
  92. package/packages/irc-core/src/commands/chanserv.ts +1166 -0
  93. package/packages/irc-core/src/commands/hostserv.ts +522 -0
  94. package/packages/irc-core/src/commands/index.ts +13 -0
  95. package/packages/irc-core/src/commands/join.ts +171 -9
  96. package/packages/irc-core/src/commands/markread.ts +202 -0
  97. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  98. package/packages/irc-core/src/commands/mode.ts +96 -4
  99. package/packages/irc-core/src/commands/nickserv.ts +402 -0
  100. package/packages/irc-core/src/commands/oper.ts +18 -1
  101. package/packages/irc-core/src/commands/operserv.ts +346 -0
  102. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  103. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  104. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  105. package/packages/irc-core/src/commands/registration.ts +78 -12
  106. package/packages/irc-core/src/commands/sasl.ts +18 -49
  107. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  108. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  109. package/packages/irc-core/src/commands/topic.ts +60 -10
  110. package/packages/irc-core/src/config.ts +36 -5
  111. package/packages/irc-core/src/effects.ts +56 -1
  112. package/packages/irc-core/src/ports.ts +1653 -84
  113. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  114. package/packages/irc-core/src/state/channel.ts +38 -1
  115. package/packages/irc-core/src/state/connection.ts +25 -1
  116. package/packages/irc-core/src/types.ts +48 -12
  117. package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
  118. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  119. package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
  120. package/packages/irc-core/tests/commands/join.test.ts +572 -1
  121. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  122. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  123. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  124. package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
  125. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  126. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  127. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  128. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  129. package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
  130. package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
  131. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  132. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  133. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  134. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  135. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  136. package/packages/irc-core/tests/config.test.ts +49 -5
  137. package/packages/irc-core/tests/effects.test.ts +19 -0
  138. package/packages/irc-core/tests/message-store.test.ts +63 -0
  139. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  140. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  141. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  142. package/packages/irc-server/package.json +1 -1
  143. package/packages/irc-server/src/actor.ts +121 -16
  144. package/packages/irc-server/src/dispatch.ts +94 -7
  145. package/packages/irc-server/src/routing.ts +19 -0
  146. package/packages/irc-server/tests/actor.test.ts +988 -15
  147. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  148. package/packages/irc-server/tests/routing.test.ts +6 -0
  149. package/packages/irc-test-support/package.json +1 -1
  150. package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
  151. package/packages/irc-test-support/src/index.ts +1 -0
  152. package/packages/irc-test-support/src/scenarios.ts +21 -6
  153. package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
  154. package/packages/irc-test-support/vitest.config.ts +6 -1
  155. package/tools/ci-hardening/package.json +1 -1
  156. package/tools/load-test/package.json +1 -1
  157. package/tools/load-test/src/client.ts +13 -13
  158. package/tools/load-test/tests/client.test.ts +258 -2
  159. package/tools/load-test/tests/config.test.ts +39 -0
  160. package/tools/load-test/tests/harness.test.ts +21 -0
  161. package/tools/load-test/tests/metrics.test.ts +7 -0
  162. package/tools/tcp-ws-forwarder/package.json +1 -1
  163. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  164. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  165. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
  166. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  167. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  168. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -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
+ });
@@ -4,8 +4,9 @@
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 `/app/`; this page links through to it plus the
8
- 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>
@@ -135,6 +136,113 @@
135
136
  border-radius: 4px;
136
137
  font-size: 0.9em;
137
138
  }
139
+ pre {
140
+ font-family: var(--mono);
141
+ background-color: var(--surface);
142
+ border: 1px solid var(--border);
143
+ border-radius: 8px;
144
+ padding: 14px 16px;
145
+ margin: 12px 0;
146
+ overflow-x: auto;
147
+ line-height: 1.5;
148
+ font-size: 0.88rem;
149
+ }
150
+ pre code {
151
+ background: none;
152
+ border: none;
153
+ padding: 0;
154
+ font-size: inherit;
155
+ }
156
+ section.connect {
157
+ margin: 36px 0 8px;
158
+ padding: 24px 0;
159
+ border-top: 1px solid var(--border);
160
+ }
161
+ section.connect h2 {
162
+ margin: 0 0 12px;
163
+ font-size: 1.4rem;
164
+ letter-spacing: -0.01em;
165
+ }
166
+ section.connect h3 {
167
+ margin: 22px 0 6px;
168
+ font-size: 1.08rem;
169
+ color: var(--accent-hover);
170
+ }
171
+ section.connect p {
172
+ margin: 8px 0;
173
+ color: var(--text);
174
+ }
175
+ section.connect p.lead {
176
+ color: var(--muted);
177
+ }
178
+ section.connect .note {
179
+ color: var(--muted);
180
+ font-size: 0.92rem;
181
+ }
182
+ section.connect .cta {
183
+ margin: 14px 0;
184
+ }
185
+ /* Client picker — pure CSS, no JavaScript. Radio inputs hold the
186
+ selected tab; the labels are the visible buttons arranged in a
187
+ horizontal row; `:checked ~` reveals the matching panel and
188
+ highlights its label. See build-smoke "works without JavaScript". */
189
+ .client-tabs {
190
+ margin: 8px 0 4px;
191
+ }
192
+ .client-tabs > input[type="radio"] {
193
+ position: absolute;
194
+ opacity: 0;
195
+ pointer-events: none;
196
+ }
197
+ .client-tabs .tablist {
198
+ display: flex;
199
+ flex-wrap: wrap;
200
+ gap: 8px;
201
+ margin-bottom: 16px;
202
+ }
203
+ .client-tabs .tablist label {
204
+ padding: 10px 16px;
205
+ cursor: pointer;
206
+ border: 1px solid var(--border);
207
+ border-radius: 8px;
208
+ background-color: var(--surface);
209
+ color: var(--muted);
210
+ font-weight: 600;
211
+ font-size: 0.95rem;
212
+ -webkit-tap-highlight-color: transparent;
213
+ transition: border-color 0.15s, color 0.15s, background-color 0.15s;
214
+ }
215
+ .client-tabs .tablist label:hover {
216
+ border-color: var(--accent);
217
+ color: var(--text);
218
+ }
219
+ .client-tabs .panel {
220
+ display: none;
221
+ }
222
+ .client-tabs .panel h3 {
223
+ margin-top: 0;
224
+ }
225
+ #tab-web:checked ~ .tablist label[for="tab-web"],
226
+ #tab-weechat:checked ~ .tablist label[for="tab-weechat"],
227
+ #tab-hexchat:checked ~ .tablist label[for="tab-hexchat"],
228
+ #tab-irccloud:checked ~ .tablist label[for="tab-irccloud"] {
229
+ background-color: var(--bg);
230
+ color: var(--accent-hover);
231
+ border-color: var(--accent);
232
+ }
233
+ #tab-web:focus-visible ~ .tablist label[for="tab-web"],
234
+ #tab-weechat:focus-visible ~ .tablist label[for="tab-weechat"],
235
+ #tab-hexchat:focus-visible ~ .tablist label[for="tab-hexchat"],
236
+ #tab-irccloud:focus-visible ~ .tablist label[for="tab-irccloud"] {
237
+ outline: 2px solid var(--accent);
238
+ outline-offset: 2px;
239
+ }
240
+ #tab-web:checked ~ #panel-web,
241
+ #tab-weechat:checked ~ #panel-weechat,
242
+ #tab-hexchat:checked ~ #panel-hexchat,
243
+ #tab-irccloud:checked ~ #panel-irccloud {
244
+ display: block;
245
+ }
138
246
  .links {
139
247
  margin-top: 28px;
140
248
  padding: 20px 0;
@@ -170,7 +278,7 @@
170
278
  <header>
171
279
  <div class="container">
172
280
  <h1>Serverless<span class="irc">IRCd</span></h1>
173
- <p>A serverless IRC daemon. One TypeScript core, two serverless substrates.</p>
281
+ <p>IRC daemon with a serverless deployment model.</p>
174
282
  </div>
175
283
  </header>
176
284
 
@@ -178,28 +286,141 @@
178
286
  <div class="container">
179
287
  <p>
180
288
  ServerlessIRCd is an IRC server where the protocol logic lives in a
181
- pure, platform-agnostic core, and two thin adapters run it on
182
- <strong>Cloudflare Workers</strong> (Durable Objects) or
289
+ platform-agnostic TypeScript core. Two adapters run that core on
290
+ <strong>Cloudflare Workers</strong> (Durable Objects) and
183
291
  <strong>AWS</strong> (API Gateway WebSockets + Lambda + DynamoDB).
184
- The same code, unchanged, on both.
185
292
  </p>
186
293
 
187
294
  <ul class="features">
188
- <li>Pure-reducer protocol core every command is a trivial unit test</li>
189
- <li>Dual transport: WebSocket <em>and</em> <code>irc+tls</code> on port 6697</li>
190
- <li>Full IRCv3 surface: message-tags, chathistory, SASL, labeled-response, and more</li>
191
- <li>Authoritative state in one place per datum — races are structurally impossible</li>
295
+ <li>Reducer-based protocol core: each command is a pure function</li>
296
+ <li>WebSocket and <code>irc+tls</code> (port 6697) transports</li>
297
+ <li>IRCv3 extensions: message-tags, chathistory, SASL, labeled-response</li>
298
+ <li>Per-datum authoritative state held in a single location</li>
192
299
  </ul>
193
300
 
194
301
  <div class="cta">
195
- <a class="primary" href="/app/">Launch Web Client</a>
196
- <a class="secondary" href="docs/">Read the Docs</a>
302
+ <a class="primary" href="/webclient/">Launch Web Client</a>
197
303
  </div>
198
304
 
305
+ <section class="connect">
306
+ <h2>Connect</h2>
307
+ <p class="lead">
308
+ ServerlessIRCd speaks IRC over secure WebSocket
309
+ (<code>wss://</code>, port <code>443</code>) as its primary
310
+ transport, and optionally raw <code>irc+tls</code> on port
311
+ <code>6697</code>. Replace <code>irc.example.com</code> with your
312
+ deployed Worker hostname in the snippets below. This page is
313
+ static and identical across staging and prod.
314
+ </p>
315
+
316
+ <div class="client-tabs">
317
+ <input type="radio" name="client-tab" id="tab-web" checked />
318
+ <input type="radio" name="client-tab" id="tab-weechat" />
319
+ <input type="radio" name="client-tab" id="tab-hexchat" />
320
+ <input type="radio" name="client-tab" id="tab-irccloud" />
321
+
322
+ <div
323
+ class="tablist"
324
+ role="tablist"
325
+ aria-label="IRC client connect instructions"
326
+ >
327
+ <label for="tab-web" class="tab" role="tab">Web client</label>
328
+ <label for="tab-weechat" class="tab" role="tab">WeeChat</label>
329
+ <label for="tab-hexchat" class="tab" role="tab"
330
+ >HexChat / irssi</label
331
+ >
332
+ <label for="tab-irccloud" class="tab" role="tab"
333
+ >IRCCloud / TheLounge</label
334
+ >
335
+ </div>
336
+
337
+ <div
338
+ class="panel"
339
+ id="panel-web"
340
+ role="tabpanel"
341
+ aria-labelledby="tab-web"
342
+ >
343
+ <p>
344
+ <code>wss</code> on port <code>443</code>, same origin as this
345
+ page. No client install or bridge required.
346
+ </p>
347
+ <div class="cta">
348
+ <a class="primary" href="/webclient/">Launch Web Client</a>
349
+ </div>
350
+ </div>
351
+
352
+ <div
353
+ class="panel"
354
+ id="panel-weechat"
355
+ role="tabpanel"
356
+ aria-labelledby="tab-weechat"
357
+ >
358
+ <p>
359
+ Connect over <code>wss</code> (port <code>443</code>) using the
360
+ snippet below, or directly over <code>irc+tls</code> on port
361
+ <code>6697</code> when the TCP+TLS surface is deployed.
362
+ </p>
363
+ <pre><code>/server add ircd irc.example.com/443
364
+ /set irc.server.ircd.ssl on
365
+ /connect ircd
366
+ /join #test</code></pre>
367
+ <p class="note">
368
+ For the <code>irc+tls :6697</code> path, swap the port above for
369
+ <code>6697</code>. The TCP surface is a separate deployment
370
+ (Cloudflare Spectrum + Container); see the
371
+ <a
372
+ href="https://gitea.com/ServerlessIRCd/ServerlessIRCd/wiki/Cloudflare-TCP-Deployment"
373
+ >Cloudflare TCP Deployment Guide</a
374
+ >.
375
+ </p>
376
+ </div>
377
+
378
+ <div
379
+ class="panel"
380
+ id="panel-hexchat"
381
+ role="tabpanel"
382
+ aria-labelledby="tab-hexchat"
383
+ >
384
+ <p>
385
+ These TCP-only IRC clients do not support WebSocket natively.
386
+ Use <code>tools/tcp-ws-forwarder</code> to bridge them: it
387
+ listens on a local TCP port and forwards each IRC line over
388
+ <code>wss</code> to the Worker, negotiating the
389
+ <code>binary.ircv3.net</code> subprotocol upstream.
390
+ </p>
391
+ <pre><code>pnpm --filter tcp-ws-forwarder start \
392
+ --target wss://irc.example.com/ --listen-port 16667</code></pre>
393
+ <p class="note">
394
+ Then point HexChat / irssi at <code>127.0.0.1:16667</code>
395
+ (TLS off &mdash; the bridge is local). Bridging instructions
396
+ are in the
397
+ <a
398
+ href="https://gitea.com/ServerlessIRCd/ServerlessIRCd/wiki/WebClientGuide"
399
+ >Web Client Guide</a
400
+ >.
401
+ </p>
402
+ </div>
403
+
404
+ <div
405
+ class="panel"
406
+ id="panel-irccloud"
407
+ role="tabpanel"
408
+ aria-labelledby="tab-irccloud"
409
+ >
410
+ <p>
411
+ Both support WebSocket natively. Add a network pointing at
412
+ <code>wss://irc.example.com/</code> (port <code>443</code>, TLS
413
+ on); no bridge required.
414
+ </p>
415
+ </div>
416
+ </div>
417
+ </section>
418
+
199
419
  <div class="links">
200
- <a href="https://github.com/fowlmouth/ServerlessIRCd">Source Code (GitHub)</a>
201
- <a href="https://github.com/fowlmouth/ServerlessIRCd/issues">Issue Tracker</a>
202
- <a href="/app/">Web IRC Client</a>
420
+ <a href="/docs/">Docs</a>
421
+ <a href="https://gitea.com/ServerlessIRCd/ServerlessIRCd">Source Code (Gitea)</a>
422
+ <a href="https://gitea.com/ServerlessIRCd/ServerlessIRCd/issues">Issue Tracker</a>
423
+ <a href="/webclient/">Web IRC Client</a>
203
424
  </div>
204
425
  </div>
205
426
  </main>
@@ -208,7 +429,7 @@
208
429
  <div class="container">
209
430
  <p>
210
431
  ServerlessIRCd &middot;
211
- <a href="https://github.com/fowlmouth/ServerlessIRCd">GitHub</a> &middot;
432
+ <a href="https://gitea.com/ServerlessIRCd/ServerlessIRCd">Gitea</a> &middot;
212
433
  BSD-3-Clause License
213
434
  </p>
214
435
  </div>
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@serverless-ircd/web",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
- "description": "Web client workspace: Kiwi IRC SPA served at /app/ and project landing page at /",
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": {
@@ -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"
@@ -5,17 +5,22 @@ import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { resolveConfigFile } from '../src/build-env.ts';
7
7
  import { parseKiwiConfig } from '../src/config-schema.ts';
8
+ import { buildDocs } from '../src/render-docs.ts';
8
9
 
9
10
  const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
11
  const upstream = path.resolve(pkgRoot, 'upstream');
11
12
  const dist = path.resolve(pkgRoot, 'dist');
12
- const appDist = path.join(dist, 'app');
13
+ const appDist = path.join(dist, 'webclient');
13
14
  const staticDir = path.join(pkgRoot, 'static');
15
+ // The `docs/` submodule lives at the monorepo root (two levels up from
16
+ // apps/web). Rendered to dist/docs/ by the docs build step (see step 6).
17
+ const docsSrc = path.resolve(pkgRoot, '..', '..', 'docs');
18
+ const docsDist = path.join(dist, 'docs');
14
19
 
15
- // The SPA is served at /app/ on the Worker (the [assets] binding arrives in a
16
- // later ticket). All hashed asset URLs must be /app/-prefixed so they load
17
- // from /app/static/... rather than /static/... (which would 404 at root).
18
- const PUBLIC_PATH = '/app/';
20
+ // The SPA is served at /webclient/ on the Worker (the [assets] binding serves
21
+ // `dist/`). All hashed asset URLs must be /webclient/-prefixed so they load
22
+ // from /webclient/static/... rather than /static/... (which would 404 at root).
23
+ const PUBLIC_PATH = '/webclient/';
19
24
 
20
25
  // Yarn runs from a pnpm lifecycle script. Kiwi v1.7.1's package.json has no
21
26
  // `packageManager` field, so yarn classic walks up and finds the workspace
@@ -44,16 +49,32 @@ if (!existsSync(upstream)) {
44
49
  );
45
50
  }
46
51
 
52
+ // The `docs/` submodule must be checked out — step 6 renders every docs/*.md
53
+ // to dist/docs/. Mirrors the Kiwi upstream guard above so a stale/partial
54
+ // submodule checkout fails loudly with the recovery command instead of
55
+ // producing an empty docs/ section on the deployed site.
56
+ if (!existsSync(docsSrc)) {
57
+ throw new Error(
58
+ `Docs submodule missing at ${docsSrc}. Run \`git submodule update --init docs\` first.`,
59
+ );
60
+ }
61
+
47
62
  // 1. Install upstream deps. Kiwi ships a yarn.lock; --frozen-lockfile gives a
48
- // reproducible tree. Contributors get yarn via `corepack enable`.
49
- if (!existsSync(path.join(upstream, 'node_modules'))) {
63
+ // reproducible tree. Contributors get yarn via `corepack enable`. The
64
+ // guard checks for the actual binary the build invokes, not just the
65
+ // `node_modules` directory — a stale/partial install (interrupted, or
66
+ // populated by a foreign package manager that skipped `.bin/` linking)
67
+ // leaves the directory present but `vue-cli-service` unresolvable, which
68
+ // would otherwise crash the build at step 2 with a confusing exit 127.
69
+ const vueCliBin = path.join(upstream, 'node_modules', '.bin', 'vue-cli-service');
70
+ if (!existsSync(vueCliBin)) {
50
71
  runYarn(['install', '--frozen-lockfile', '--ignore-engines']);
51
72
  }
52
73
 
53
- // 2. Build with publicPath=/app/. Upstream pins publicPath:'' (relative); we
54
- // temporarily patch vue.config.js so webpack emits /app/-prefixed asset
55
- // URLs and the runtime public_path matches the mount point. Restored in
56
- // `finally` so the submodule stays clean.
74
+ // 2. Build with publicPath=/webclient/. Upstream pins publicPath:'' (relative);
75
+ // we temporarily patch vue.config.js so webpack emits /webclient/-prefixed
76
+ // asset URLs and the runtime public_path matches the mount point. Restored
77
+ // in `finally` so the submodule stays clean.
57
78
  const vueConfigPath = path.join(upstream, 'vue.config.js');
58
79
  const originalVueConfig = await readFile(vueConfigPath, 'utf8');
59
80
  try {
@@ -67,14 +88,15 @@ try {
67
88
  await writeFile(vueConfigPath, originalVueConfig);
68
89
  }
69
90
 
70
- // 3. Copy the built SPA into dist/app/.
91
+ // 3. Copy the built SPA into dist/webclient/.
71
92
  await rm(appDist, { recursive: true, force: true });
72
93
  await mkdir(appDist, { recursive: true });
73
94
  await cp(path.join(upstream, 'dist'), appDist, { recursive: true });
74
95
 
75
96
  // 3b. Webpack applies publicPath to the bundles it emits, but template-hardcoded
76
97
  // refs (e.g. the favicon) stay relative. Prefix every bare `static/...`
77
- // reference so all asset URLs resolve to /app/static/... when served at /app/.
98
+ // reference so all asset URLs resolve to /webclient/static/... when served
99
+ // at /webclient/.
78
100
  const indexDest = path.join(appDist, 'index.html');
79
101
  let indexHtml = await readFile(indexDest, 'utf8');
80
102
  indexHtml = indexHtml.replace(/((?:src|href)=["'])(static\/)/g, `$1${PUBLIC_PATH}$2`);
@@ -109,8 +131,8 @@ await writeFile(
109
131
 
110
132
  // 5. Landing page: copy the static landing page (landing/index.html) to
111
133
  // dist/index.html so the Worker `[assets]` binding serves it at `/`. Pure
112
- // static HTML + inline CSS (no JS); links to /app/ (the SPA), docs/, and
113
- // the source repo.
134
+ // static HTML + inline CSS (no JS); links to /webclient/ (the SPA),
135
+ // /docs/ (the rendered docs built in step 6), and the source repo.
114
136
  await mkdir(dist, { recursive: true });
115
137
  const landingSrc = path.join(pkgRoot, 'landing', 'index.html');
116
138
  if (!existsSync(landingSrc)) {
@@ -120,6 +142,14 @@ if (!existsSync(landingSrc)) {
120
142
  }
121
143
  await cp(landingSrc, path.join(dist, 'index.html'));
122
144
 
145
+ // 6. Docs: render every docs/*.md to standalone HTML under dist/docs/ so the
146
+ // Worker `[assets]` binding serves them at /docs/<name>.html (and /docs/
147
+ // for the index — Home.md becomes index.html). Requires the `docs/`
148
+ // submodule (guarded at step 0). Zero Worker changes — the existing
149
+ // [assets] binding already serves dist/ with not_found_handling = "none".
150
+ const docsResult = await buildDocs({ srcDir: docsSrc, outDir: docsDist });
151
+
123
152
  console.log(
124
- `Kiwi SPA built -> dist/app/ (publicPath=${PUBLIC_PATH}, config=${configFileName}, env=${configEnv})`,
153
+ `Kiwi SPA built -> dist/webclient/ (publicPath=${PUBLIC_PATH}, config=${configFileName}, env=${configEnv})`,
125
154
  );
155
+ console.log(`Docs rendered -> dist/docs/ (${docsResult.emitted.length} pages, index=index.html)`);
@@ -4,7 +4,7 @@
4
4
  * Extracted from the build script so the `--env` → config-file mapping is
5
5
  * unit-testable without running the (slow) Kiwi upstream build. The build
6
6
  * script delegates here and then validates the selected file against
7
- * {@link parseKiwiConfig} before baking it into `dist/app/static/config.json`.
7
+ * {@link parseKiwiConfig} before baking it into `dist/webclient/static/config.json`.
8
8
  */
9
9
 
10
10
  /** argv values accepted by `--env`; mapped to a `static/config.<env>.json` file. */
@@ -4,16 +4,16 @@
4
4
  * supply to drive the embedded Kiwi SPA.
5
5
  *
6
6
  * `build.mjs` runs {@link parseKiwiConfig} against the selected env config
7
- * before baking it into `dist/app/static/config.json`, so a malformed config
8
- * fails the build with a readable aggregated error rather than shipping a
9
- * broken SPA. The schema only models the fields ServerlessIRCd cares about
10
- * (server, port, channel, WS path); the rest of Kiwi's surface is passed
11
- * through untouched.
7
+ * before baking it into `dist/webclient/static/config.json`, so a malformed
8
+ * config fails the build with a readable aggregated error rather than
9
+ * shipping a broken SPA. The schema only models the fields ServerlessIRCd
10
+ * cares about (server, port, channel, WS path); the rest of Kiwi's surface
11
+ * is passed through untouched.
12
12
  *
13
13
  * Hosting layout reminder:
14
14
  * - The Worker's WebSocket endpoint is at `/` (root), so Kiwi's
15
15
  * `startupOptions.path` — when supplied — MUST be `/`.
16
- * - The SPA itself is served from `/app/` by the Worker `[assets]`
16
+ * - The SPA itself is served from `/webclient/` by the Worker `[assets]`
17
17
  * binding (a separate concern handled by the public-path patch in
18
18
  * `build.mjs`); that is independent of the WS path modelled here.
19
19
  */