serverless-ircd 0.2.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 +258 -18
- package/README.md +72 -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/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +11 -1
- package/apps/cf-worker/wrangler.toml +69 -19
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +11 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +46 -3
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- package/package.json +19 -16
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +124 -34
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +62 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +43 -2
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +120 -9
- 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 +13 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
- package/packages/aws-adapter/tests/account-store.test.ts +279 -0
- 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 +75 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +194 -47
- 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 +40 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +62 -0
- package/packages/cf-adapter/src/connection-do.ts +181 -31
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +58 -8
- package/packages/cf-adapter/src/index.ts +11 -9
- 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/cf-runtime.test.ts +91 -0
- 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/connection-do.test.ts +40 -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 +9 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +8 -2
- 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/case-fold.ts +64 -0
- 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 +11 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +4 -3
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +26 -19
- 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/tagmsg.ts +205 -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 +84 -3
- 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 +3 -0
- package/packages/irc-core/src/ports.ts +240 -7
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +60 -1
- package/packages/irc-core/tests/account-store.test.ts +131 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- 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/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +256 -19
- 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 +662 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +170 -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 +182 -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 +21 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +617 -1
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +7 -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 +78 -13
- 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 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +79 -0
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/AGENTS.md +0 -5
- package/MOTD.txt +0 -3
- package/PLAN-FIXES.md +0 -358
- package/PLAN.md +0 -420
- package/dashboards/cloudwatch-irc.json +0 -139
- package/docs/AWS-Adapter-Architecture.md +0 -494
- package/docs/AWS-Deployment.md +0 -1107
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -1
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -440
- package/progress.md +0 -107
- package/tickets.md +0 -2485
- package/webircgateway/LICENSE +0 -201
- package/webircgateway/Makefile +0 -44
- package/webircgateway/README.md +0 -134
- package/webircgateway/config.conf.example +0 -135
- package/webircgateway/go.mod +0 -16
- package/webircgateway/go.sum +0 -89
- package/webircgateway/main.go +0 -118
- package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
- package/webircgateway/pkg/identd/identd.go +0 -86
- package/webircgateway/pkg/identd/rpcclient.go +0 -59
- package/webircgateway/pkg/irc/isupport.go +0 -56
- package/webircgateway/pkg/irc/message.go +0 -217
- package/webircgateway/pkg/irc/state.go +0 -79
- package/webircgateway/pkg/proxy/proxy.go +0 -129
- package/webircgateway/pkg/proxy/server.go +0 -237
- package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
- package/webircgateway/pkg/webircgateway/client.go +0 -741
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
- package/webircgateway/pkg/webircgateway/config.go +0 -385
- package/webircgateway/pkg/webircgateway/gateway.go +0 -278
- package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
- package/webircgateway/pkg/webircgateway/hooks.go +0 -152
- package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
- package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
- package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
- package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
- package/webircgateway/pkg/webircgateway/utils.go +0 -147
- package/webircgateway/plugins/example/plugin.go +0 -11
- package/webircgateway/plugins/stats/plugin.go +0 -52
- package/webircgateway/staticcheck.conf +0 -1
- package/webircgateway/webircgateway.svg +0 -3
|
@@ -146,23 +146,6 @@ describe('dispatch', () => {
|
|
|
146
146
|
expect(calls[0]).toEqual({ method: 'applyChannelDelta', args: ['#foo', delta] });
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
-
it('invokes lookupNick, getConnectionInfo, getChannelSnapshot lookups', async () => {
|
|
150
|
-
const calls: RecordedCall[] = [];
|
|
151
|
-
await dispatch(
|
|
152
|
-
[
|
|
153
|
-
Effect.lookupNick('alice'),
|
|
154
|
-
Effect.getConnectionInfo('c1'),
|
|
155
|
-
Effect.getChannelSnapshot('#foo'),
|
|
156
|
-
],
|
|
157
|
-
fakeRuntime(calls),
|
|
158
|
-
);
|
|
159
|
-
expect(calls.map((c) => c.method)).toEqual([
|
|
160
|
-
'lookupNick',
|
|
161
|
-
'getConnectionInfo',
|
|
162
|
-
'getChannelSnapshot',
|
|
163
|
-
]);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
149
|
it('awaits each effect in sequence (one fails, the rest after it do not run)', async () => {
|
|
167
150
|
const calls: RecordedCall[] = [];
|
|
168
151
|
const failing: IrcRuntime = {
|
|
@@ -109,10 +109,12 @@ describe('buildRoutedReducers', () => {
|
|
|
109
109
|
const reducers = buildRoutedReducers(baseServerConfig);
|
|
110
110
|
expect(Object.keys(reducers).sort()).toEqual(
|
|
111
111
|
[
|
|
112
|
+
'admin',
|
|
112
113
|
'authenticate',
|
|
113
114
|
'away',
|
|
114
115
|
'cap',
|
|
115
116
|
'channelMode',
|
|
117
|
+
'info',
|
|
116
118
|
'invite',
|
|
117
119
|
'join',
|
|
118
120
|
'kick',
|
|
@@ -121,6 +123,7 @@ describe('buildRoutedReducers', () => {
|
|
|
121
123
|
'nick',
|
|
122
124
|
'noticeChannel',
|
|
123
125
|
'noticeUser',
|
|
126
|
+
'oper',
|
|
124
127
|
'part',
|
|
125
128
|
'pass',
|
|
126
129
|
'ping',
|
|
@@ -128,9 +131,13 @@ describe('buildRoutedReducers', () => {
|
|
|
128
131
|
'privmsgChannel',
|
|
129
132
|
'privmsgUser',
|
|
130
133
|
'quit',
|
|
134
|
+
'tagmsgChannel',
|
|
135
|
+
'tagmsgUser',
|
|
136
|
+
'time',
|
|
131
137
|
'topic',
|
|
132
138
|
'user',
|
|
133
139
|
'userMode',
|
|
140
|
+
'version',
|
|
134
141
|
].sort(),
|
|
135
142
|
);
|
|
136
143
|
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { TcpByteStreamTransport, WsTextFrameTransport } from '../src/transport';
|
|
3
|
+
import type { Transport } from '../src/transport';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Transport seam: the generalized line-framing contract shared by the
|
|
7
|
+
// WebSocket and raw TCP+TLS transports (PLAN §9, ADR-009).
|
|
8
|
+
//
|
|
9
|
+
// Two transports share one parser/reducer/dispatch pipeline:
|
|
10
|
+
// - WsTextFrameTransport — one (or N `\r\n`-joined) IRC message(s) per WS
|
|
11
|
+
// text frame; a frame is a complete unit, so a
|
|
12
|
+
// trailing unterminated line is still emitted.
|
|
13
|
+
// - TcpByteStreamTransport — stateful `\r\n` byte-stream framing across
|
|
14
|
+
// arbitrary chunks with partial-line buffering.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
describe('WsTextFrameTransport', () => {
|
|
18
|
+
it('returns [] for an empty frame', () => {
|
|
19
|
+
const t = new WsTextFrameTransport();
|
|
20
|
+
expect(t.feed('')).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('emits a trailing unterminated line (a WS frame is a complete unit)', () => {
|
|
24
|
+
const t = new WsTextFrameTransport();
|
|
25
|
+
expect(t.feed('PING :tok')).toEqual(['PING :tok']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('splits a CRLF-terminated single line', () => {
|
|
29
|
+
const t = new WsTextFrameTransport();
|
|
30
|
+
expect(t.feed('PING :tok\r\n')).toEqual(['PING :tok', '']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('splits a frame with N CRLF-joined messages into N lines (in order)', () => {
|
|
34
|
+
const t = new WsTextFrameTransport();
|
|
35
|
+
expect(t.feed('PING :a\r\nPING :b\r\nPING :c\r\n')).toEqual([
|
|
36
|
+
'PING :a',
|
|
37
|
+
'PING :b',
|
|
38
|
+
'PING :c',
|
|
39
|
+
'',
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('tolerates bare LF endings', () => {
|
|
44
|
+
const t = new WsTextFrameTransport();
|
|
45
|
+
expect(t.feed('PING :a\nPING :b\n')).toEqual(['PING :a', 'PING :b', '']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('keeps empty lines between messages (the actor skips them)', () => {
|
|
49
|
+
const t = new WsTextFrameTransport();
|
|
50
|
+
expect(t.feed('PING :a\r\n\r\nPING :b\r\n')).toEqual(['PING :a', '', 'PING :b', '']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('is stateless: the same frame fed twice yields the same result', () => {
|
|
54
|
+
const t = new WsTextFrameTransport();
|
|
55
|
+
const first = t.feed('PING :a\r\n');
|
|
56
|
+
const second = t.feed('PING :a\r\n');
|
|
57
|
+
expect(second).toEqual(first);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('TcpByteStreamTransport', () => {
|
|
62
|
+
it('returns [] for an empty chunk', () => {
|
|
63
|
+
const t = new TcpByteStreamTransport();
|
|
64
|
+
expect(t.feed('')).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('emits one complete CRLF-terminated line in a single chunk', () => {
|
|
68
|
+
const t = new TcpByteStreamTransport();
|
|
69
|
+
expect(t.feed('PRIVMSG #foo :hello\r\n')).toEqual(['PRIVMSG #foo :hello']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('buffers a partial line and emits nothing until the terminator arrives', () => {
|
|
73
|
+
const t = new TcpByteStreamTransport();
|
|
74
|
+
expect(t.feed('PRIVMSG #foo :hel')).toEqual([]);
|
|
75
|
+
expect(t.feed('lo\r\n')).toEqual(['PRIVMSG #foo :hello']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('two chunks split mid-line produce exactly one complete line (no loss, no duplication)', () => {
|
|
79
|
+
const t = new TcpByteStreamTransport();
|
|
80
|
+
expect(t.feed('PRIVMSG #foo :hel')).toEqual([]);
|
|
81
|
+
expect(t.feed('lo\r\n')).toEqual(['PRIVMSG #foo :hello']);
|
|
82
|
+
// A third empty chunk must not re-emit anything.
|
|
83
|
+
expect(t.feed('')).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('emits multiple complete lines delivered in one chunk, in order', () => {
|
|
87
|
+
const t = new TcpByteStreamTransport();
|
|
88
|
+
expect(t.feed('PING :a\r\nPING :b\r\nPING :c\r\n')).toEqual(['PING :a', 'PING :b', 'PING :c']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('terminates on bare LF (mixed/legacy clients)', () => {
|
|
92
|
+
const t = new TcpByteStreamTransport();
|
|
93
|
+
expect(t.feed('PING :a\nPING :b\n')).toEqual(['PING :a', 'PING :b']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('handles mixed CRLF and LF terminators in one stream', () => {
|
|
97
|
+
const t = new TcpByteStreamTransport();
|
|
98
|
+
expect(t.feed('A\r\nB\nC\r\n')).toEqual(['A', 'B', 'C']);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('emits empty lines for consecutive terminators (the actor skips them)', () => {
|
|
102
|
+
const t = new TcpByteStreamTransport();
|
|
103
|
+
expect(t.feed('PING :a\r\n\r\nPING :b\r\n')).toEqual(['PING :a', '', 'PING :b']);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('emits an empty line for a chunk that is only a terminator', () => {
|
|
107
|
+
const t = new TcpByteStreamTransport();
|
|
108
|
+
expect(t.feed('\r\n')).toEqual(['']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('emits an empty line when a chunk starts with a bare LF (no preceding CR)', () => {
|
|
112
|
+
const t = new TcpByteStreamTransport();
|
|
113
|
+
expect(t.feed('\n')).toEqual(['']);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('reassembles a line split across more than two chunks', () => {
|
|
117
|
+
const t = new TcpByteStreamTransport();
|
|
118
|
+
expect(t.feed('PRI')).toEqual([]);
|
|
119
|
+
expect(t.feed('VMSG')).toEqual([]);
|
|
120
|
+
expect(t.feed(' #foo :')).toEqual([]);
|
|
121
|
+
expect(t.feed('hi\r\n')).toEqual(['PRIVMSG #foo :hi']);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('handles a CRLF terminator split across chunks (CR in one, LF in next)', () => {
|
|
125
|
+
const t = new TcpByteStreamTransport();
|
|
126
|
+
expect(t.feed('PING :tok\r')).toEqual([]);
|
|
127
|
+
expect(t.feed('\n')).toEqual(['PING :tok']);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('does not treat a stray CR without a following LF as a terminator', () => {
|
|
131
|
+
const t = new TcpByteStreamTransport();
|
|
132
|
+
// A bare CR mid-line is part of the line until a LF terminates it.
|
|
133
|
+
expect(t.feed('PING :a\rb\r\n')).toEqual(['PING :a\rb']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('preserves a trailing unterminated line across a flush of complete lines', () => {
|
|
137
|
+
const t = new TcpByteStreamTransport();
|
|
138
|
+
// Two complete lines plus the start of a third still in flight.
|
|
139
|
+
expect(t.feed('PING :a\r\nPING :b\r\nPI')).toEqual(['PING :a', 'PING :b']);
|
|
140
|
+
expect(t.feed('NG :c\r\n')).toEqual(['PING :c']);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('satisfies the Transport interface contract', () => {
|
|
144
|
+
const t: Transport = new TcpByteStreamTransport();
|
|
145
|
+
expect(typeof t.feed).toBe('function');
|
|
146
|
+
expect(t.feed('x\r\n')).toEqual(['x']);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Buffer save/restore: the NLB+Lambda TCP adapter must persist
|
|
152
|
+
// the partial-line buffer across independent Lambda invocations. The
|
|
153
|
+
// TcpByteStreamTransport exposes getBuffer/restore so the handler can
|
|
154
|
+
// snapshot the in-flight bytes to DynamoDB and rehydrate them on the next
|
|
155
|
+
// chunk from the same NLB flow.
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
describe('TcpByteStreamTransport — buffer save/restore', () => {
|
|
159
|
+
it('getBuffer returns "" when no partial line is buffered', () => {
|
|
160
|
+
const t = new TcpByteStreamTransport();
|
|
161
|
+
expect(t.getBuffer()).toBe('');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('getBuffer returns the buffered partial line after a feed', () => {
|
|
165
|
+
const t = new TcpByteStreamTransport();
|
|
166
|
+
t.feed('PRIVMSG #foo :hel');
|
|
167
|
+
expect(t.getBuffer()).toBe('PRIVMSG #foo :hel');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('getBuffer returns "" after all buffered bytes have been terminated', () => {
|
|
171
|
+
const t = new TcpByteStreamTransport();
|
|
172
|
+
t.feed('PRIVMSG #foo :hel');
|
|
173
|
+
t.feed('lo\r\n');
|
|
174
|
+
expect(t.getBuffer()).toBe('');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('restore seeds the buffer so a subsequent chunk completes the line', () => {
|
|
178
|
+
const t = new TcpByteStreamTransport();
|
|
179
|
+
t.restore('PRIVMSG #foo :hel');
|
|
180
|
+
expect(t.feed('lo\r\n')).toEqual(['PRIVMSG #foo :hello']);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('a fresh transport restored from a snapshot + fed the next chunk matches the original', () => {
|
|
184
|
+
const original = new TcpByteStreamTransport();
|
|
185
|
+
original.feed('PING :a\r\nPRIV');
|
|
186
|
+
const snapshot = original.getBuffer();
|
|
187
|
+
|
|
188
|
+
const restored = new TcpByteStreamTransport();
|
|
189
|
+
restored.restore(snapshot);
|
|
190
|
+
expect(restored.feed('MSG #b :hi\r\n')).toEqual(['PRIVMSG #b :hi']);
|
|
191
|
+
expect(restored.getBuffer()).toBe('');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('restore("") behaves like a fresh transport (empty buffer)', () => {
|
|
195
|
+
const t = new TcpByteStreamTransport();
|
|
196
|
+
t.restore('');
|
|
197
|
+
expect(t.getBuffer()).toBe('');
|
|
198
|
+
expect(t.feed('PING :x\r\n')).toEqual(['PING :x']);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Equivalence: a fully-terminated message stream produces the same complete
|
|
204
|
+
// lines regardless of how it is chunked across feed() calls.
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
describe('Transport — WS vs TCP framing equivalence', () => {
|
|
208
|
+
it('a fully-terminated single line yields the same complete line from both', () => {
|
|
209
|
+
const ws = new WsTextFrameTransport();
|
|
210
|
+
const tcp = new TcpByteStreamTransport();
|
|
211
|
+
const frame = 'PRIVMSG #foo :hello\r\n';
|
|
212
|
+
// Both transports emit the same complete line (the WS transport also
|
|
213
|
+
// yields a trailing '' from the final terminator, which the actor skips).
|
|
214
|
+
expect(ws.feed(frame)[0]).toBe(tcp.feed(frame)[0]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('chunking the same bytes arbitrarily never changes the reassembled lines', () => {
|
|
218
|
+
const stream = 'PRIVMSG #a :1\r\nPRIVMSG #b :2\r\nNOTICE #c :3\r\n';
|
|
219
|
+
const wsAll = new WsTextFrameTransport().feed(stream).filter((l) => l.length > 0);
|
|
220
|
+
|
|
221
|
+
// Feed the TCP transport the same bytes in arbitrary 5-byte chunks.
|
|
222
|
+
const tcp = new TcpByteStreamTransport();
|
|
223
|
+
const tcpLines: string[] = [];
|
|
224
|
+
for (let i = 0; i < stream.length; i += 5) {
|
|
225
|
+
tcpLines.push(...tcp.feed(stream.slice(i, i + 5)));
|
|
226
|
+
}
|
|
227
|
+
// Empty artifacts filtered, the reassembled lines are byte-identical.
|
|
228
|
+
expect(tcpLines.filter((l) => l.length > 0)).toEqual(wsAll);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -5,20 +5,37 @@
|
|
|
5
5
|
* scenarios pass in-memory + CF + AWS." This file defines the seam:
|
|
6
6
|
*
|
|
7
7
|
* - {@link IrcHarnessFactory} builds a fresh, isolated world per test.
|
|
8
|
-
* Each runtime
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Each (runtime × transport) combination ships one factory. Adding a
|
|
9
|
+
* runtime OR a transport to the parametrized suite is exactly "register
|
|
10
|
+
* another factory in the `describe.each` table".
|
|
11
11
|
* - {@link IrcHarness} spawns clients, each a single IRC connection.
|
|
12
12
|
* - {@link ClientHarness} is the per-connection handle: send a raw IRC
|
|
13
|
-
* line,
|
|
14
|
-
* connection (NICK+USER), close.
|
|
13
|
+
* line, feed raw transport bytes (for framing tests), observe received
|
|
14
|
+
* lines, await a predicate, register the connection (NICK+USER), close.
|
|
15
15
|
*
|
|
16
16
|
* The harness hides platform-specific transport (in-memory actor,
|
|
17
|
-
* HibernatingWebSocket in a ConnectionDO, APIGW `$default` Lambda
|
|
18
|
-
* one synchronous-looking API. Tests are
|
|
19
|
-
* full pipeline: bytes → framed messages →
|
|
17
|
+
* HibernatingWebSocket in a ConnectionDO, APIGW `$default` Lambda, raw
|
|
18
|
+
* TCP+TLS byte stream) behind one synchronous-looking API. Tests are
|
|
19
|
+
* written once and exercise the full pipeline: bytes → framed messages →
|
|
20
|
+
* reducer → dispatch.
|
|
20
21
|
*/
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The kind of line-framing transport a factory binds. Drives the
|
|
25
|
+
* (runtime × transport) parametrized matrix:
|
|
26
|
+
* - `ws` — one WebSocket text frame per inbound unit (one or N
|
|
27
|
+
* `\r\n`-joined messages); a trailing unterminated line is
|
|
28
|
+
* still emitted because a frame is a complete unit.
|
|
29
|
+
* - `tcp` — a raw TCP+TLS byte stream; arbitrary chunk boundaries with
|
|
30
|
+
* stateful `\r\n` framing and partial-line buffering across
|
|
31
|
+
* chunks (PLAN §9, ADR-009).
|
|
32
|
+
*
|
|
33
|
+
* The scenario runner uses this to gate transport-specific cases: the
|
|
34
|
+
* TCP-framing scenarios (chunk boundaries, slow-loris partial sends, bare
|
|
35
|
+
* `\n` tolerance) only run against `transport: 'tcp'` factories.
|
|
36
|
+
*/
|
|
37
|
+
export type TransportKind = 'ws' | 'tcp';
|
|
38
|
+
|
|
22
39
|
import type { ConnId } from '@serverless-ircd/irc-core';
|
|
23
40
|
|
|
24
41
|
/**
|
|
@@ -29,8 +46,14 @@ import type { ConnId } from '@serverless-ircd/irc-core';
|
|
|
29
46
|
* once per `it(...)` and immediately tears down via {@link IrcHarness.close}.
|
|
30
47
|
*/
|
|
31
48
|
export interface IrcHarnessFactory {
|
|
32
|
-
/** Human-readable label for `describe.each` output (e.g. `"in-memory"`). */
|
|
49
|
+
/** Human-readable label for `describe.each` output (e.g. `"in-memory+ws"`). */
|
|
33
50
|
readonly name: string;
|
|
51
|
+
/**
|
|
52
|
+
* The line-framing transport this factory binds. Drives the
|
|
53
|
+
* (runtime × transport) matrix and gates transport-specific scenarios
|
|
54
|
+
* (the TCP-framing block only runs against `transport: 'tcp'`).
|
|
55
|
+
*/
|
|
56
|
+
readonly transport: TransportKind;
|
|
34
57
|
/** Constructs a new harness. MUST NOT retain state across invocations. */
|
|
35
58
|
create(): Promise<IrcHarness>;
|
|
36
59
|
}
|
|
@@ -83,8 +106,20 @@ export interface ClientHarness {
|
|
|
83
106
|
/**
|
|
84
107
|
* Sends one IRC line as this connection. The harness frames it
|
|
85
108
|
* (`line + \r\n`) and routes through the runtime's full pipeline.
|
|
109
|
+
* Equivalent to {@link feed} with a single complete terminated line;
|
|
110
|
+
* prefer `send` for ordinary one-line-per-call test setup.
|
|
86
111
|
*/
|
|
87
112
|
send(line: string): Promise<void>;
|
|
113
|
+
/**
|
|
114
|
+
* Feeds raw transport input as this connection — one WebSocket text
|
|
115
|
+
* frame for the `ws` transport, one arbitrary byte chunk for the `tcp`
|
|
116
|
+
* transport. Use this to exercise transport-specific line framing:
|
|
117
|
+
* chunk boundaries, partial-line buffering, slow-loris-style partial
|
|
118
|
+
* sends, mixed `\r\n` / bare `\n` endings. The chunk is routed through
|
|
119
|
+
* the bound {@link Transport} and the resulting complete lines dispatch
|
|
120
|
+
* through the full pipeline.
|
|
121
|
+
*/
|
|
122
|
+
feed(chunk: string): Promise<void>;
|
|
88
123
|
/**
|
|
89
124
|
* Resolves with the first line (already-seen or future) matching
|
|
90
125
|
* `predicate`. Rejects after `timeoutMs` (default 1000) with the
|
|
@@ -24,12 +24,18 @@ import {
|
|
|
24
24
|
StaticMotdProvider,
|
|
25
25
|
createConnection,
|
|
26
26
|
} from '@serverless-ircd/irc-core';
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
ConnectionActor,
|
|
29
|
+
TcpByteStreamTransport,
|
|
30
|
+
type Transport,
|
|
31
|
+
WsTextFrameTransport,
|
|
32
|
+
} from '@serverless-ircd/irc-server';
|
|
28
33
|
import type {
|
|
29
34
|
ClientHarness,
|
|
30
35
|
IrcHarness,
|
|
31
36
|
IrcHarnessFactory,
|
|
32
37
|
SpawnClientOptions,
|
|
38
|
+
TransportKind,
|
|
33
39
|
} from './harness.js';
|
|
34
40
|
|
|
35
41
|
/** Default tick for the polling waitFor* implementations (ms). */
|
|
@@ -52,10 +58,11 @@ export const HARNESS_SERVER_CONFIG = {
|
|
|
52
58
|
} as const;
|
|
53
59
|
|
|
54
60
|
/**
|
|
55
|
-
* Default MOTD lines exposed to every spawned client. The
|
|
56
|
-
*
|
|
57
|
-
* command after registration reads
|
|
58
|
-
* port. Tests assert against this content
|
|
61
|
+
* Default MOTD lines exposed to every spawned client. The registration
|
|
62
|
+
* welcome block renders these lines as `375`/`372`×N/`376` numerics, and
|
|
63
|
+
* an explicit `MOTD` command after registration reads them again via the
|
|
64
|
+
* {@link MotdProvider} port. Tests assert against this content
|
|
65
|
+
* (scenario: MOTD).
|
|
59
66
|
*/
|
|
60
67
|
export const HARNESS_MOTD_LINES = [
|
|
61
68
|
'Welcome to the ServerlessIRCd integration-test harness.',
|
|
@@ -63,16 +70,44 @@ export const HARNESS_MOTD_LINES = [
|
|
|
63
70
|
];
|
|
64
71
|
|
|
65
72
|
/**
|
|
66
|
-
* Factory entry for the in-memory runtime
|
|
67
|
-
*
|
|
73
|
+
* Factory entry for the in-memory runtime over the WebSocket text-frame
|
|
74
|
+
* transport (PLAN §9, ADR-009). Append this to a suite's FACTORIES array
|
|
75
|
+
* to opt the scenarios into the in-memory+ws cell of the
|
|
76
|
+
* (runtime × transport) matrix.
|
|
77
|
+
*/
|
|
78
|
+
export const inMemoryWsHarnessFactory: IrcHarnessFactory = Object.freeze({
|
|
79
|
+
name: 'in-memory+ws',
|
|
80
|
+
transport: 'ws',
|
|
81
|
+
async create() {
|
|
82
|
+
return new InMemoryHarness('ws');
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Factory entry for the in-memory runtime over the raw TCP+TLS byte-stream
|
|
88
|
+
* transport (stateful `\r\n` framing with partial-line buffering across
|
|
89
|
+
* chunks — PLAN §9, ADR-009). Append this to a suite's FACTORIES array to
|
|
90
|
+
* opt the scenarios into the in-memory+tcp cell of the
|
|
91
|
+
* (runtime × transport) matrix. Lights up the TCP-framing
|
|
92
|
+
* scenario block (chunk boundaries, slow-loris sends, bare `\n`).
|
|
68
93
|
*/
|
|
69
|
-
export const
|
|
70
|
-
name: 'in-memory',
|
|
94
|
+
export const inMemoryTcpHarnessFactory: IrcHarnessFactory = Object.freeze({
|
|
95
|
+
name: 'in-memory+tcp',
|
|
96
|
+
transport: 'tcp',
|
|
71
97
|
async create() {
|
|
72
|
-
return new InMemoryHarness();
|
|
98
|
+
return new InMemoryHarness('tcp');
|
|
73
99
|
},
|
|
74
100
|
});
|
|
75
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Legacy alias for {@link inMemoryWsHarnessFactory}. Before the
|
|
104
|
+
* transport dimension was added, the in-memory runtime registered a
|
|
105
|
+
* single factory with no transport dimension; that factory was
|
|
106
|
+
* implicitly the WebSocket transport. Kept so existing adapter suites
|
|
107
|
+
* and tests referencing `inMemoryHarnessFactory` keep compiling unchanged.
|
|
108
|
+
*/
|
|
109
|
+
export const inMemoryHarnessFactory: IrcHarnessFactory = inMemoryWsHarnessFactory;
|
|
110
|
+
|
|
76
111
|
interface TrackedClient {
|
|
77
112
|
harness: InMemoryClientHarness;
|
|
78
113
|
disconnect: () => void;
|
|
@@ -84,9 +119,22 @@ export class InMemoryHarness implements IrcHarness {
|
|
|
84
119
|
private readonly ids = new SequentialIdFactory();
|
|
85
120
|
private readonly motd = new StaticMotdProvider(HARNESS_MOTD_LINES);
|
|
86
121
|
private readonly clients = new Map<ConnId, TrackedClient>();
|
|
122
|
+
private readonly transportKind: TransportKind;
|
|
87
123
|
private nextId = 0;
|
|
88
124
|
private closed = false;
|
|
89
125
|
|
|
126
|
+
/**
|
|
127
|
+
* @param transportKind The line-framing transport each spawned client's
|
|
128
|
+
* actor binds. `'ws'` (default) uses a stateless
|
|
129
|
+
* {@link WsTextFrameTransport}; `'tcp'` uses a stateful
|
|
130
|
+
* {@link TcpByteStreamTransport} so chunk-boundary scenarios exercise
|
|
131
|
+
* real byte-stream framing. One transport instance per client (TCP
|
|
132
|
+
* transports own a per-connection partial-line buffer).
|
|
133
|
+
*/
|
|
134
|
+
constructor(transportKind: TransportKind = 'ws') {
|
|
135
|
+
this.transportKind = transportKind;
|
|
136
|
+
}
|
|
137
|
+
|
|
90
138
|
async spawnClient(opts?: SpawnClientOptions): Promise<ClientHarness> {
|
|
91
139
|
if (this.closed) throw new Error('spawnClient called on a closed harness');
|
|
92
140
|
const id = opts?.id ?? `c${this.nextId++}`;
|
|
@@ -118,6 +166,7 @@ export class InMemoryHarness implements IrcHarness {
|
|
|
118
166
|
clock: this.clock,
|
|
119
167
|
ids: this.ids,
|
|
120
168
|
motd: this.motd,
|
|
169
|
+
transport: this.newTransport(),
|
|
121
170
|
});
|
|
122
171
|
client.bindActor(actor);
|
|
123
172
|
|
|
@@ -135,6 +184,15 @@ export class InMemoryHarness implements IrcHarness {
|
|
|
135
184
|
return client;
|
|
136
185
|
}
|
|
137
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Allocates a fresh transport instance for one client. TCP transports
|
|
189
|
+
* carry per-connection partial-line state, so each client MUST receive
|
|
190
|
+
* its own instance rather than a shared one.
|
|
191
|
+
*/
|
|
192
|
+
private newTransport(): Transport {
|
|
193
|
+
return this.transportKind === 'tcp' ? new TcpByteStreamTransport() : new WsTextFrameTransport();
|
|
194
|
+
}
|
|
195
|
+
|
|
138
196
|
async clientByNick(nick: string): Promise<ClientHarness | undefined> {
|
|
139
197
|
const connId = await this.runtime.lookupNick(nick);
|
|
140
198
|
if (connId === null) return undefined;
|
|
@@ -172,13 +230,20 @@ class InMemoryClientHarness implements ClientHarness {
|
|
|
172
230
|
}
|
|
173
231
|
|
|
174
232
|
async send(line: string): Promise<void> {
|
|
233
|
+
// `send` is `feed` with one complete terminated line; routing both
|
|
234
|
+
// through the bound transport guarantees a transport change lights up
|
|
235
|
+
// identically across the scenario suite.
|
|
236
|
+
await this.feed(`${line}\r\n`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async feed(chunk: string): Promise<void> {
|
|
175
240
|
/* istanbul ignore next -- defensive: bindActor is called by the
|
|
176
241
|
harness before the client is returned, so the unbound-actor branch
|
|
177
242
|
is unreachable through the public API. */
|
|
178
243
|
if (this.actor === undefined) {
|
|
179
244
|
throw new Error(`client ${this.id} has no bound actor`);
|
|
180
245
|
}
|
|
181
|
-
await this.actor.
|
|
246
|
+
await this.actor.receive(chunk);
|
|
182
247
|
}
|
|
183
248
|
|
|
184
249
|
async waitForLine(
|
|
@@ -216,8 +281,8 @@ class InMemoryClientHarness implements ClientHarness {
|
|
|
216
281
|
// disconnect handler is wired by the harness to unregister.
|
|
217
282
|
if (this.actor !== undefined) {
|
|
218
283
|
try {
|
|
219
|
-
await this.actor.
|
|
220
|
-
/* istanbul ignore next -- defensive:
|
|
284
|
+
await this.actor.receive('QUIT :client closed\r\n');
|
|
285
|
+
/* istanbul ignore next -- defensive: receive swallows
|
|
221
286
|
every error internally; the catch is purely a teardown safety
|
|
222
287
|
net for catastrophic runtime failures. */
|
|
223
288
|
} catch {
|
|
@@ -14,11 +14,14 @@ export type {
|
|
|
14
14
|
IrcHarness,
|
|
15
15
|
IrcHarnessFactory,
|
|
16
16
|
SpawnClientOptions,
|
|
17
|
+
TransportKind,
|
|
17
18
|
} from './harness.js';
|
|
18
19
|
export {
|
|
19
20
|
HARNESS_MOTD_LINES,
|
|
20
21
|
HARNESS_SERVER_CONFIG,
|
|
21
22
|
InMemoryHarness,
|
|
22
23
|
inMemoryHarnessFactory,
|
|
24
|
+
inMemoryTcpHarnessFactory,
|
|
25
|
+
inMemoryWsHarnessFactory,
|
|
23
26
|
} from './in-memory-harness.js';
|
|
24
27
|
export { runIrcScenarios } from './scenarios.js';
|