serverless-ircd 0.4.0 → 0.6.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 +34 -2
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/CHANGELOG.md +435 -0
- package/README.md +206 -27
- package/apps/aws-stack/README.md +37 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +106 -13
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +49 -3
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.toml +5 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +138 -28
- package/apps/local-cli/tests/e2e.test.ts +113 -29
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
- package/biome.json +1 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +110 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +15 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +66 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +40 -2
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +100 -4
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +238 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +6 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +185 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +38 -4
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +42 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +59 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +12 -9
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +72 -10
- package/packages/irc-core/src/effects.ts +41 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +568 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +57 -11
- package/packages/irc-core/src/protocol/outbound.ts +36 -4
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +120 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +129 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -0
- package/packages/irc-core/tests/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +699 -19
- package/packages/irc-server/src/dispatch.ts +109 -16
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +2523 -42
- package/packages/irc-server/tests/dispatch.test.ts +300 -2
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/index.ts +6 -0
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- package/tools/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -92,7 +92,13 @@ class TcpTestClient extends EventEmitter {
|
|
|
92
92
|
|
|
93
93
|
describe('local-cli TCP transport — startLocalServer tcpPort option', () => {
|
|
94
94
|
it('exposes tcpPort and testTcpSockets when tcpPort is provided', async () => {
|
|
95
|
-
const srv = await startLocalServer({
|
|
95
|
+
const srv = await startLocalServer({
|
|
96
|
+
port: 0,
|
|
97
|
+
tcpPort: 0,
|
|
98
|
+
hostname: '127.0.0.1',
|
|
99
|
+
serverName: 'irc.local.example.com',
|
|
100
|
+
networkName: 'LocalNet',
|
|
101
|
+
});
|
|
96
102
|
expect(srv.tcpPort).toBeGreaterThan(0);
|
|
97
103
|
expect(srv.testTcpSockets).toBeDefined();
|
|
98
104
|
expect(srv.testTcpSockets?.size).toBe(0);
|
|
@@ -100,16 +106,33 @@ describe('local-cli TCP transport — startLocalServer tcpPort option', () => {
|
|
|
100
106
|
});
|
|
101
107
|
|
|
102
108
|
it('omits tcpPort / testTcpSockets when tcpPort is not provided', async () => {
|
|
103
|
-
const srv = await startLocalServer({
|
|
109
|
+
const srv = await startLocalServer({
|
|
110
|
+
port: 0,
|
|
111
|
+
hostname: '127.0.0.1',
|
|
112
|
+
serverName: 'irc.local.example.com',
|
|
113
|
+
networkName: 'LocalNet',
|
|
114
|
+
});
|
|
104
115
|
expect(srv.tcpPort).toBeUndefined();
|
|
105
116
|
expect(srv.testTcpSockets).toBeUndefined();
|
|
106
117
|
await srv.close();
|
|
107
118
|
});
|
|
108
119
|
|
|
109
120
|
it('rejects when the TCP port is already in use', async () => {
|
|
110
|
-
const first = await startLocalServer({
|
|
121
|
+
const first = await startLocalServer({
|
|
122
|
+
port: 0,
|
|
123
|
+
tcpPort: 0,
|
|
124
|
+
hostname: '127.0.0.1',
|
|
125
|
+
serverName: 'irc.local.example.com',
|
|
126
|
+
networkName: 'LocalNet',
|
|
127
|
+
});
|
|
111
128
|
await expect(
|
|
112
|
-
startLocalServer({
|
|
129
|
+
startLocalServer({
|
|
130
|
+
port: 0,
|
|
131
|
+
tcpPort: first.tcpPort,
|
|
132
|
+
hostname: '127.0.0.1',
|
|
133
|
+
serverName: 'irc.local.example.com',
|
|
134
|
+
networkName: 'LocalNet',
|
|
135
|
+
}),
|
|
113
136
|
).rejects.toThrow();
|
|
114
137
|
await first.close();
|
|
115
138
|
});
|
|
@@ -119,7 +142,13 @@ describe('local-cli TCP transport — IRC flows', () => {
|
|
|
119
142
|
let server: LocalServer;
|
|
120
143
|
|
|
121
144
|
beforeAll(async () => {
|
|
122
|
-
server = await startLocalServer({
|
|
145
|
+
server = await startLocalServer({
|
|
146
|
+
port: 0,
|
|
147
|
+
tcpPort: 0,
|
|
148
|
+
hostname: '127.0.0.1',
|
|
149
|
+
serverName: 'irc.local.example.com',
|
|
150
|
+
networkName: 'LocalNet',
|
|
151
|
+
});
|
|
123
152
|
});
|
|
124
153
|
|
|
125
154
|
afterAll(async () => {
|
|
@@ -141,11 +170,11 @@ describe('local-cli TCP transport — IRC flows', () => {
|
|
|
141
170
|
|
|
142
171
|
await alice.send('NICK alice');
|
|
143
172
|
await alice.send('USER alice 0 * :Alice');
|
|
144
|
-
await alice.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
173
|
+
await alice.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
145
174
|
|
|
146
175
|
await bob.send('NICK bob');
|
|
147
176
|
await bob.send('USER bob 0 * :Bob');
|
|
148
|
-
await bob.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
177
|
+
await bob.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
149
178
|
|
|
150
179
|
await alice.send('JOIN #tcp-e2e');
|
|
151
180
|
await alice.waitFor((l) => l.includes('JOIN #tcp-e2e'));
|
|
@@ -164,7 +193,7 @@ describe('local-cli TCP transport — IRC flows', () => {
|
|
|
164
193
|
await client.opened();
|
|
165
194
|
await client.send('NICK pingUser');
|
|
166
195
|
await client.send('USER pingUser 0 * :Ping');
|
|
167
|
-
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
196
|
+
await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
168
197
|
|
|
169
198
|
await client.send('PING :tcp-tok');
|
|
170
199
|
await client.waitFor((l) => l === 'PONG :tcp-tok');
|
|
@@ -176,7 +205,7 @@ describe('local-cli TCP transport — IRC flows', () => {
|
|
|
176
205
|
await client.opened();
|
|
177
206
|
await client.send('NICK splitter');
|
|
178
207
|
await client.send('USER splitter 0 * :Splitter');
|
|
179
|
-
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
208
|
+
await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
180
209
|
|
|
181
210
|
// Split a PING line mid-token across two writes. The server's LineScanner
|
|
182
211
|
// must buffer the partial line and emit one PONG when the terminator lands.
|
|
@@ -191,7 +220,7 @@ describe('local-cli TCP transport — IRC flows', () => {
|
|
|
191
220
|
await client.opened();
|
|
192
221
|
// NICK and USER bundled into one write, CRLF-separated.
|
|
193
222
|
await client.writeRaw('NICK bundler\r\nUSER bundler 0 * :Bundler\r\n');
|
|
194
|
-
await client.waitFor((l) => l.startsWith(':irc.example.com 001 bundler'));
|
|
223
|
+
await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 bundler'));
|
|
195
224
|
await client.close();
|
|
196
225
|
});
|
|
197
226
|
|
|
@@ -200,7 +229,7 @@ describe('local-cli TCP transport — IRC flows', () => {
|
|
|
200
229
|
await client.opened();
|
|
201
230
|
await client.send('NICK termUser');
|
|
202
231
|
await client.send('USER termUser 0 * :Term');
|
|
203
|
-
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
232
|
+
await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
204
233
|
|
|
205
234
|
const connId = await server.runtime.lookupNick('termUser');
|
|
206
235
|
expect(connId).not.toBeNull();
|
|
@@ -217,7 +246,7 @@ describe('local-cli TCP transport — IRC flows', () => {
|
|
|
217
246
|
await client.opened();
|
|
218
247
|
await client.send('NICK reasonUser');
|
|
219
248
|
await client.send('USER reasonUser 0 * :Reason');
|
|
220
|
-
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
249
|
+
await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
221
250
|
|
|
222
251
|
// Synthesize a flood-control / oper-kill style disconnect with a reason.
|
|
223
252
|
const connId = await server.runtime.lookupNick('reasonUser');
|
|
@@ -247,7 +276,7 @@ describe('local-cli TCP transport — IRC flows', () => {
|
|
|
247
276
|
await next.opened();
|
|
248
277
|
await next.send('NICK tcpRecover');
|
|
249
278
|
await next.send('USER tcpRecover 0 * :TcpRecover');
|
|
250
|
-
await next.waitFor((l) => l.startsWith(':irc.example.com 001 '), 2000);
|
|
279
|
+
await next.waitFor((l) => l.startsWith(':irc.local.example.com 001 '), 2000);
|
|
251
280
|
await next.close();
|
|
252
281
|
await client.close();
|
|
253
282
|
});
|
|
@@ -255,7 +284,13 @@ describe('local-cli TCP transport — IRC flows', () => {
|
|
|
255
284
|
|
|
256
285
|
describe('local-cli TCP × WS cross-transport interop', () => {
|
|
257
286
|
it('delivers a TCP PRIVMSG to a WS client in the same channel', async () => {
|
|
258
|
-
const srv = await startLocalServer({
|
|
287
|
+
const srv = await startLocalServer({
|
|
288
|
+
port: 0,
|
|
289
|
+
tcpPort: 0,
|
|
290
|
+
hostname: '127.0.0.1',
|
|
291
|
+
serverName: 'irc.local.example.com',
|
|
292
|
+
networkName: 'LocalNet',
|
|
293
|
+
});
|
|
259
294
|
|
|
260
295
|
// WS side.
|
|
261
296
|
const wsClient = new WebSocket(`ws://127.0.0.1:${srv.port}/`);
|
|
@@ -288,7 +323,7 @@ describe('local-cli TCP × WS cross-transport interop', () => {
|
|
|
288
323
|
|
|
289
324
|
wsSend('NICK wsUser');
|
|
290
325
|
wsSend('USER wsUser 0 * :WS');
|
|
291
|
-
await wsWaitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
326
|
+
await wsWaitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
292
327
|
wsSend('JOIN #interop');
|
|
293
328
|
await wsWaitFor((l) => l.includes('JOIN #interop'));
|
|
294
329
|
|
|
@@ -297,7 +332,7 @@ describe('local-cli TCP × WS cross-transport interop', () => {
|
|
|
297
332
|
await tcp.opened();
|
|
298
333
|
await tcp.send('NICK tcpUser');
|
|
299
334
|
await tcp.send('USER tcpUser 0 * :TCP');
|
|
300
|
-
await tcp.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
335
|
+
await tcp.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
301
336
|
await tcp.send('JOIN #interop');
|
|
302
337
|
await tcp.waitFor((l) => l.includes('JOIN #interop'));
|
|
303
338
|
|
|
@@ -315,7 +350,13 @@ describe('local-cli TCP × WS cross-transport interop', () => {
|
|
|
315
350
|
|
|
316
351
|
describe('local-cli TCP transport — shutdown', () => {
|
|
317
352
|
it('close() tears down the TCP server and live TCP sockets', async () => {
|
|
318
|
-
const srv = await startLocalServer({
|
|
353
|
+
const srv = await startLocalServer({
|
|
354
|
+
port: 0,
|
|
355
|
+
tcpPort: 0,
|
|
356
|
+
hostname: '127.0.0.1',
|
|
357
|
+
serverName: 'irc.local.example.com',
|
|
358
|
+
networkName: 'LocalNet',
|
|
359
|
+
});
|
|
319
360
|
const client = new TcpTestClient('127.0.0.1', srv.tcpPort as number);
|
|
320
361
|
await client.opened();
|
|
321
362
|
// Closing the server destroys the server-side socket; the client side
|
|
@@ -327,12 +368,23 @@ describe('local-cli TCP transport — shutdown', () => {
|
|
|
327
368
|
});
|
|
328
369
|
|
|
329
370
|
it('close() is a no-op for TCP when tcpPort was never enabled', async () => {
|
|
330
|
-
const srv = await startLocalServer({
|
|
371
|
+
const srv = await startLocalServer({
|
|
372
|
+
port: 0,
|
|
373
|
+
hostname: '127.0.0.1',
|
|
374
|
+
serverName: 'irc.local.example.com',
|
|
375
|
+
networkName: 'LocalNet',
|
|
376
|
+
});
|
|
331
377
|
await expect(srv.close()).resolves.toBeUndefined();
|
|
332
378
|
});
|
|
333
379
|
|
|
334
380
|
it('logs and recovers from a per-TCP-socket error without crashing the server', async () => {
|
|
335
|
-
const srv = await startLocalServer({
|
|
381
|
+
const srv = await startLocalServer({
|
|
382
|
+
port: 0,
|
|
383
|
+
tcpPort: 0,
|
|
384
|
+
hostname: '127.0.0.1',
|
|
385
|
+
serverName: 'irc.local.example.com',
|
|
386
|
+
networkName: 'LocalNet',
|
|
387
|
+
});
|
|
336
388
|
const client = new TcpTestClient('127.0.0.1', srv.tcpPort as number);
|
|
337
389
|
await client.opened();
|
|
338
390
|
// Wait for the server-side 'connection' handler to register the socket
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
3
|
+
import { WebSocket } from 'ws';
|
|
4
|
+
import { type LocalServer, startLocalServer } from '../src/server';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* IRCv3 WebSocket subprotocol negotiation + per-message framing e2e.
|
|
8
|
+
*
|
|
9
|
+
* A spec-mode (`text.ircv3.net` / `binary.ircv3.net`) connection MUST:
|
|
10
|
+
* - have its offered subprotocol echoed back in the handshake;
|
|
11
|
+
* - exchange exactly one IRC message per WebSocket message, with NO
|
|
12
|
+
* trailing CR-LF on either direction.
|
|
13
|
+
*
|
|
14
|
+
* A legacy (un-negotiated) connection keeps the original `\r\n`-batched
|
|
15
|
+
* framing, so the existing integration suite stays green.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A spec-mode client: one IRC line per WS message, no trailing CRLF. */
|
|
19
|
+
class SpecClient extends EventEmitter {
|
|
20
|
+
readonly ws: WebSocket;
|
|
21
|
+
readonly received: string[] = [];
|
|
22
|
+
|
|
23
|
+
constructor(url: string, protocols: string | string[]) {
|
|
24
|
+
super();
|
|
25
|
+
this.ws = new WebSocket(url, protocols);
|
|
26
|
+
// Each inbound WS message is exactly one IRC line in spec mode (no CRLF).
|
|
27
|
+
this.ws.on('message', (data) => {
|
|
28
|
+
const line = data.toString('utf8');
|
|
29
|
+
if (line.length > 0) {
|
|
30
|
+
this.received.push(line);
|
|
31
|
+
this.emit('line', line);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolves once the WS handshake completes. */
|
|
37
|
+
opened(): Promise<void> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
if (this.ws.readyState === WebSocket.OPEN) resolve();
|
|
40
|
+
else {
|
|
41
|
+
this.ws.once('open', () => resolve());
|
|
42
|
+
this.ws.once('error', reject);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Sends one bare IRC line as its own WS message (no trailing CRLF). */
|
|
48
|
+
send(line: string): Promise<void> {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
this.ws.send(line, (err) => (err ? reject(err) : resolve()));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Waits until a received line satisfies `predicate` (substring match). */
|
|
55
|
+
waitFor(predicate: (line: string) => boolean, timeoutMs = 1000): Promise<string> {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const timeout = setTimeout(
|
|
58
|
+
() => reject(new Error(`timeout; received: ${JSON.stringify(this.received)}`)),
|
|
59
|
+
timeoutMs,
|
|
60
|
+
);
|
|
61
|
+
const check = (line: string) => {
|
|
62
|
+
if (predicate(line)) {
|
|
63
|
+
clearTimeout(timeout);
|
|
64
|
+
this.off('line', check);
|
|
65
|
+
resolve(line);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
for (const line of this.received) {
|
|
69
|
+
if (predicate(line)) {
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
resolve(line);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
this.on('line', check);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
close(): Promise<void> {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
if (this.ws.readyState === WebSocket.CLOSED) {
|
|
82
|
+
resolve();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.ws.once('close', () => resolve());
|
|
86
|
+
this.ws.close();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('local-cli IRCv3 WebSocket subprotocol', () => {
|
|
92
|
+
let server: LocalServer;
|
|
93
|
+
|
|
94
|
+
beforeAll(async () => {
|
|
95
|
+
server = await startLocalServer({
|
|
96
|
+
port: 0,
|
|
97
|
+
hostname: '127.0.0.1',
|
|
98
|
+
serverName: 'irc.local.example.com',
|
|
99
|
+
networkName: 'LocalNet',
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterAll(async () => {
|
|
104
|
+
await server.close();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('echoes text.ircv3.net when offered', async () => {
|
|
108
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
|
|
109
|
+
await client.opened();
|
|
110
|
+
expect(client.ws.protocol).toBe('text.ircv3.net');
|
|
111
|
+
await client.close();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('echoes binary.ircv3.net when offered', async () => {
|
|
115
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'binary.ircv3.net');
|
|
116
|
+
await client.opened();
|
|
117
|
+
expect(client.ws.protocol).toBe('binary.ircv3.net');
|
|
118
|
+
await client.close();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('completes a full session over binary.ircv3.net (binary frames)', async () => {
|
|
122
|
+
// binary.ircv3.net exchanges binary WebSocket frames. The server emits
|
|
123
|
+
// binary outbound frames (Buffer) and decodes inbound as UTF-8; the
|
|
124
|
+
// round-trip must decode to valid IRC lines with no trailing CRLF.
|
|
125
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'binary.ircv3.net');
|
|
126
|
+
await client.opened();
|
|
127
|
+
await client.send('NICK spec-bin');
|
|
128
|
+
await client.send('USER spec-bin 0 * :Spec Bin');
|
|
129
|
+
const welcome = await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
130
|
+
expect(welcome).toContain('spec-bin');
|
|
131
|
+
|
|
132
|
+
await client.send('PING :bin-tok');
|
|
133
|
+
const pong = await client.waitFor((l) => l === 'PONG :bin-tok');
|
|
134
|
+
expect(pong).toBe('PONG :bin-tok');
|
|
135
|
+
|
|
136
|
+
// Every decoded outbound message is a single bare IRC line.
|
|
137
|
+
for (const line of client.received) {
|
|
138
|
+
expect(line.includes('\r')).toBe(false);
|
|
139
|
+
expect(line.includes('\n')).toBe(false);
|
|
140
|
+
}
|
|
141
|
+
await client.close();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('selects the first-listed supported subprotocol (client preference)', async () => {
|
|
145
|
+
// Client prefers binary over text; server honours the order.
|
|
146
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, [
|
|
147
|
+
'binary.ircv3.net',
|
|
148
|
+
'text.ircv3.net',
|
|
149
|
+
]);
|
|
150
|
+
await client.opened();
|
|
151
|
+
expect(client.ws.protocol).toBe('binary.ircv3.net');
|
|
152
|
+
await client.close();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('ignores unsupported offers and still selects a supported one', async () => {
|
|
156
|
+
// Client lists an unsupported protocol first, then a supported one. The
|
|
157
|
+
// server MUST select the supported entry, not blindly echo the first.
|
|
158
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, ['chat', 'text.ircv3.net']);
|
|
159
|
+
await client.opened();
|
|
160
|
+
expect(client.ws.protocol).toBe('text.ircv3.net');
|
|
161
|
+
await client.close();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('connects in legacy mode when no subprotocol is offered', async () => {
|
|
165
|
+
// A plain client (no Sec-WebSocket-Protocol) is the legacy path: the
|
|
166
|
+
// server does not negotiate and the existing integration suite keeps
|
|
167
|
+
// working unchanged.
|
|
168
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, []);
|
|
169
|
+
await client.opened();
|
|
170
|
+
expect(client.ws.protocol).toBe('');
|
|
171
|
+
await client.close();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('completes registration with one IRC message per WebSocket message (text mode)', async () => {
|
|
175
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
|
|
176
|
+
await client.opened();
|
|
177
|
+
// One bare line per WS message (no trailing CRLF).
|
|
178
|
+
await client.send('NICK spec-alice');
|
|
179
|
+
await client.send('USER spec-alice 0 * :Spec Alice');
|
|
180
|
+
const welcome = await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
181
|
+
expect(welcome).toContain('spec-alice');
|
|
182
|
+
|
|
183
|
+
// Every received message must be a single IRC line with no trailing CRLF
|
|
184
|
+
// and no embedded line break (one IRC message per WS message).
|
|
185
|
+
for (const line of client.received) {
|
|
186
|
+
expect(line.includes('\r')).toBe(false);
|
|
187
|
+
expect(line.includes('\n')).toBe(false);
|
|
188
|
+
}
|
|
189
|
+
await client.close();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('delivers each outbound line as a distinct WebSocket message in spec mode', async () => {
|
|
193
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
|
|
194
|
+
await client.opened();
|
|
195
|
+
await client.send('NICK spec-bob');
|
|
196
|
+
await client.send('USER spec-bob 0 * :Spec Bob');
|
|
197
|
+
await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
198
|
+
|
|
199
|
+
// The welcome sequence is many numerics (001..005, 375/372/376). In spec
|
|
200
|
+
// mode each arrives as its own WS message; a legacy batch would have
|
|
201
|
+
// concatenated them with CRLF inside one message. Assert we observed
|
|
202
|
+
// several distinct messages.
|
|
203
|
+
expect(client.received.length).toBeGreaterThan(1);
|
|
204
|
+
await client.close();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('routes PING → PONG as a single-message frame in spec mode', async () => {
|
|
208
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
|
|
209
|
+
await client.opened();
|
|
210
|
+
await client.send('NICK spec-ping');
|
|
211
|
+
await client.send('USER spec-ping 0 * :Spec Ping');
|
|
212
|
+
await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
213
|
+
|
|
214
|
+
await client.send('PING :spec-tok');
|
|
215
|
+
const pong = await client.waitFor((l) => l === 'PONG :spec-tok');
|
|
216
|
+
expect(pong).toBe('PONG :spec-tok');
|
|
217
|
+
await client.close();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('rejects an inbound message exceeding the 510-byte budget in spec mode', async () => {
|
|
221
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
|
|
222
|
+
await client.opened();
|
|
223
|
+
await client.send('NICK spec-budget');
|
|
224
|
+
await client.send('USER spec-budget 0 * :Spec Budget');
|
|
225
|
+
// Drain the entire registration burst (001..376) so delayed numerics
|
|
226
|
+
// don't pollute the post-overlong-message assertion below.
|
|
227
|
+
await client.waitFor((l) => l.startsWith(':irc.local.example.com 376 '));
|
|
228
|
+
|
|
229
|
+
client.received.length = 0;
|
|
230
|
+
// 511-byte payload: `PRIVMSG #x :` (12 bytes) + 499 chars = 511 total.
|
|
231
|
+
const overlong = `PRIVMSG #x :${'a'.repeat(499)}`;
|
|
232
|
+
expect(overlong.length).toBe(511);
|
|
233
|
+
await client.send(overlong);
|
|
234
|
+
// Give the server a moment to process + close. An over-budget message
|
|
235
|
+
// MUST NOT produce a PRIVMSG broadcast or numeric reply.
|
|
236
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
237
|
+
expect(client.received.length).toBe(0);
|
|
238
|
+
await client.close();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// A reducer-driven disconnect with a reason (e.g. flood control / oper
|
|
242
|
+
// kill) emits the RFC-style ERROR notice. In spec mode it MUST arrive as
|
|
243
|
+
// its own WebSocket message with no trailing CR-LF, for both text and
|
|
244
|
+
// binary negotiated connections.
|
|
245
|
+
for (const subproto of ['text.ircv3.net', 'binary.ircv3.net'] as const) {
|
|
246
|
+
it(`delivers the ERROR notice as a single message on a ${subproto} connection`, async () => {
|
|
247
|
+
const nick = subproto === 'text.ircv3.net' ? 'spec-disc-text' : 'spec-disc-bin';
|
|
248
|
+
const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, subproto);
|
|
249
|
+
await client.opened();
|
|
250
|
+
await client.send(`NICK ${nick}`);
|
|
251
|
+
await client.send(`USER ${nick} 0 * :Spec Disc`);
|
|
252
|
+
await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
253
|
+
|
|
254
|
+
const connId = await server.runtime.lookupNick(nick);
|
|
255
|
+
expect(connId).not.toBeNull();
|
|
256
|
+
await server.runtime.disconnect(connId as string, 'Excess Flood');
|
|
257
|
+
const notice = await client.waitFor((l) => l.startsWith('ERROR :Closing link:'));
|
|
258
|
+
expect(notice).toBe('ERROR :Closing link: (Excess Flood)');
|
|
259
|
+
await client.close();
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
});
|
package/biome.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
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)`
|
|
@@ -0,0 +1,82 @@
|
|
|
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)
|