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.
- package/.github/workflows/ci.yml +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- 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/`;
|
|
8
|
-
the
|
|
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>
|
|
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 — 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>
|
package/apps/web/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/web",
|
|
3
|
-
"version": "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 {
|
|
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
|
|
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.
|
|
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),
|
|
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)`);
|