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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { FakeClock } from '../src/ports';
|
|
3
|
+
import {
|
|
4
|
+
InMemoryNickHistoryStore,
|
|
5
|
+
type NickHistoryEntry,
|
|
6
|
+
type NickHistoryStore,
|
|
7
|
+
} from '../src/ports';
|
|
8
|
+
|
|
9
|
+
/** Builds a history entry with sensible defaults for tests. */
|
|
10
|
+
function entry(
|
|
11
|
+
nick: string,
|
|
12
|
+
signoffTime: number,
|
|
13
|
+
opts: Partial<NickHistoryEntry> = {},
|
|
14
|
+
): NickHistoryEntry {
|
|
15
|
+
return {
|
|
16
|
+
nick,
|
|
17
|
+
connId: opts.connId ?? 'c1',
|
|
18
|
+
signoffTime,
|
|
19
|
+
...(opts.username !== undefined ? { username: opts.username } : {}),
|
|
20
|
+
...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}),
|
|
21
|
+
...(opts.realname !== undefined ? { realname: opts.realname } : {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('InMemoryNickHistoryStore — record + query', () => {
|
|
26
|
+
it('implements the NickHistoryStore port', () => {
|
|
27
|
+
const store: NickHistoryStore = new InMemoryNickHistoryStore(new FakeClock(1_000));
|
|
28
|
+
expect(store).toBeDefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns a recorded entry on query (most-recent-first)', () => {
|
|
32
|
+
const clock = new FakeClock(1_000);
|
|
33
|
+
const store = new InMemoryNickHistoryStore(clock);
|
|
34
|
+
store.record(entry('alice', 1_000, { username: 'alice', hostname: 'ex.org' }));
|
|
35
|
+
|
|
36
|
+
const out = store.query('alice', 10);
|
|
37
|
+
|
|
38
|
+
expect(out).toHaveLength(1);
|
|
39
|
+
expect(out[0]?.nick).toBe('alice');
|
|
40
|
+
expect(out[0]?.username).toBe('alice');
|
|
41
|
+
expect(out[0]?.hostname).toBe('ex.org');
|
|
42
|
+
expect(out[0]?.signoffTime).toBe(1_000);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns multiple entries most-recent-first', () => {
|
|
46
|
+
const clock = new FakeClock(1_000);
|
|
47
|
+
const store = new InMemoryNickHistoryStore(clock);
|
|
48
|
+
store.record(entry('alice', 1_000, { connId: 'c1' }));
|
|
49
|
+
clock.advance(500);
|
|
50
|
+
store.record(entry('alice', 1_500, { connId: 'c2' }));
|
|
51
|
+
clock.advance(500);
|
|
52
|
+
store.record(entry('alice', 2_000, { connId: 'c3' }));
|
|
53
|
+
|
|
54
|
+
const out = store.query('alice', 10);
|
|
55
|
+
expect(out.map((e) => e.connId)).toEqual(['c3', 'c2', 'c1']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('caps the result at the requested count', () => {
|
|
59
|
+
const clock = new FakeClock(1_000);
|
|
60
|
+
const store = new InMemoryNickHistoryStore(clock);
|
|
61
|
+
store.record(entry('alice', 1_000, { connId: 'c1' }));
|
|
62
|
+
clock.advance(500);
|
|
63
|
+
store.record(entry('alice', 1_500, { connId: 'c2' }));
|
|
64
|
+
clock.advance(500);
|
|
65
|
+
store.record(entry('alice', 2_000, { connId: 'c3' }));
|
|
66
|
+
|
|
67
|
+
const out = store.query('alice', 2);
|
|
68
|
+
|
|
69
|
+
expect(out.map((e) => e.connId)).toEqual(['c3', 'c2']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns an empty array for a nick with no history', () => {
|
|
73
|
+
const clock = new FakeClock(1_000);
|
|
74
|
+
const store = new InMemoryNickHistoryStore(clock);
|
|
75
|
+
expect(store.query('ghost', 10)).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('matches nicks case-insensitively (rfc1459 fold)', () => {
|
|
79
|
+
const clock = new FakeClock(1_000);
|
|
80
|
+
const store = new InMemoryNickHistoryStore(clock);
|
|
81
|
+
store.record(entry('Ali[e', 1_000));
|
|
82
|
+
|
|
83
|
+
expect(store.query('ali{e', 10)).toHaveLength(1);
|
|
84
|
+
expect(store.query('ALI{E', 10)).toHaveLength(1);
|
|
85
|
+
expect(store.query('ALI[E', 10)).toHaveLength(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns an empty array when count is zero', () => {
|
|
89
|
+
const clock = new FakeClock(1_000);
|
|
90
|
+
const store = new InMemoryNickHistoryStore(clock);
|
|
91
|
+
store.record(entry('alice', 1_000));
|
|
92
|
+
|
|
93
|
+
expect(store.query('alice', 0)).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('keeps nick histories independent', () => {
|
|
97
|
+
const clock = new FakeClock(1_000);
|
|
98
|
+
const store = new InMemoryNickHistoryStore(clock);
|
|
99
|
+
store.record(entry('alice', 1_000));
|
|
100
|
+
clock.advance(500);
|
|
101
|
+
store.record(entry('bob', 1_500));
|
|
102
|
+
|
|
103
|
+
expect(store.query('alice', 10)).toHaveLength(1);
|
|
104
|
+
expect(store.query('bob', 10)).toHaveLength(1);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('InMemoryNickHistoryStore — TTL purge', () => {
|
|
109
|
+
it('purges entries older than maxAge on query', () => {
|
|
110
|
+
const clock = new FakeClock(1_000);
|
|
111
|
+
const store = new InMemoryNickHistoryStore(clock, { maxAge: 5_000 });
|
|
112
|
+
store.record(entry('alice', 1_000, { connId: 'c1' }));
|
|
113
|
+
clock.advance(3_000);
|
|
114
|
+
store.record(entry('alice', 4_000, { connId: 'c2' }));
|
|
115
|
+
clock.advance(3_000);
|
|
116
|
+
|
|
117
|
+
const out = store.query('alice', 10);
|
|
118
|
+
|
|
119
|
+
expect(out.map((e) => e.connId)).toEqual(['c2']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('purges all entries once they exceed maxAge', () => {
|
|
123
|
+
const clock = new FakeClock(1_000);
|
|
124
|
+
const store = new InMemoryNickHistoryStore(clock, { maxAge: 1_000 });
|
|
125
|
+
store.record(entry('alice', 1_000));
|
|
126
|
+
clock.advance(2_000);
|
|
127
|
+
|
|
128
|
+
expect(store.query('alice', 10)).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('purges on record so the per-nick buffer does not grow unbounded', () => {
|
|
132
|
+
const clock = new FakeClock(1_000);
|
|
133
|
+
const store = new InMemoryNickHistoryStore(clock, { maxAge: 1_000 });
|
|
134
|
+
for (let i = 0; i < 50; i++) {
|
|
135
|
+
store.record(entry('alice', clock.now(), { connId: `c${i}` }));
|
|
136
|
+
clock.advance(100);
|
|
137
|
+
}
|
|
138
|
+
// clock.now() is now 6_000; entries older than cutoff 5_000 are purged.
|
|
139
|
+
const out = store.query('alice', 100);
|
|
140
|
+
expect(out.length).toBeLessThan(50);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('purges to an empty array when all entries expire, then query returns empty', () => {
|
|
144
|
+
const clock = new FakeClock(1_000);
|
|
145
|
+
const store = new InMemoryNickHistoryStore(clock, { maxAge: 1_000 });
|
|
146
|
+
store.record(entry('alice', 1_000, { connId: 'c1' }));
|
|
147
|
+
clock.advance(2_000);
|
|
148
|
+
// First query purges the lone entry → empty buffer remains in the map.
|
|
149
|
+
expect(store.query('alice', 10)).toEqual([]);
|
|
150
|
+
// Second query hits purgeExpired on the now-empty buffer (no-op splice).
|
|
151
|
+
expect(store.query('alice', 10)).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('uses a sane default maxAge when omitted', () => {
|
|
155
|
+
const clock = new FakeClock(1_000);
|
|
156
|
+
const store = new InMemoryNickHistoryStore(clock);
|
|
157
|
+
store.record(entry('alice', 1_000));
|
|
158
|
+
clock.advance(10_000);
|
|
159
|
+
|
|
160
|
+
expect(store.query('alice', 10)).toHaveLength(1);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -23,6 +23,18 @@ describe('Numerics registry', () => {
|
|
|
23
23
|
expect(Numerics.ERR_NEEDMOREPARAMS).toBe(461);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
it('maps RPL_WHOWASUSER to 314', () => {
|
|
27
|
+
expect(Numerics.RPL_WHOWASUSER).toBe(314);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('maps RPL_ENDOFWHOWAS to 369', () => {
|
|
31
|
+
expect(Numerics.RPL_ENDOFWHOWAS).toBe(369);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('maps ERR_WASNOSUCHNICK to 406', () => {
|
|
35
|
+
expect(Numerics.ERR_WASNOSUCHNICK).toBe(406);
|
|
36
|
+
});
|
|
37
|
+
|
|
26
38
|
it('maps ERR_NICKNAMEINUSE to 433', () => {
|
|
27
39
|
expect(Numerics.ERR_NICKNAMEINUSE).toBe(433);
|
|
28
40
|
});
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import type { RawLine } from '../src/effects';
|
|
3
3
|
import { Effect as EffectValue } from '../src/effects';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
EmptyMotdProvider,
|
|
6
|
+
FakeClock,
|
|
7
|
+
InMemoryNickHistoryStore,
|
|
8
|
+
SequentialIdFactory,
|
|
9
|
+
} from '../src/ports';
|
|
5
10
|
import { createConnection } from '../src/state/connection';
|
|
6
11
|
import {
|
|
7
12
|
type Ctx,
|
|
@@ -97,6 +102,35 @@ describe('Ctx / Reducer types', () => {
|
|
|
97
102
|
const keys = Object.keys(ctx) as Array<keyof Ctx>;
|
|
98
103
|
expect(keys).not.toContain('msg');
|
|
99
104
|
});
|
|
105
|
+
|
|
106
|
+
it('threads an optional history store through buildCtx onto ctx.history', () => {
|
|
107
|
+
const history = new InMemoryNickHistoryStore(new FakeClock(0));
|
|
108
|
+
const serverConfig: ServerConfig = {
|
|
109
|
+
serverName: 'irc.example.com',
|
|
110
|
+
networkName: 'ExampleNet',
|
|
111
|
+
maxChannelsPerUser: 30,
|
|
112
|
+
maxTargetsPerCommand: 10,
|
|
113
|
+
maxListEntries: 50,
|
|
114
|
+
nickLen: 30,
|
|
115
|
+
channelLen: 50,
|
|
116
|
+
topicLen: 390,
|
|
117
|
+
quitMessage: 'Client Quit',
|
|
118
|
+
};
|
|
119
|
+
const ctx = buildCtx({
|
|
120
|
+
serverConfig,
|
|
121
|
+
clock: new FakeClock(1_000),
|
|
122
|
+
ids: new SequentialIdFactory(),
|
|
123
|
+
motd: EmptyMotdProvider,
|
|
124
|
+
connection: createConnection({ id: 'c1', connectedSince: 0 }),
|
|
125
|
+
history,
|
|
126
|
+
});
|
|
127
|
+
expect(ctx.history).toBe(history);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('omits ctx.history when no history store is supplied', () => {
|
|
131
|
+
const ctx = makeCtx();
|
|
132
|
+
expect(ctx.history).toBeUndefined();
|
|
133
|
+
});
|
|
100
134
|
});
|
|
101
135
|
|
|
102
136
|
describe('ServerConfig shape', () => {
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type ChanName,
|
|
31
31
|
type ChannelState,
|
|
32
32
|
type Clock,
|
|
33
|
+
type ConnSnapshot,
|
|
33
34
|
type ConnectionState,
|
|
34
35
|
Effect,
|
|
35
36
|
type Effect as EffectType,
|
|
@@ -39,6 +40,8 @@ import {
|
|
|
39
40
|
type Logger,
|
|
40
41
|
type MessageStore,
|
|
41
42
|
type MotdProvider,
|
|
43
|
+
type MtlsIdentityProvider,
|
|
44
|
+
type NickHistoryStore,
|
|
42
45
|
NoopLoggerInstance,
|
|
43
46
|
Numerics,
|
|
44
47
|
type RawLine,
|
|
@@ -47,15 +50,20 @@ import {
|
|
|
47
50
|
chathistoryReducer,
|
|
48
51
|
handleJoinZero,
|
|
49
52
|
isChannelTarget,
|
|
53
|
+
isonReducer,
|
|
50
54
|
listReducer,
|
|
51
55
|
parse,
|
|
56
|
+
toSnapshot,
|
|
57
|
+
userhostReducer,
|
|
52
58
|
whoReducer,
|
|
53
59
|
whoisReducer,
|
|
60
|
+
whowasReducer,
|
|
54
61
|
} from '@serverless-ircd/irc-core';
|
|
55
62
|
import { dispatch } from './dispatch.js';
|
|
56
63
|
import type { DispatchOptions } from './dispatch.js';
|
|
57
64
|
import { type RoutedReducers, buildRoutedReducers } from './routing.js';
|
|
58
65
|
import type { IrcRuntime } from './runtime.js';
|
|
66
|
+
import { type Transport, WsTextFrameTransport } from './transport.js';
|
|
59
67
|
|
|
60
68
|
/**
|
|
61
69
|
* Channel-state access for the actor.
|
|
@@ -114,6 +122,22 @@ export interface ConnectionActorOptions {
|
|
|
114
122
|
* can vouch for the credentials.
|
|
115
123
|
*/
|
|
116
124
|
readonly accounts?: AccountStore;
|
|
125
|
+
/**
|
|
126
|
+
* mTLS client-cert identity source for SASL EXTERNAL (absent when mTLS
|
|
127
|
+
* is not configured). When bound, `ctx.mtlsIdentity` is defined so the
|
|
128
|
+
* `AUTHENTICATE EXTERNAL` reducer can resolve the connection's verified
|
|
129
|
+
* certificate subject. Omitted (→ `ctx.mtlsIdentity` undefined) means
|
|
130
|
+
* EXTERNAL is rejected with `904 ERR_SASLFAIL`.
|
|
131
|
+
*/
|
|
132
|
+
readonly mtlsIdentity?: MtlsIdentityProvider;
|
|
133
|
+
/**
|
|
134
|
+
* Nick-history source for the `WHOWAS` command (absent when WHOWAS
|
|
135
|
+
* history retention is disabled). When bound, `ctx.history` is defined
|
|
136
|
+
* so the QUIT and NICK-change reducers record sign-off entries and the
|
|
137
|
+
* WHOWAS reducer replays them. Omitted (→ `ctx.history` undefined)
|
|
138
|
+
* preserves the no-store behaviour: `WHOWAS <nick>` emits `406`+`369`.
|
|
139
|
+
*/
|
|
140
|
+
readonly history?: NickHistoryStore;
|
|
117
141
|
/**
|
|
118
142
|
* Structured-logger port. When omitted the actor runs
|
|
119
143
|
* silently — observability is purely opt-in. When supplied, every
|
|
@@ -127,6 +151,15 @@ export interface ConnectionActorOptions {
|
|
|
127
151
|
* still uniquely identifiable in logs.
|
|
128
152
|
*/
|
|
129
153
|
readonly traceId?: string;
|
|
154
|
+
/**
|
|
155
|
+
* Line-framing seam (PLAN §9, ADR-009). Turns inbound transport input
|
|
156
|
+
* into complete IRC lines. Defaults to a {@link WsTextFrameTransport}
|
|
157
|
+
* (one message per WebSocket text frame, the serverless default). A raw
|
|
158
|
+
* TCP+TLS adapter injects a {@link TcpByteStreamTransport} for stateful
|
|
159
|
+
* `\r\n` byte-stream framing across chunks. The parser/reducer/dispatch
|
|
160
|
+
* pipeline is shared unchanged across both transports.
|
|
161
|
+
*/
|
|
162
|
+
readonly transport?: Transport;
|
|
130
163
|
}
|
|
131
164
|
|
|
132
165
|
export class ConnectionActor {
|
|
@@ -139,7 +172,16 @@ export class ConnectionActor {
|
|
|
139
172
|
private readonly motd: MotdProvider;
|
|
140
173
|
private readonly messages: MessageStore | undefined;
|
|
141
174
|
private readonly accounts: AccountStore | undefined;
|
|
175
|
+
private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
|
|
176
|
+
private readonly history: NickHistoryStore | undefined;
|
|
142
177
|
private readonly reducers: RoutedReducers;
|
|
178
|
+
/**
|
|
179
|
+
* Line-framing seam. Turns inbound input (a WS text frame or a raw TCP
|
|
180
|
+
* chunk) into complete IRC lines. Defaulted to a WS-frame transport for
|
|
181
|
+
* the serverless default; a TCP+TLS adapter injects the stateful
|
|
182
|
+
* byte-stream transport at construction.
|
|
183
|
+
*/
|
|
184
|
+
private readonly transport: Transport;
|
|
143
185
|
/**
|
|
144
186
|
* Bound logger. Defaults to a no-op so the actor pipeline has no
|
|
145
187
|
* observable side effects when callers do not opt in.
|
|
@@ -163,6 +205,8 @@ export class ConnectionActor {
|
|
|
163
205
|
this.motd = opts.motd ?? EmptyMotdProvider;
|
|
164
206
|
this.messages = opts.messages;
|
|
165
207
|
this.accounts = opts.accounts;
|
|
208
|
+
this.mtlsIdentity = opts.mtlsIdentity;
|
|
209
|
+
this.history = opts.history;
|
|
166
210
|
this.logger = opts.logger ?? NoopLoggerInstance;
|
|
167
211
|
this.pinnedTraceId = opts.traceId;
|
|
168
212
|
// Bind the connection id once so every record the actor emits carries
|
|
@@ -176,15 +220,19 @@ export class ConnectionActor {
|
|
|
176
220
|
// reducer when flood control is disabled) so a flooding client is bounded
|
|
177
221
|
// regardless of which authority owns the command's state.
|
|
178
222
|
this.reducers = buildRoutedReducers(opts.serverConfig);
|
|
223
|
+
this.transport = opts.transport ?? new WsTextFrameTransport();
|
|
179
224
|
}
|
|
180
225
|
|
|
181
226
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
227
|
+
* Transport-agnostic entry point: feeds `chunk` through the bound
|
|
228
|
+
* {@link Transport} and dispatches each complete, non-empty line in order.
|
|
229
|
+
* A WS adapter passes one text frame per call; a raw TCP+TLS adapter
|
|
230
|
+
* passes one arbitrary byte chunk per call and the transport buffers any
|
|
231
|
+
* partial trailing line across calls. A parse failure on one line emits a
|
|
232
|
+
* `421` and processing continues with the next line.
|
|
185
233
|
*/
|
|
186
|
-
async
|
|
187
|
-
const lines =
|
|
234
|
+
async receive(chunk: string): Promise<void> {
|
|
235
|
+
const lines = this.transport.feed(chunk);
|
|
188
236
|
if (lines.length === 0) return;
|
|
189
237
|
const traceId = this.pinnedTraceId ?? this.ids.traceId();
|
|
190
238
|
const log = this.logger.child({ traceId });
|
|
@@ -194,6 +242,17 @@ export class ConnectionActor {
|
|
|
194
242
|
}
|
|
195
243
|
}
|
|
196
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Processes one WebSocket text frame. Equivalent to {@link receive} — kept
|
|
247
|
+
* as the named entry point every WS adapter already calls. Splits on
|
|
248
|
+
* `\r\n` (tolerating bare `\n`) via the default WS-frame transport and
|
|
249
|
+
* dispatches each non-empty line in order. A parse failure on one line
|
|
250
|
+
* emits a `421` and processing continues with the next line.
|
|
251
|
+
*/
|
|
252
|
+
async receiveTextFrame(text: string): Promise<void> {
|
|
253
|
+
await this.receive(text);
|
|
254
|
+
}
|
|
255
|
+
|
|
197
256
|
private async processLine(line: string, log: Logger, traceId: string): Promise<void> {
|
|
198
257
|
let msg: IrcMessage;
|
|
199
258
|
let parseFailed = false;
|
|
@@ -256,6 +315,8 @@ export class ConnectionActor {
|
|
|
256
315
|
connection: this.state,
|
|
257
316
|
...(this.messages !== undefined ? { messages: this.messages } : {}),
|
|
258
317
|
...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
|
|
318
|
+
...(this.mtlsIdentity !== undefined ? { mtlsIdentity: this.mtlsIdentity } : {}),
|
|
319
|
+
...(this.history !== undefined ? { history: this.history } : {}),
|
|
259
320
|
});
|
|
260
321
|
|
|
261
322
|
switch (msg.command) {
|
|
@@ -283,6 +344,20 @@ export class ConnectionActor {
|
|
|
283
344
|
case 'AWAY':
|
|
284
345
|
return this.reducers.away(this.state, msg, ctx).effects;
|
|
285
346
|
|
|
347
|
+
// -------- Server-info query commands --------
|
|
348
|
+
case 'VERSION':
|
|
349
|
+
return this.reducers.version(this.state, msg, ctx).effects;
|
|
350
|
+
case 'TIME':
|
|
351
|
+
return this.reducers.time(this.state, msg, ctx).effects;
|
|
352
|
+
case 'ADMIN':
|
|
353
|
+
return this.reducers.admin(this.state, msg, ctx).effects;
|
|
354
|
+
case 'INFO':
|
|
355
|
+
return this.reducers.info(this.state, msg, ctx).effects;
|
|
356
|
+
case 'USERHOST':
|
|
357
|
+
return this.routeUserhost(msg, ctx);
|
|
358
|
+
case 'ISON':
|
|
359
|
+
return this.routeIson(msg, ctx);
|
|
360
|
+
|
|
286
361
|
// -------- Channel-authority commands --------
|
|
287
362
|
case 'JOIN':
|
|
288
363
|
return this.routeJoin(msg, ctx);
|
|
@@ -305,6 +380,8 @@ export class ConnectionActor {
|
|
|
305
380
|
return this.routeWho(msg, ctx);
|
|
306
381
|
case 'WHOIS':
|
|
307
382
|
return this.routeWhois(msg, ctx);
|
|
383
|
+
case 'WHOWAS':
|
|
384
|
+
return this.routeWhowas(msg, ctx);
|
|
308
385
|
case 'CHATHISTORY':
|
|
309
386
|
return this.routeChathistory(msg, ctx);
|
|
310
387
|
|
|
@@ -339,6 +416,16 @@ export class ConnectionActor {
|
|
|
339
416
|
: this.reducers.userMode(this.state, msg, ctx).effects;
|
|
340
417
|
|
|
341
418
|
default:
|
|
419
|
+
// Remaining standard IRC verbs not yet implemented. They fall through
|
|
420
|
+
// to a graceful `421 ERR_UNKNOWNCOMMAND`. Triage of remaining verbs:
|
|
421
|
+
// - Deferred (serverless-incompatible / S2S / needs new infra):
|
|
422
|
+
// LUSERS (needs cross-adapter getStats), KILL (oper disconnect),
|
|
423
|
+
// REHASH (no runtime config reload), CONNECT/SQUIT (S2S, PLAN
|
|
424
|
+
// non-goal), TRACE (complex routing info), STATS (stats backend),
|
|
425
|
+
// WALLOPS (oper broadcast mechanism), SETNAME (CHGHOST plumbing),
|
|
426
|
+
// LINKS (S2S server list).
|
|
427
|
+
// - Obsolete (RFC 2812, never widely implemented): SERVICE, SUMMON,
|
|
428
|
+
// USERS.
|
|
342
429
|
return [this.unknownCommandEffect(msg.command)];
|
|
343
430
|
}
|
|
344
431
|
}
|
|
@@ -387,6 +474,72 @@ export class ConnectionActor {
|
|
|
387
474
|
return whoisReducer(target, channels, msg, ctx).effects;
|
|
388
475
|
}
|
|
389
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Routes `WHOWAS <nick>{,<nick>} [<count>]` to the whowas reducer. The
|
|
479
|
+
* command is query-only: it reads the bound nick-history store
|
|
480
|
+
* (`this.history`, absent when no store is wired) and emits
|
|
481
|
+
* `314`/`369`/`406` numerics. No runtime lookups are needed — the store
|
|
482
|
+
* is synchronous and self-contained.
|
|
483
|
+
*/
|
|
484
|
+
private async routeWhowas(
|
|
485
|
+
msg: IrcMessage,
|
|
486
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
487
|
+
): Promise<EffectType[]> {
|
|
488
|
+
return whowasReducer(this.history, msg, ctx).effects;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Routes `USERHOST <nick>{ <nick>}` to the userhost reducer. Resolves each
|
|
493
|
+
* requested nick via the nick registry + connection lookup, passing the
|
|
494
|
+
* resulting snapshots (or `null` for offline nicks) to the pure formatter.
|
|
495
|
+
* The number of queried nicks is capped at
|
|
496
|
+
* `serverConfig.maxTargetsPerCommand` by the reducer.
|
|
497
|
+
*/
|
|
498
|
+
private async routeUserhost(
|
|
499
|
+
msg: IrcMessage,
|
|
500
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
501
|
+
): Promise<EffectType[]> {
|
|
502
|
+
const cap = ctx.serverConfig.maxTargetsPerCommand;
|
|
503
|
+
const nicks = msg.params.slice(0, cap);
|
|
504
|
+
const resolved = new Map<string, ConnSnapshot | null>();
|
|
505
|
+
for (const nick of nicks) {
|
|
506
|
+
if (nick.length === 0) continue;
|
|
507
|
+
const connId = await this.runtime.lookupNick(nick);
|
|
508
|
+
if (connId === null) {
|
|
509
|
+
resolved.set(nick, null);
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
const conn = await this.runtime.getConnection(connId);
|
|
513
|
+
resolved.set(nick, conn === null ? null : toSnapshot(conn));
|
|
514
|
+
}
|
|
515
|
+
return userhostReducer(resolved, msg, ctx).effects;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Routes `ISON <nick>{ <nick>}` to the ison reducer. Resolves each requested
|
|
520
|
+
* nick via the nick registry; for online nicks, fetches the connection to
|
|
521
|
+
* recover the registered (display) spelling. Passes a folded→display map to
|
|
522
|
+
* the pure formatter.
|
|
523
|
+
*/
|
|
524
|
+
private async routeIson(
|
|
525
|
+
msg: IrcMessage,
|
|
526
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
527
|
+
): Promise<EffectType[]> {
|
|
528
|
+
const cap = ctx.serverConfig.maxTargetsPerCommand;
|
|
529
|
+
const nicks = msg.params.slice(0, cap);
|
|
530
|
+
const online = new Map<string, string>();
|
|
531
|
+
for (const nick of nicks) {
|
|
532
|
+
if (nick.length === 0) continue;
|
|
533
|
+
const connId = await this.runtime.lookupNick(nick);
|
|
534
|
+
if (connId === null) continue;
|
|
535
|
+
const conn = await this.runtime.getConnection(connId);
|
|
536
|
+
if (conn?.nick !== undefined) {
|
|
537
|
+
online.set(conn.nick.toLowerCase(), conn.nick);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return isonReducer(online, msg, ctx).effects;
|
|
541
|
+
}
|
|
542
|
+
|
|
390
543
|
/**
|
|
391
544
|
* Routes `CHATHISTORY <sub> <target> [<args>...]` to the chathistory
|
|
392
545
|
* reducer. The command is query-only and must NOT create a channel as a
|
|
@@ -531,15 +684,6 @@ export class ConnectionActor {
|
|
|
531
684
|
}
|
|
532
685
|
}
|
|
533
686
|
|
|
534
|
-
/**
|
|
535
|
-
* Splits a WebSocket text frame into IRC lines. Tolerates `\r\n`, bare `\n`,
|
|
536
|
-
* and missing trailing terminators. Returns each line bare (no CRLF).
|
|
537
|
-
*/
|
|
538
|
-
export function splitFrameLines(text: string): string[] {
|
|
539
|
-
if (text.length === 0) return [];
|
|
540
|
-
return text.split(/\r?\n/u);
|
|
541
|
-
}
|
|
542
|
-
|
|
543
687
|
/** Returns the first whitespace-delimited token of `line`, or `''`. */
|
|
544
688
|
function firstToken(line: string): string {
|
|
545
689
|
const m = line.match(/^\S+/u);
|
|
@@ -33,9 +33,6 @@ const handlers: { [K in Effect['tag']]: Handler<Extract<Effect, { tag: K }>> } =
|
|
|
33
33
|
ChangeNick: (e, r) => r.changeNick(e.conn, e.oldNick, e.newNick).then(noop),
|
|
34
34
|
ReleaseNick: (e, r) => r.releaseNick(e.nick),
|
|
35
35
|
ApplyChannelDelta: (e, r) => r.applyChannelDelta(e.chan, e.delta),
|
|
36
|
-
LookupNick: (e, r) => r.lookupNick(e.nick).then(noop),
|
|
37
|
-
GetConnectionInfo: (e, r) => r.getConnectionInfo(e.conn).then(noop),
|
|
38
|
-
GetChannelSnapshot: (e, r) => r.getChannelSnapshot(e.chan).then(noop),
|
|
39
36
|
};
|
|
40
37
|
|
|
41
38
|
function noop(): void {}
|
|
@@ -3,11 +3,19 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Orchestration layer: defines the {@link IrcRuntime} port, the {@link dispatch}
|
|
5
5
|
* interpreter that runs an `Effect[]` against it, and the {@link ConnectionActor}
|
|
6
|
-
* that turns a WebSocket text frame
|
|
6
|
+
* that turns inbound transport input (a WebSocket text frame or a raw TCP byte
|
|
7
|
+
* chunk) into reducer invocations + dispatch via the transport-agnostic
|
|
8
|
+
* {@link Transport} framing seam.
|
|
7
9
|
*/
|
|
8
10
|
export type { IrcRuntime } from './runtime.js';
|
|
9
11
|
export { dispatch } from './dispatch.js';
|
|
10
|
-
export { ConnectionActor
|
|
12
|
+
export { ConnectionActor } from './actor.js';
|
|
11
13
|
export type { ActorChannelAccess, ConnectionActorOptions } from './actor.js';
|
|
14
|
+
export {
|
|
15
|
+
splitFrameLines,
|
|
16
|
+
WsTextFrameTransport,
|
|
17
|
+
TcpByteStreamTransport,
|
|
18
|
+
} from './transport.js';
|
|
19
|
+
export type { Transport } from './transport.js';
|
|
12
20
|
export { buildRoutedReducers, resolveFloodControl } from './routing.js';
|
|
13
21
|
export type { RoutedReducers } from './routing.js';
|
|
@@ -29,11 +29,13 @@ import {
|
|
|
29
29
|
type FloodControlConfig,
|
|
30
30
|
type Reducer,
|
|
31
31
|
type ServerConfig,
|
|
32
|
+
adminReducer,
|
|
32
33
|
authenticateReducer,
|
|
33
34
|
awayReducer,
|
|
34
35
|
capReducer,
|
|
35
36
|
channelModeReducer,
|
|
36
37
|
defaultCommandCost,
|
|
38
|
+
infoReducer,
|
|
37
39
|
inviteReducer,
|
|
38
40
|
joinReducer,
|
|
39
41
|
kickReducer,
|
|
@@ -52,9 +54,11 @@ import {
|
|
|
52
54
|
quitReducer,
|
|
53
55
|
tagmsgChannelReducer,
|
|
54
56
|
tagmsgUserReducer,
|
|
57
|
+
timeReducer,
|
|
55
58
|
topicReducer,
|
|
56
59
|
userModeReducer,
|
|
57
60
|
userReducer,
|
|
61
|
+
versionReducer,
|
|
58
62
|
wrapWithFloodControl,
|
|
59
63
|
} from '@serverless-ircd/irc-core';
|
|
60
64
|
|
|
@@ -76,6 +80,10 @@ export interface RoutedReducers {
|
|
|
76
80
|
readonly cap: Reducer<ConnectionState>;
|
|
77
81
|
readonly authenticate: Reducer<ConnectionState>;
|
|
78
82
|
readonly away: Reducer<ConnectionState>;
|
|
83
|
+
readonly version: Reducer<ConnectionState>;
|
|
84
|
+
readonly time: Reducer<ConnectionState>;
|
|
85
|
+
readonly admin: Reducer<ConnectionState>;
|
|
86
|
+
readonly info: Reducer<ConnectionState>;
|
|
79
87
|
// Channel-authority reducers.
|
|
80
88
|
readonly join: Reducer<ChannelState>;
|
|
81
89
|
readonly part: Reducer<ChannelState>;
|
|
@@ -142,6 +150,10 @@ export function buildRoutedReducers(serverConfig: ServerConfig): RoutedReducers
|
|
|
142
150
|
cap: wrap(capReducer),
|
|
143
151
|
authenticate: wrap(authenticateReducer),
|
|
144
152
|
away: wrap(awayReducer),
|
|
153
|
+
version: wrap(versionReducer),
|
|
154
|
+
time: wrap(timeReducer),
|
|
155
|
+
admin: wrap(adminReducer),
|
|
156
|
+
info: wrap(infoReducer),
|
|
145
157
|
join: wrap(joinReducer),
|
|
146
158
|
part: wrap(partReducer),
|
|
147
159
|
topic: wrap(topicReducer),
|