serverless-ircd 0.3.0 → 0.4.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 +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +174 -18
- package/README.md +63 -30
- package/apps/aws-stack/README.md +2 -2
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +118 -6
- package/apps/aws-stack/tests/stack.test.ts +98 -3
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.test.toml +6 -0
- package/apps/cf-worker/wrangler.toml +28 -2
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +10 -0
- package/apps/local-cli/src/server.ts +20 -3
- package/package.json +14 -10
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +1 -1
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +6 -4
- package/packages/aws-adapter/src/config-loader.ts +32 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +35 -2
- package/packages/aws-adapter/src/handlers/index.ts +69 -3
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +6 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
- package/packages/aws-adapter/tests/account-store.test.ts +19 -20
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +154 -53
- package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +2 -4
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +111 -46
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +33 -9
- package/packages/cf-adapter/src/index.ts +9 -8
- package/packages/cf-adapter/tests/cf-harness.ts +11 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/worker/main.ts +8 -1
- package/packages/cf-adapter/wrangler.test.toml +8 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +6 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/caps/capabilities.ts +23 -2
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +58 -0
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +6 -29
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +181 -12
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +38 -1
- package/packages/irc-core/tests/account-store.test.ts +45 -2
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +95 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/effects.test.ts +0 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +12 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- package/packages/irc-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +158 -14
- package/packages/irc-server/src/dispatch.ts +0 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +12 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +400 -3
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +4 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +132 -2
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +9 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
- package/docs/ADR-002-location-of-authority.md +0 -82
- package/docs/ADR-003-durable-object-sharding.md +0 -93
- package/docs/ADR-004-dynamodb-schema.md +0 -96
- package/docs/ADR-005-wss-only-transport-v1.md +0 -83
- package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
- package/docs/ADR-007-deterministic-ports.md +0 -82
- package/docs/ADR-008-monorepo-tooling.md +0 -60
- package/docs/AWS-Adapter-Architecture.md +0 -496
- package/docs/AWS-Deployment.md +0 -1186
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -11
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -443
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Parametrized IRC scenarios — 032.
|
|
2
|
+
* Parametrized IRC scenarios — 032 + 057.
|
|
3
3
|
*
|
|
4
4
|
* The scenario bodies live in `src/scenarios.ts` (now re-exported as
|
|
5
5
|
* `runIrcScenarios`) so adapter packages can register their own
|
|
6
6
|
* {@link IrcHarnessFactory} against the exact same suite under their
|
|
7
7
|
* native vitest configuration (CF runs in `workerd`, AWS runs in node
|
|
8
|
-
* with dynamodb-local, etc.). This file is the in-memory entry point
|
|
9
|
-
* it registers only {@link inMemoryHarnessFactory}.
|
|
8
|
+
* with dynamodb-local, etc.). This file is the in-memory entry point.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* The transport dimension extends the matrix: every scenario runs
|
|
11
|
+
* against (runtime × transport). The in-memory runtime
|
|
12
|
+
* registers both transports — `inMemoryWsHarnessFactory` (WebSocket
|
|
13
|
+
* text-frame framing) and `inMemoryTcpHarnessFactory` (raw TCP+TLS
|
|
14
|
+
* byte-stream framing) — proving the same scenario suite is green across
|
|
15
|
+
* both transports on the reference runtime. CF and AWS append their own
|
|
16
|
+
* factories (per transport) to their package suites.
|
|
17
|
+
*
|
|
18
|
+
* Adding a runtime OR a transport to the in-memory suite = appending one
|
|
19
|
+
* entry to the `FACTORIES` array (acceptance criterion: "adding a new
|
|
20
|
+
* transport requires only registering a factory").
|
|
14
21
|
*/
|
|
15
22
|
|
|
16
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
inMemoryTcpHarnessFactory,
|
|
25
|
+
inMemoryWsHarnessFactory,
|
|
26
|
+
runIrcScenarios,
|
|
27
|
+
} from '../src/index.js';
|
|
17
28
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The (runtime × transport) matrix. Adding a runtime or transport =
|
|
31
|
+
* appending one entry here.
|
|
32
|
+
*/
|
|
33
|
+
runIrcScenarios([inMemoryWsHarnessFactory, inMemoryTcpHarnessFactory]);
|
package/pnpm-workspace.yaml
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
linkedWorkspacePackages: true
|
|
2
1
|
packages:
|
|
3
2
|
- "packages/*"
|
|
4
3
|
- "apps/*"
|
|
5
4
|
- "tools/*"
|
|
5
|
+
linkWorkspacePackages: true
|
|
6
|
+
allowBuilds:
|
|
7
|
+
'@biomejs/biome': true
|
|
8
|
+
cpu-features: true
|
|
9
|
+
esbuild: true
|
|
10
|
+
protobufjs: true
|
|
11
|
+
sharp: true
|
|
12
|
+
ssh2: true
|
|
13
|
+
workerd: true
|
|
@@ -17,12 +17,9 @@
|
|
|
17
17
|
* Re-running with the same username overwrites the prior row.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { parseArgs } from 'node:util';
|
|
21
20
|
import { readFileSync } from 'node:fs';
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
putAccountCredential,
|
|
25
|
-
} from '@serverless-ircd/aws-adapter';
|
|
21
|
+
import { parseArgs } from 'node:util';
|
|
22
|
+
import { createDynamoDocumentClient, putAccountCredential } from '@serverless-ircd/aws-adapter';
|
|
26
23
|
|
|
27
24
|
const { values } = parseArgs({
|
|
28
25
|
options: {
|
|
@@ -35,7 +32,9 @@ const { values } = parseArgs({
|
|
|
35
32
|
});
|
|
36
33
|
|
|
37
34
|
if (values.table === undefined) {
|
|
38
|
-
console.error(
|
|
35
|
+
console.error(
|
|
36
|
+
'Usage: seed-aws-accounts --table <TableName> [--endpoint <url>] [--accounts user:pass ...] [--file <path>]',
|
|
37
|
+
);
|
|
39
38
|
process.exit(1);
|
|
40
39
|
}
|
|
41
40
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Seed SASL PLAIN accounts into a Cloudflare D1 `accounts` table.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node --import tsx tools/seed-cf-accounts.ts \
|
|
7
|
+
* --database serverless-ircd-accounts-staging \
|
|
8
|
+
* --env staging \
|
|
9
|
+
* --remote \
|
|
10
|
+
* --accounts alice:s3cret bob:password2
|
|
11
|
+
*
|
|
12
|
+
* Or read from a file (newline-delimited `username:password`):
|
|
13
|
+
* node --import tsx tools/seed-cf-accounts.ts \
|
|
14
|
+
* --database serverless-ircd-accounts-staging \
|
|
15
|
+
* --env staging \
|
|
16
|
+
* --remote \
|
|
17
|
+
* --file accounts.txt
|
|
18
|
+
*
|
|
19
|
+
* The script hashes each password with scrypt (never writes plaintext) and
|
|
20
|
+
* executes the resulting `INSERT OR REPLACE` via `wrangler d1 execute`.
|
|
21
|
+
* Re-running with the same username overwrites the prior row. The `accounts`
|
|
22
|
+
* table is created (IF NOT EXISTS) on the first run.
|
|
23
|
+
*
|
|
24
|
+
* Flags:
|
|
25
|
+
* --database D1 database name (as declared in wrangler.toml). Required.
|
|
26
|
+
* --env Wrangler environment (e.g. `staging`). Optional.
|
|
27
|
+
* --remote Target the remote D1 (default: local preview).
|
|
28
|
+
* --local Target local D1 (explicit; default).
|
|
29
|
+
* --accounts One or more `username:password` pairs.
|
|
30
|
+
* --file Path to a newline-delimited `username:password` file.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { execFileSync } from 'node:child_process';
|
|
34
|
+
import { readFileSync } from 'node:fs';
|
|
35
|
+
import { parseArgs } from 'node:util';
|
|
36
|
+
import { CREATE_ACCOUNTS_TABLE_SQL, buildAccountPutStatement } from '@serverless-ircd/cf-adapter';
|
|
37
|
+
|
|
38
|
+
const { values } = parseArgs({
|
|
39
|
+
options: {
|
|
40
|
+
database: { type: 'string' },
|
|
41
|
+
env: { type: 'string', default: undefined },
|
|
42
|
+
remote: { type: 'boolean', default: false },
|
|
43
|
+
local: { type: 'boolean', default: false },
|
|
44
|
+
accounts: { type: 'string', multiple: true, default: [] },
|
|
45
|
+
file: { type: 'string', default: undefined },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (values.database === undefined) {
|
|
50
|
+
console.error(
|
|
51
|
+
'Usage: seed-cf-accounts --database <d1-name> [--env staging] [--remote] [--accounts user:pass ...] [--file <path>]',
|
|
52
|
+
);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const pairs: Array<{ username: string; password: string }> = [];
|
|
57
|
+
|
|
58
|
+
for (const pair of values.accounts) {
|
|
59
|
+
const sep = pair.indexOf(':');
|
|
60
|
+
if (sep <= 0) {
|
|
61
|
+
console.error(`Skipping malformed entry (no colon): ${pair}`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
pairs.push({ username: pair.slice(0, sep), password: pair.slice(sep + 1) });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (values.file !== undefined) {
|
|
68
|
+
const content = readFileSync(values.file, 'utf-8');
|
|
69
|
+
for (const line of content.split('\n')) {
|
|
70
|
+
const trimmed = line.trim();
|
|
71
|
+
if (trimmed.length === 0) continue;
|
|
72
|
+
const sep = trimmed.indexOf(':');
|
|
73
|
+
if (sep <= 0) continue;
|
|
74
|
+
pairs.push({ username: trimmed.slice(0, sep), password: trimmed.slice(sep + 1) });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (pairs.length === 0) {
|
|
79
|
+
console.error('No accounts to seed. Use --accounts or --file.');
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Runs `wrangler d1 execute` with the supplied SQL command. */
|
|
84
|
+
function runD1Execute(sql: string): void {
|
|
85
|
+
const args = ['d1', 'execute', values.database as string, '--command', sql];
|
|
86
|
+
if (values.env !== undefined) {
|
|
87
|
+
args.push('--env', values.env);
|
|
88
|
+
}
|
|
89
|
+
if (values.remote) {
|
|
90
|
+
args.push('--remote');
|
|
91
|
+
}
|
|
92
|
+
execFileSync('npx', ['wrangler', ...args], { stdio: 'inherit' });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Ensure the table exists (idempotent).
|
|
96
|
+
runD1Execute(CREATE_ACCOUNTS_TABLE_SQL);
|
|
97
|
+
|
|
98
|
+
for (const { username, password } of pairs) {
|
|
99
|
+
const { entry, sql } = buildAccountPutStatement(username, password);
|
|
100
|
+
runD1Execute(sql);
|
|
101
|
+
console.log(`Seeded ${username} (algorithm=${entry.algorithm})`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log(`Done: ${pairs.length} account(s) written to D1 database ${values.database}.`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/tcp-ws-forwarder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Local TCP to ws/wss forwarder so standard IRC clients can reach the WebSocket-only ServerlessIRCd transport",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
# ADR-001: Pure reducers and the effect system
|
|
2
|
-
|
|
3
|
-
**Date:** 2025-01-15 (Phase 1)
|
|
4
|
-
**Status:** Accepted
|
|
5
|
-
**Supersedes:** —
|
|
6
|
-
**Superseded by:** —
|
|
7
|
-
|
|
8
|
-
## Context
|
|
9
|
-
|
|
10
|
-
Every IRC daemon must decide where side effects happen. A traditional ircd
|
|
11
|
-
threads mutable state and IO through command handlers — the handler reads a
|
|
12
|
-
channel roster, sends messages, mutates a user record, and returns. That makes
|
|
13
|
-
handlers hard to unit-test (they require a live socket, a real timer, a
|
|
14
|
-
populated data store) and hard to reason about (the handler's observable
|
|
15
|
-
behaviour is inseparable from its IO).
|
|
16
|
-
|
|
17
|
-
ServerlessIRCd targets two cloud adapters (Cloudflare Workers, AWS Lambda)
|
|
18
|
-
that share one protocol core. If the core performed IO directly, each adapter
|
|
19
|
-
would need its own mock surface for every core test — or worse, the core
|
|
20
|
-
would grow adapter-specific code paths.
|
|
21
|
-
|
|
22
|
-
## Decision
|
|
23
|
-
|
|
24
|
-
Every command handler is a **pure function** with the signature:
|
|
25
|
-
|
|
26
|
-
```ts
|
|
27
|
-
type Reducer<S> = (state: S, msg: IrcMessage, ctx: Ctx) => { state: S; effects: Effect[] };
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
- **No IO in the reducer.** No `await`, no `fetch`, no `setTimeout`, no
|
|
31
|
-
`Date.now()`, no `Math.random()`. The reducer reads `ctx.clock.now()` and
|
|
32
|
-
`ctx.ids.nonce()` instead (see ADR-007).
|
|
33
|
-
- **Side effects are values.** The reducer returns an `Effect[]` — a
|
|
34
|
-
discriminated union (`Send`, `Broadcast`, `Disconnect`, `ReserveNick`,
|
|
35
|
-
`ChangeNick`, `ReleaseNick`, `ApplyChannelDelta`, `SendToNick`, …). Each
|
|
36
|
-
effect tag maps one-to-one to an `IrcRuntime` method.
|
|
37
|
-
- **A single `dispatch(effects, runtime)` function** in `irc-server`
|
|
38
|
-
interprets the effects against the bound runtime. Only `dispatch` awaits.
|
|
39
|
-
- **Reducer purity is enforced by convention and testability, not by a
|
|
40
|
-
sandbox.** The type system carries the contract; the 100% coverage gate on
|
|
41
|
-
`irc-core` verifies it empirically.
|
|
42
|
-
|
|
43
|
-
## Consequences
|
|
44
|
-
|
|
45
|
-
**Positive:**
|
|
46
|
-
- Every reducer is a trivial unit test: arrange state, apply message, assert
|
|
47
|
-
`state` and `effects`. No mocks, no fakes beyond an injected `Clock` and
|
|
48
|
-
`IdFactory`.
|
|
49
|
-
- The same reducer code runs unchanged on in-memory, CF, and AWS runtimes.
|
|
50
|
-
Adapter work is "wire the runtime", not "rewrite the handler".
|
|
51
|
-
- Reducer behaviour is deterministic and reproducible — critical for the
|
|
52
|
-
parametrized contract suite (TICKET-032) that runs identical scenarios
|
|
53
|
-
across all three runtimes.
|
|
54
|
-
- Mutation testing (Stryker, TICKET-048) is meaningful because reducers have
|
|
55
|
-
no hidden IO to mask a mutation's effect.
|
|
56
|
-
|
|
57
|
-
**Negative:**
|
|
58
|
-
- The `Effect` union grows with every new side-effect type. Adding a novel
|
|
59
|
-
effect (e.g. `QueryHistory` for chathistory) requires touching `effects.ts`,
|
|
60
|
-
`dispatch.ts`, and every runtime implementation. This is by design (the
|
|
61
|
-
type system makes missing-runtime-support a compile error) but adds
|
|
62
|
-
ceremony.
|
|
63
|
-
- Reducers cannot directly observe the result of a side effect they emit
|
|
64
|
-
(e.g. "did the broadcast actually reach anyone?"). Cross-effect
|
|
65
|
-
coordination is deferred to the actor layer, which can pre-fetch runtime
|
|
66
|
-
state before invoking the reducer.
|
|
67
|
-
|
|
68
|
-
## References
|
|
69
|
-
|
|
70
|
-
- PLAN §2.1 — "The key idea: pure reducers + location-of-authority"
|
|
71
|
-
- PLAN §2.2 — `IrcRuntime` port
|
|
72
|
-
- `packages/irc-core/src/effects.ts` — `Effect` union
|
|
73
|
-
- `packages/irc-core/src/types.ts` — `Reducer<S>` signature
|
|
74
|
-
- `packages/irc-server/src/dispatch.ts` — `dispatch(effects, runtime)`
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
# ADR-002: Location-of-authority state split
|
|
2
|
-
|
|
3
|
-
**Date:** 2025-01-15 (Phase 1)
|
|
4
|
-
**Status:** Accepted
|
|
5
|
-
**Supersedes:** —
|
|
6
|
-
**Superseded by:** —
|
|
7
|
-
|
|
8
|
-
## Context
|
|
9
|
-
|
|
10
|
-
An IRC server manages several kinds of state: connection records (nick, caps,
|
|
11
|
-
away status), channel rosters (who is in `#foo`, with what prefix), channel
|
|
12
|
-
metadata (topic, modes, ban lists), and the nick registry (nick → connection
|
|
13
|
-
map). In a single-process ircd all of this lives in one address space, so
|
|
14
|
-
read-modify-write races are handled with locks or a single event loop.
|
|
15
|
-
|
|
16
|
-
ServerlessIRCd runs on stateless-per-request compute (Lambda) or
|
|
17
|
-
per-entity-isolated compute (Durable Objects). There is no global lock. If
|
|
18
|
-
two connections race to JOIN the same channel, or two clients race to claim
|
|
19
|
-
the same nick, the outcome must still be correct. Traditional locking is
|
|
20
|
-
impossible across instances.
|
|
21
|
-
|
|
22
|
-
## Decision
|
|
23
|
-
|
|
24
|
-
Each piece of state has **exactly one authority** — the single entity that
|
|
25
|
-
owns the canonical copy and serializes all mutations to it. Reducers are
|
|
26
|
-
routed to the authority that owns the state they mutate:
|
|
27
|
-
|
|
28
|
-
| Command family | Authority | Primary state |
|
|
29
|
-
|---|---|---|
|
|
30
|
-
| Registration (NICK/USER/PASS) | Connection entity | `ConnectionState` |
|
|
31
|
-
| PING/PONG, QUIT, AWAY, user MODE | Connection entity | `ConnectionState` |
|
|
32
|
-
| JOIN/PART/channel MODE/TOPIC/KICK/INVITE | Channel entity | `ChannelState` + roster |
|
|
33
|
-
| PRIVMSG/NOTICE to channel | Channel entity | Fanout (reads roster) |
|
|
34
|
-
| PRIVMSG/NOTICE to user | Sender Connection | Nick → Connection route |
|
|
35
|
-
| Nick reservation | Registry entity | Nick → Connection map |
|
|
36
|
-
|
|
37
|
-
Cross-authority coordination is done via **effects**, not direct mutation.
|
|
38
|
-
When a connection accepts a JOIN, the connection-authority reducer emits an
|
|
39
|
-
`ApplyChannelDelta` effect that the channel authority consumes. The channel
|
|
40
|
-
authority is the sole writer of its roster; the connection never touches it
|
|
41
|
-
directly.
|
|
42
|
-
|
|
43
|
-
Platform mapping:
|
|
44
|
-
- **Cloudflare:** each authority is a Durable Object (`ConnectionDO`,
|
|
45
|
-
`ChannelDO`, `RegistryDO`). DO single-threaded serial execution gives the
|
|
46
|
-
uniqueness invariant for free — two concurrent requests to the same DO are
|
|
47
|
-
queued, not racing.
|
|
48
|
-
- **AWS:** each authority is a DynamoDB table with conditional writes
|
|
49
|
-
(`ConditionExpression`) or `TransactWriteItems` for multi-row atomicity
|
|
50
|
-
(e.g. JOIN spans `Connections` and `ChannelMembers`).
|
|
51
|
-
|
|
52
|
-
## Consequences
|
|
53
|
-
|
|
54
|
-
**Positive:**
|
|
55
|
-
- Races are structurally impossible, not defended against. Two clients
|
|
56
|
-
claiming the same nick both route to the same `RegistryDO` shard (or the
|
|
57
|
-
same `Nicks` row with a conditional PutItem) — exactly one wins.
|
|
58
|
-
- The reducer signature stays uniform (`Reducer<S>`) regardless of which
|
|
59
|
-
authority it runs in. The actor layer routes; the reducer does not know or
|
|
60
|
-
care whether it is running in a DO or a Lambda.
|
|
61
|
-
- Each authority can be tested in isolation. The channel reducer tests
|
|
62
|
-
arrange a `ChannelState` and assert effects; they never need a live
|
|
63
|
-
connection registry.
|
|
64
|
-
|
|
65
|
-
**Negative:**
|
|
66
|
-
- Cross-authority coordination requires a round-trip (DO `stub()` call on CF;
|
|
67
|
-
extra DynamoDB write on AWS). A JOIN that updates both the connection's
|
|
68
|
-
joined-list and the channel roster is two writes, not one.
|
|
69
|
-
- The actor layer must pre-fetch authoritative state before invoking a
|
|
70
|
-
reducer (e.g. WHOIS needs the target connection's state from a different
|
|
71
|
-
authority). This adds a `prefetchChannelState` step in the actor pipeline.
|
|
72
|
-
- Listing all channels (LIST) or resolving every member of a channel (WHO)
|
|
73
|
-
requires aggregating across authorities — there is no global view. The CF
|
|
74
|
-
adapter needs a channel registry (TICKET-073); the AWS adapter does a
|
|
75
|
-
DynamoDB scan.
|
|
76
|
-
|
|
77
|
-
## References
|
|
78
|
-
|
|
79
|
-
- PLAN §2.1 — location-of-authority table
|
|
80
|
-
- PLAN §4 — state model & authoritative ownership
|
|
81
|
-
- ADR-001 — pure reducers (how effects flow between authorities)
|
|
82
|
-
- `packages/irc-server/src/actor.ts` — `route()` switch (authority routing)
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
# ADR-003: Cloudflare Durable Object sharding strategy
|
|
2
|
-
|
|
3
|
-
**Date:** 2025-02-01 (Phase 3)
|
|
4
|
-
**Status:** Accepted
|
|
5
|
-
**Supersedes:** —
|
|
6
|
-
**Superseded by:** —
|
|
7
|
-
|
|
8
|
-
## Context
|
|
9
|
-
|
|
10
|
-
Cloudflare Durable Objects (DOs) are single-threaded per-instance: all
|
|
11
|
-
requests to a given DO instance are serialized. This is ideal for enforcing
|
|
12
|
-
uniqueness invariants (two racing requests land on the same DO, one after the
|
|
13
|
-
other), but a single DO instance is a throughput ceiling. ServerlessIRCd needs
|
|
14
|
-
three DO classes:
|
|
15
|
-
|
|
16
|
-
1. **ConnectionDO** — one per WebSocket connection. Keyed by `connectionId`.
|
|
17
|
-
No sharding needed: each connection is its own instance.
|
|
18
|
-
2. **ChannelDO** — one per channel. Keyed by lowercased channel name.
|
|
19
|
-
No sharding needed: one DO per channel is the natural unit; a hot channel
|
|
20
|
-
(1k+ members) is bounded by DO fanout latency, not by DO count.
|
|
21
|
-
3. **RegistryDO** — holds the global nick → connectionId map. If this were a
|
|
22
|
-
single DO, every nick registration on the entire server would serialize
|
|
23
|
-
through one instance. That is an unacceptable bottleneck for any non-trivial
|
|
24
|
-
deployment.
|
|
25
|
-
|
|
26
|
-
The open question (PLAN §9): *"Channel DO sharding granularity (one DO per
|
|
27
|
-
channel vs. bucketed)?"* and *"Registry DO: how to shard?"*
|
|
28
|
-
|
|
29
|
-
## Decision
|
|
30
|
-
|
|
31
|
-
**ConnectionDO:** one instance per connection (`idFromName(connectionId)`).
|
|
32
|
-
No sharding.
|
|
33
|
-
|
|
34
|
-
**ChannelDO:** one instance per channel
|
|
35
|
-
(`idFromName(lowercasedChannelName)`). No bucketing. If a hot channel exceeds
|
|
36
|
-
DO throughput limits, the mitigation is fanout batching (one `stub()` call
|
|
37
|
-
carrying multiple lines) rather than splitting the channel across DOs. A
|
|
38
|
-
single channel's roster and modes live in one DO so that all mutations are
|
|
39
|
-
serialized correctly.
|
|
40
|
-
|
|
41
|
-
**RegistryDO:** sharded by `hash(lowercased(nick)) % N`, where `N` defaults
|
|
42
|
-
to **32** (`DEFAULT_REGISTRY_SHARDS`). The hash function is FNV-1a 32-bit,
|
|
43
|
-
chosen for its uniform distribution, deterministic output (no randomness),
|
|
44
|
-
and trivial implementation.
|
|
45
|
-
|
|
46
|
-
```
|
|
47
|
-
shard = fnv1a32(nick.toLowerCase()) % N
|
|
48
|
-
key = `s${shard}` // e.g. "s7", "s31"
|
|
49
|
-
stub = env.REGISTRY_DO.get(env.REGISTRY_DO.idFromName(key))
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
Two clients racing on the same nick always land on the same shard (because
|
|
53
|
-
lowercasing collapses case variants), so the single-shard uniqueness check
|
|
54
|
-
covers all case variants. Different nicks spread across shards, so
|
|
55
|
-
registration throughput scales with the fleet size.
|
|
56
|
-
|
|
57
|
-
Cross-shard nick changes (old nick and new nick hash to different shards)
|
|
58
|
-
use a two-phase reserve-new-then-release-old. The new-nick shard wins the
|
|
59
|
-
race; the old-nick shard is released only after the new reservation succeeds.
|
|
60
|
-
|
|
61
|
-
`N` is configurable via the `REGISTRY_SHARDS` environment variable. Changing
|
|
62
|
-
`N` in production requires a migration (re-sharding every existing nick entry
|
|
63
|
-
to its new shard).
|
|
64
|
-
|
|
65
|
-
## Consequences
|
|
66
|
-
|
|
67
|
-
**Positive:**
|
|
68
|
-
- Nick uniqueness is race-free within the DO model. No conditional writes,
|
|
69
|
-
no optimistic concurrency — the DO's serial execution is the lock.
|
|
70
|
-
- Registration throughput scales horizontally: 32 shards handle 32× the
|
|
71
|
-
single-DO throughput for distinct nicks.
|
|
72
|
-
- One DO per channel keeps channel semantics simple: JOIN/PART/MODE/KICK all
|
|
73
|
-
serialize through the same instance.
|
|
74
|
-
|
|
75
|
-
**Negative:**
|
|
76
|
-
- Changing `N` (the shard count) requires migrating every existing nick to
|
|
77
|
-
its new shard. This is an offline migration, not a live reshard. The shard
|
|
78
|
-
count should be chosen conservatively at deployment time.
|
|
79
|
-
- The channel registry problem: there is no DO that "knows" all channels
|
|
80
|
-
(LIST, TICKET-073). A dedicated registry DO or KV index is needed — this is
|
|
81
|
-
a known gap tracked in TICKET-073.
|
|
82
|
-
- Hot-nick collisions (many nicks hashing to the same shard) are possible but
|
|
83
|
-
statistically rare with FNV-1a and `N = 32` for typical populations.
|
|
84
|
-
|
|
85
|
-
## References
|
|
86
|
-
|
|
87
|
-
- PLAN §9 — open question: "Channel DO sharding granularity"
|
|
88
|
-
- PLAN §6.1 — `RegistryDO` sharded by `hash(nick) % N`
|
|
89
|
-
- `packages/cf-adapter/src/sharding.ts` — `shardNick`, `registryKeyForNick`,
|
|
90
|
-
`DEFAULT_REGISTRY_SHARDS = 32`
|
|
91
|
-
- `packages/cf-adapter/src/registry-do.ts` — per-shard registry authority
|
|
92
|
-
- TICKET-034 — RegistryDO implementation
|
|
93
|
-
- TICKET-073 — CF runtime stubbed lookups (channel registry gap)
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
# ADR-004: AWS DynamoDB schema
|
|
2
|
-
|
|
3
|
-
**Date:** 2025-02-15 (Phase 4)
|
|
4
|
-
**Status:** Accepted
|
|
5
|
-
**Supersedes:** —
|
|
6
|
-
**Superseded by:** —
|
|
7
|
-
|
|
8
|
-
## Context
|
|
9
|
-
|
|
10
|
-
The AWS adapter runs IRC state on DynamoDB (single table family, not
|
|
11
|
-
single-table design). Unlike Cloudflare Durable Objects, DynamoDB does not
|
|
12
|
-
serialize access to a record by default — concurrent writes can interleave.
|
|
13
|
-
Nick uniqueness, channel membership atomicity, and gone-connection cleanup
|
|
14
|
-
all require explicit concurrency control.
|
|
15
|
-
|
|
16
|
-
The schema must support: connection records, channel metadata, channel
|
|
17
|
-
membership (roster), nick registry, and SASL account credentials. It must
|
|
18
|
-
also handle the Lambda invocation model: each `$default` invocation is a
|
|
19
|
-
fresh process that reads state, runs a reducer, and writes back — there is no
|
|
20
|
-
in-memory state between invocations.
|
|
21
|
-
|
|
22
|
-
## Decision
|
|
23
|
-
|
|
24
|
-
Five tables, each with a clear partition-key boundary:
|
|
25
|
-
|
|
26
|
-
| Table | Partition Key | Sort Key | TTL | Purpose |
|
|
27
|
-
|---|---|---|---|---|
|
|
28
|
-
| `Connections` | `connectionId` | — | `idleSince` | Per-connection state: nick, user, host, caps, joined channels, away |
|
|
29
|
-
| `ChannelMeta` | `channelName` | — | — | Topic, modes, key, limit |
|
|
30
|
-
| `ChannelMembers` | `channelName` | `connectionId` | — | Roster: membership + prefix flags (op/voice) |
|
|
31
|
-
| `Nicks` | `nickLower` | — | — | Nick → connectionId registry |
|
|
32
|
-
| `Accounts` | `account` | — | — | SASL credentials (scrypt-hashed, never plaintext) |
|
|
33
|
-
|
|
34
|
-
**Concurrency invariants:**
|
|
35
|
-
- **Nick uniqueness:** enforced via conditional `PutItem`
|
|
36
|
-
(`ConditionExpression: attribute_not_exists(nickLower)`). Two racing
|
|
37
|
-
Puts for the same nick — exactly one succeeds; the other gets a
|
|
38
|
-
`ConditionalCheckFailedException` → `433 ERR_NICKNAMEINUSE`.
|
|
39
|
-
- **Membership atomicity:** `TransactWriteItems` spans `Connections`
|
|
40
|
-
(update `joinedChannels`) and `ChannelMembers` (add/remove member) in a
|
|
41
|
-
single atomic transaction. Concurrent JOIN/PART by the same connection
|
|
42
|
-
cannot leave inconsistent state.
|
|
43
|
-
- **Gone connections:** `ApiGatewayManagementApi.postToConnection` throws
|
|
44
|
-
`GoneException` for a stale connection. The handler catches this, triggers
|
|
45
|
-
lazy cleanup (removes the `Connections` row + `ChannelMembers` rows), and
|
|
46
|
-
broadcasts QUIT to peers (TICKET-074). A sweeper Lambda reaps rows whose
|
|
47
|
-
`idleSince` TTL has expired.
|
|
48
|
-
- **Environment isolation:** physical table names are environment-prefixed
|
|
49
|
-
(`<Env>Connections`, e.g. `StagingConnections`) so staging and production
|
|
50
|
-
can coexist in the same account + region (TICKET-066).
|
|
51
|
-
|
|
52
|
-
**SASL credentials** (`Accounts`): stored as scrypt `HashedAccountCredential`
|
|
53
|
-
rows (`{ account, algorithm, salt, hash }`, base64-encoded). The Lambda
|
|
54
|
-
pre-loads all rows at cold start into an in-memory `DynamoAccountStore`; the
|
|
55
|
-
reducer's `AccountStore.verify()` stays synchronous (reducers are pure — no
|
|
56
|
-
async). Seed tooling (`tools/seed-aws-accounts.ts`) writes hashed rows; never
|
|
57
|
-
plaintext.
|
|
58
|
-
|
|
59
|
-
## Consequences
|
|
60
|
-
|
|
61
|
-
**Positive:**
|
|
62
|
-
- Clear separation of concerns: each table is one authority (matches
|
|
63
|
-
ADR-002). A channel's metadata, membership, and each member's connection
|
|
64
|
-
record are independently addressable.
|
|
65
|
-
- Conditional writes + `TransactWriteItems` give race-free semantics without
|
|
66
|
-
locks — the natural fit for DynamoDB.
|
|
67
|
-
- `idleSince` TTL auto-reaps idle connections even if the sweeper misses a
|
|
68
|
-
tick; the sweeper is a belt-and-suspenders cleanup, not the only path.
|
|
69
|
-
- Pay-per-request billing means idle tables cost nothing; the schema does not
|
|
70
|
-
over-provision.
|
|
71
|
-
|
|
72
|
-
**Negative:**
|
|
73
|
-
- Five tables (vs. a single-table design) means more `TransactWriteItems`
|
|
74
|
-
spanning multiple partitions. `TransactWriteItems` consumes more RCUs than
|
|
75
|
-
individual writes and has a 100-item-per-transaction cap. Membership
|
|
76
|
-
transactions are 2 items (well within limits), but a future feature that
|
|
77
|
-
touches many channels in one command could approach the cap.
|
|
78
|
-
- No cross-table GSI for "all channels a connection is in" — the
|
|
79
|
-
`Connections` row stores `joinedChannels` as a set, which must be updated
|
|
80
|
-
on every JOIN/PART. This duplicates membership data between `Connections`
|
|
81
|
-
and `ChannelMembers`, kept consistent by the transaction.
|
|
82
|
-
- `Accounts` pre-loading at cold start means every Lambda cold start scans
|
|
83
|
-
the table. For deployments with thousands of accounts, this adds latency.
|
|
84
|
-
Mitigated by Lambda reuse (warm starts skip the scan); a cache layer or
|
|
85
|
-
on-demand lookup is a documented follow-up.
|
|
86
|
-
|
|
87
|
-
## References
|
|
88
|
-
|
|
89
|
-
- PLAN §4 — state model & authoritative ownership
|
|
90
|
-
- PLAN §6.2 — DynamoDB tables
|
|
91
|
-
- `packages/aws-adapter/src/cdk-table-defs.ts` — `TABLE_DEFS` (source of truth)
|
|
92
|
-
- `packages/aws-adapter/src/aws-runtime.ts` — `AwsRuntime` implementation
|
|
93
|
-
- TICKET-039 — CDK stack
|
|
94
|
-
- TICKET-041 — DynamoDB transactions for membership
|
|
95
|
-
- TICKET-066 — environment-name prefixing
|
|
96
|
-
- TICKET-072 — `Accounts` table scrypt-hashed `AccountStore`
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
# ADR-005: wss-only transport in v1 (no raw TCP)
|
|
2
|
-
|
|
3
|
-
**Date:** 2025-01-15 (Phase 1)
|
|
4
|
-
**Status:** Superseded by ADR-009 (Phase 7 — TICKET-052)
|
|
5
|
-
**Supersedes:** —
|
|
6
|
-
|
|
7
|
-
## Context
|
|
8
|
-
|
|
9
|
-
IRC is traditionally spoken over raw TCP (port 6667 plaintext, 6697 TLS).
|
|
10
|
-
WebSocket Secure (wss) is a framing layer over TLS that adds an HTTP upgrade
|
|
11
|
-
handshake. Serverless compute platforms have fundamentally different support
|
|
12
|
-
for these two transports:
|
|
13
|
-
|
|
14
|
-
- **Cloudflare Workers** can accept WebSocket upgrades but **cannot accept raw
|
|
15
|
-
TCP**. Spectrum (Enterprise tier) can terminate TLS and proxy to an origin,
|
|
16
|
-
but Workers itself is request-response.
|
|
17
|
-
- **AWS API Gateway WebSocket API** provides persistent WebSocket connections
|
|
18
|
-
with per-message Lambda invocation. Raw TCP requires a Network Load Balancer
|
|
19
|
-
+ Lambda streaming (invoke mode) — a fundamentally different integration.
|
|
20
|
-
|
|
21
|
-
The v1 goal is a clean serverless story: one codebase, two adapters, zero
|
|
22
|
-
stateful origin servers. Raw TCP on either platform requires additional,
|
|
23
|
-
non-serverless infrastructure (a long-running container on CF; NLB + Lambda
|
|
24
|
-
streaming on AWS).
|
|
25
|
-
|
|
26
|
-
Additionally, wss carries one IRC message per WebSocket text frame, which
|
|
27
|
-
simplifies framing: the server does not need to implement a stateful
|
|
28
|
-
byte-stream line buffer. The parser still tolerates `\r\n`-joined frames
|
|
29
|
-
(multiple messages in one text frame) for robustness, but the common case is
|
|
30
|
-
one-message-per-frame.
|
|
31
|
-
|
|
32
|
-
## Decision
|
|
33
|
-
|
|
34
|
-
v1 ships **wss-only**. No plaintext TCP listener, no `irc://` scheme, no
|
|
35
|
-
port 6667/6697. Clients connect via `wss://server.tld/` and speak IRC inside
|
|
36
|
-
WebSocket text frames.
|
|
37
|
-
|
|
38
|
-
Rationale:
|
|
39
|
-
1. Both target platforms support wss natively without additional
|
|
40
|
-
infrastructure.
|
|
41
|
-
2. wss is TLS-terminated at the edge (CF) or at API Gateway (AWS) — the
|
|
42
|
-
Lambda/Worker never sees plaintext credentials.
|
|
43
|
-
3. One-message-per-frame eliminates the need for a TCP-style line-framing
|
|
44
|
-
buffer in the core (though the parser tolerates joined frames).
|
|
45
|
-
4. The serverless cost/ops model is clean: no long-running origin, no TCP
|
|
46
|
-
keepalive management.
|
|
47
|
-
|
|
48
|
-
The `ConnectionActor` transport seam accepts WebSocket text frames directly.
|
|
49
|
-
A `Transport` interface is not abstracted in v1 — the actor's
|
|
50
|
-
`receiveTextFrame(text)` is the only entry point.
|
|
51
|
-
|
|
52
|
-
## Consequences
|
|
53
|
-
|
|
54
|
-
**Positive:**
|
|
55
|
-
- Zero infrastructure beyond the serverless platform. No TCP listener, no
|
|
56
|
-
origin server, no port management.
|
|
57
|
-
- TLS everywhere — no accidental plaintext exposure.
|
|
58
|
-
- Simple framing: WebSocket text frames are message-delimited by definition.
|
|
59
|
-
- Lower operational burden for v1.
|
|
60
|
-
|
|
61
|
-
**Negative:**
|
|
62
|
-
- RFC 1459/2812 clients that only speak raw TCP cannot connect. WeeChat and
|
|
63
|
-
HexChat support WebSocket; some older clients and bots do not.
|
|
64
|
-
- The WebSocket overhead (HTTP upgrade, per-frame masking) adds ~2–10 bytes
|
|
65
|
-
per message vs. raw TCP. Negligible for IRC's small message sizes.
|
|
66
|
-
- Some IRCv3 extensions assume a TCP transport (e.g. SASL EXTERNAL via client
|
|
67
|
-
certs over TLS passthrough). Without raw TCP, mTLS must be done at the edge
|
|
68
|
-
(CF API Shield, API Gateway custom domain), which changes the cert
|
|
69
|
-
verification surface.
|
|
70
|
-
|
|
71
|
-
**Superseded:** Phase 7 (TICKET-052) reverses this decision for v1.x: a
|
|
72
|
-
dual-transport model (wss stays the default; `irc+tls` on :6697 is added via
|
|
73
|
-
Cloudflare Spectrum + Container origin, and AWS NLB + Lambda streaming). This
|
|
74
|
-
ADR documents the original v1 rationale; the superseding ADR covers the
|
|
75
|
-
reversal and per-platform TCP+TLS mapping.
|
|
76
|
-
|
|
77
|
-
## References
|
|
78
|
-
|
|
79
|
-
- PLAN §3 — "No plaintext TCP in v1 (wss only)"
|
|
80
|
-
- PLAN §9 — "Framing: wss-only"
|
|
81
|
-
- TICKET-052 — superseding ADR: dual wss + irc+tls
|
|
82
|
-
- `packages/irc-server/src/actor.ts` — `receiveTextFrame(text)` (single
|
|
83
|
-
transport entry point in v1)
|