serverless-ircd 0.1.0 → 0.3.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 +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/CHANGELOG.md +436 -0
- package/README.md +127 -84
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -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 +5 -1
- package/apps/cf-worker/wrangler.toml +66 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +57 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +267 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +126 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- 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 +93 -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 +86 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1186 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +11 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +19 -14
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +871 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
- package/packages/aws-adapter/src/config-loader.ts +126 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +330 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
- package/packages/aws-adapter/src/handlers/index.ts +293 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +74 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
- package/packages/aws-adapter/tests/account-store.test.ts +280 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +473 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/cf-runtime.ts +42 -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 +135 -0
- package/packages/cf-adapter/src/connection-do.ts +119 -0
- package/packages/cf-adapter/src/env.ts +27 -1
- package/packages/cf-adapter/src/index.ts +2 -1
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
- package/packages/cf-adapter/tests/worker/main.ts +2 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +10 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
- package/packages/irc-core/package.json +13 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +63 -10
- 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/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +79 -21
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +270 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +7 -0
- package/packages/irc-core/src/ports.ts +719 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +88 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- 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/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +380 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -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/config.test.ts +721 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +186 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +160 -0
- package/packages/irc-server/tests/actor.test.ts +690 -15
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +204 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import {
|
|
3
|
+
CapturingLogger,
|
|
3
4
|
type Clock,
|
|
5
|
+
ConsoleLogger,
|
|
4
6
|
FakeClock,
|
|
5
7
|
type IdFactory,
|
|
8
|
+
LogLevel,
|
|
9
|
+
type LogRecord,
|
|
10
|
+
type Logger,
|
|
11
|
+
NoopLogger,
|
|
12
|
+
NoopLoggerInstance,
|
|
6
13
|
SequentialIdFactory,
|
|
7
14
|
SystemClock,
|
|
8
15
|
UuidIdFactory,
|
|
@@ -99,3 +106,259 @@ describe('SequentialIdFactory', () => {
|
|
|
99
106
|
expect(f.sessionId()).toBe('session-2');
|
|
100
107
|
});
|
|
101
108
|
});
|
|
109
|
+
|
|
110
|
+
describe('IdFactory.traceId', () => {
|
|
111
|
+
it('UuidIdFactory returns unique trace ids', () => {
|
|
112
|
+
const f: IdFactory = new UuidIdFactory();
|
|
113
|
+
expect(typeof f.traceId()).toBe('string');
|
|
114
|
+
expect(f.traceId()).not.toBe(f.traceId());
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('SequentialIdFactory returns deterministic trace ids', () => {
|
|
118
|
+
const f = new SequentialIdFactory();
|
|
119
|
+
expect(f.traceId()).toBe('trace-0');
|
|
120
|
+
expect(f.traceId()).toBe('trace-1');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('LogLevel', () => {
|
|
125
|
+
it('orders levels debug < info < warn < error', () => {
|
|
126
|
+
expect(LogLevel.Debug).toBeLessThan(LogLevel.Info);
|
|
127
|
+
expect(LogLevel.Info).toBeLessThan(LogLevel.Warn);
|
|
128
|
+
expect(LogLevel.Warn).toBeLessThan(LogLevel.Error);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('NoopLogger', () => {
|
|
133
|
+
it('implements the Logger port without throwing', () => {
|
|
134
|
+
const log: Logger = NoopLoggerInstance;
|
|
135
|
+
expect(() => log.debug('m')).not.toThrow();
|
|
136
|
+
expect(() => log.info('m')).not.toThrow();
|
|
137
|
+
expect(() => log.warn('m')).not.toThrow();
|
|
138
|
+
expect(() => log.error('m')).not.toThrow();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('filters out levels below its threshold', () => {
|
|
142
|
+
const called = 0;
|
|
143
|
+
const log = new NoopLogger(LogLevel.Warn);
|
|
144
|
+
log.on(LogLevel.Debug, 'm');
|
|
145
|
+
log.info('m');
|
|
146
|
+
// The NoopLogger body is empty; the only contract is "no observable
|
|
147
|
+
// side-effect", which the count staying zero demonstrates.
|
|
148
|
+
expect(called).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('child() returns an equivalent no-op logger', () => {
|
|
152
|
+
const child = new NoopLogger().child({ traceId: 't-1' });
|
|
153
|
+
expect(() => child.info('m')).not.toThrow();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('CapturingLogger', () => {
|
|
158
|
+
it('captures every record in order', () => {
|
|
159
|
+
const log = new CapturingLogger();
|
|
160
|
+
log.info('one', { a: 1 });
|
|
161
|
+
log.warn('two');
|
|
162
|
+
log.error('three', { e: 'boom' });
|
|
163
|
+
expect(log.records).toHaveLength(3);
|
|
164
|
+
expect(log.records[0]?.msg).toBe('one');
|
|
165
|
+
expect(log.records[1]?.level).toBe(LogLevel.Warn);
|
|
166
|
+
expect(log.records[2]?.fields).toEqual({ e: 'boom' });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('stamps each record with the supplied traceId and connectionId', () => {
|
|
170
|
+
const log = new CapturingLogger({ traceId: 't-1', connectionId: 'c-1' });
|
|
171
|
+
log.info('hi');
|
|
172
|
+
const rec = log.records[0];
|
|
173
|
+
expect(rec?.traceId).toBe('t-1');
|
|
174
|
+
expect(rec?.connectionId).toBe('c-1');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('child() inherits the parent traceId and connectionId and merges new fields', () => {
|
|
178
|
+
const log = new CapturingLogger({ traceId: 't-1', connectionId: 'c-1' });
|
|
179
|
+
const child = log.child({ connectionId: 'c-2' });
|
|
180
|
+
child.info('hi');
|
|
181
|
+
expect(log.records).toHaveLength(1);
|
|
182
|
+
expect(log.records[0]?.traceId).toBe('t-1');
|
|
183
|
+
expect(log.records[0]?.connectionId).toBe('c-2');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('respects a minimum level', () => {
|
|
187
|
+
const log = new CapturingLogger(undefined, LogLevel.Warn);
|
|
188
|
+
log.info('skipped');
|
|
189
|
+
log.warn('kept');
|
|
190
|
+
expect(log.records.map((r) => r.msg)).toEqual(['kept']);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('merges ctx fields with per-call fields', () => {
|
|
194
|
+
const log = new CapturingLogger({ traceId: 't-1', connectionId: 'c-1', chan: '#foo' });
|
|
195
|
+
log.info('hi', { lines: 3 });
|
|
196
|
+
expect(log.records[0]?.fields).toEqual({ chan: '#foo', lines: 3 });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('records a debug entry via debug()', () => {
|
|
200
|
+
const log = new CapturingLogger();
|
|
201
|
+
log.debug('dbg');
|
|
202
|
+
expect(log.records[0]?.level).toBe(LogLevel.Debug);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('routes through on() at any level', () => {
|
|
206
|
+
const log = new CapturingLogger();
|
|
207
|
+
log.on(LogLevel.Warn, 'manual');
|
|
208
|
+
expect(log.records[0]?.level).toBe(LogLevel.Warn);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('uses the injected clock for ts', () => {
|
|
212
|
+
const clock = new FakeClock(42_000);
|
|
213
|
+
const log = new CapturingLogger({}, LogLevel.Debug, clock);
|
|
214
|
+
log.info('t');
|
|
215
|
+
expect(log.records[0]?.ts).toBe(42_000);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('per-call traceId/connectionId overrides take precedence over ctx (used by dispatch)', () => {
|
|
219
|
+
// dispatch() calls logger.on(level, msg, { traceId, connectionId, ... })
|
|
220
|
+
// rather than constructing a child logger per call — both ctx-bound and
|
|
221
|
+
// per-call ids surface as top-level record fields, with per-call values
|
|
222
|
+
// winning. This keeps the dispatch path allocation-free while still
|
|
223
|
+
// stamping the correct ids on every record.
|
|
224
|
+
const log = new CapturingLogger({ traceId: 't-1', connectionId: 'c-1' });
|
|
225
|
+
log.info('hi', { traceId: 't-2', connectionId: 'c-2', chan: '#foo' });
|
|
226
|
+
expect(log.records[0]?.traceId).toBe('t-2');
|
|
227
|
+
expect(log.records[0]?.connectionId).toBe('c-2');
|
|
228
|
+
// chan is ordinary structured context → stays in fields.
|
|
229
|
+
expect(log.records[0]?.fields).toEqual({ chan: '#foo' });
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('ConsoleLogger', () => {
|
|
234
|
+
function stubSinks(): {
|
|
235
|
+
sinks: {
|
|
236
|
+
debug(p: string): void;
|
|
237
|
+
info(p: string): void;
|
|
238
|
+
warn(p: string): void;
|
|
239
|
+
error(p: string): void;
|
|
240
|
+
};
|
|
241
|
+
calls: string[];
|
|
242
|
+
} {
|
|
243
|
+
const calls: string[] = [];
|
|
244
|
+
const sinks = {
|
|
245
|
+
debug: (p: string) => {
|
|
246
|
+
calls.push(`debug:${p}`);
|
|
247
|
+
},
|
|
248
|
+
info: (p: string) => {
|
|
249
|
+
calls.push(`info:${p}`);
|
|
250
|
+
},
|
|
251
|
+
warn: (p: string) => {
|
|
252
|
+
calls.push(`warn:${p}`);
|
|
253
|
+
},
|
|
254
|
+
error: (p: string) => {
|
|
255
|
+
calls.push(`error:${p}`);
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
return { sinks, calls };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
it('serializes each record as a single line of JSON to console.<level>', () => {
|
|
262
|
+
const { sinks, calls } = stubSinks();
|
|
263
|
+
const log = new ConsoleLogger({ traceId: 't-1', connectionId: 'c-1' }, sinks);
|
|
264
|
+
log.info('hello', { lines: 1 });
|
|
265
|
+
log.error('boom');
|
|
266
|
+
|
|
267
|
+
expect(calls).toHaveLength(2);
|
|
268
|
+
expect(calls[0]).toMatch(/^info:/);
|
|
269
|
+
const infoLine = calls[0] ?? '';
|
|
270
|
+
const parsed0 = JSON.parse(infoLine.slice('info:'.length)) as Record<string, unknown>;
|
|
271
|
+
expect(parsed0.msg).toBe('hello');
|
|
272
|
+
expect(parsed0.level).toBe('info');
|
|
273
|
+
expect(parsed0.traceId).toBe('t-1');
|
|
274
|
+
expect(parsed0.connectionId).toBe('c-1');
|
|
275
|
+
expect(parsed0.lines).toBe(1);
|
|
276
|
+
expect(parsed0.ts).toEqual(expect.any(String));
|
|
277
|
+
// Single line — every record must be one JSON object on one physical line.
|
|
278
|
+
expect(infoLine.includes('\n')).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('routes debug/warn through their dedicated sinks', () => {
|
|
282
|
+
const { sinks, calls } = stubSinks();
|
|
283
|
+
const log = new ConsoleLogger({ traceId: 't-1' }, sinks);
|
|
284
|
+
log.debug('d');
|
|
285
|
+
log.warn('w');
|
|
286
|
+
expect(calls[0]).toMatch(/^debug:/);
|
|
287
|
+
expect(calls[1]).toMatch(/^warn:/);
|
|
288
|
+
const debugLine = calls[0] ?? '';
|
|
289
|
+
const warnLine = calls[1] ?? '';
|
|
290
|
+
const parsed0 = JSON.parse(debugLine.slice('debug:'.length)) as Record<string, unknown>;
|
|
291
|
+
expect(parsed0.level).toBe('debug');
|
|
292
|
+
const parsed1 = JSON.parse(warnLine.slice('warn:'.length)) as Record<string, unknown>;
|
|
293
|
+
expect(parsed1.level).toBe('warn');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('routes through on() at any level', () => {
|
|
297
|
+
const { sinks, calls } = stubSinks();
|
|
298
|
+
const log = new ConsoleLogger({}, sinks);
|
|
299
|
+
log.on(LogLevel.Warn, 'manual');
|
|
300
|
+
expect(calls[0]).toMatch(/^warn:/);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('respects a minimum level', () => {
|
|
304
|
+
const { sinks, calls } = stubSinks();
|
|
305
|
+
const log = new ConsoleLogger({}, sinks, LogLevel.Error);
|
|
306
|
+
log.info('skipped');
|
|
307
|
+
log.error('kept');
|
|
308
|
+
expect(calls).toHaveLength(1);
|
|
309
|
+
expect(calls[0]).toMatch(/^error:/);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('child inherits traceId/connectionId', () => {
|
|
313
|
+
const { sinks, calls } = stubSinks();
|
|
314
|
+
const log = new ConsoleLogger({ traceId: 't-1', connectionId: 'c-1' }, sinks);
|
|
315
|
+
const child = log.child({ connectionId: 'c-2' });
|
|
316
|
+
child.info('hi');
|
|
317
|
+
expect(calls[0]).toBeDefined();
|
|
318
|
+
const parsed = JSON.parse((calls[0] ?? '').slice('info:'.length)) as Record<string, unknown>;
|
|
319
|
+
expect(parsed.traceId).toBe('t-1');
|
|
320
|
+
expect(parsed.connectionId).toBe('c-2');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('stamps ts from the injected clock', () => {
|
|
324
|
+
const { sinks, calls } = stubSinks();
|
|
325
|
+
const clock = new FakeClock(1_700_000_000_000);
|
|
326
|
+
const log = new ConsoleLogger({}, sinks, LogLevel.Debug, clock);
|
|
327
|
+
log.info('t');
|
|
328
|
+
expect(calls[0]).toBeDefined();
|
|
329
|
+
const parsed = JSON.parse((calls[0] ?? '').slice('info:'.length)) as Record<string, unknown>;
|
|
330
|
+
expect(parsed.ts).toBe(new Date(1_700_000_000_000).toISOString());
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('defaults to the global console when no sink is supplied', () => {
|
|
334
|
+
const log: Logger = new ConsoleLogger();
|
|
335
|
+
// Exercise every level so the globalThis.console wrapper's four
|
|
336
|
+
// bound arrows are all hit at least once.
|
|
337
|
+
expect(() => {
|
|
338
|
+
log.debug('d');
|
|
339
|
+
log.info('i');
|
|
340
|
+
log.warn('w');
|
|
341
|
+
log.error('e');
|
|
342
|
+
}).not.toThrow();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('falls back to no-op sinks when globalThis.console is absent', () => {
|
|
346
|
+
const original = (globalThis as { console?: unknown }).console;
|
|
347
|
+
try {
|
|
348
|
+
// Simulate a runtime where `globalThis.console` is undefined.
|
|
349
|
+
(globalThis as { console?: unknown }).console = undefined;
|
|
350
|
+
const log = new ConsoleLogger();
|
|
351
|
+
expect(() => log.info('m')).not.toThrow();
|
|
352
|
+
} finally {
|
|
353
|
+
(globalThis as { console?: unknown }).console = original;
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Compile-time assertion: LogRecord includes the required observability fields.
|
|
359
|
+
const _assertLogRecord: LogRecord = {
|
|
360
|
+
ts: 0,
|
|
361
|
+
level: LogLevel.Info,
|
|
362
|
+
msg: '',
|
|
363
|
+
};
|
|
364
|
+
void _assertLogRecord;
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import {
|
|
29
|
+
type AccountStore,
|
|
29
30
|
type ChanName,
|
|
30
31
|
type ChannelState,
|
|
31
32
|
type Clock,
|
|
@@ -35,39 +36,25 @@ import {
|
|
|
35
36
|
EmptyMotdProvider,
|
|
36
37
|
type IdFactory,
|
|
37
38
|
type IrcMessage,
|
|
39
|
+
type Logger,
|
|
40
|
+
type MessageStore,
|
|
38
41
|
type MotdProvider,
|
|
42
|
+
NoopLoggerInstance,
|
|
39
43
|
Numerics,
|
|
40
44
|
type RawLine,
|
|
41
45
|
type ServerConfig,
|
|
42
46
|
buildCtx,
|
|
43
|
-
|
|
44
|
-
channelModeReducer,
|
|
47
|
+
chathistoryReducer,
|
|
45
48
|
handleJoinZero,
|
|
46
|
-
inviteReducer,
|
|
47
49
|
isChannelTarget,
|
|
48
|
-
joinReducer,
|
|
49
|
-
kickReducer,
|
|
50
50
|
listReducer,
|
|
51
|
-
motdReducer,
|
|
52
|
-
namesReducer,
|
|
53
|
-
nickReducer,
|
|
54
|
-
noticeChannelReducer,
|
|
55
|
-
noticeUserReducer,
|
|
56
51
|
parse,
|
|
57
|
-
partReducer,
|
|
58
|
-
passReducer,
|
|
59
|
-
pingReducer,
|
|
60
|
-
pongReducer,
|
|
61
|
-
privmsgChannelReducer,
|
|
62
|
-
privmsgUserReducer,
|
|
63
|
-
quitReducer,
|
|
64
|
-
topicReducer,
|
|
65
|
-
userModeReducer,
|
|
66
|
-
userReducer,
|
|
67
52
|
whoReducer,
|
|
68
53
|
whoisReducer,
|
|
69
54
|
} from '@serverless-ircd/irc-core';
|
|
70
55
|
import { dispatch } from './dispatch.js';
|
|
56
|
+
import type { DispatchOptions } from './dispatch.js';
|
|
57
|
+
import { type RoutedReducers, buildRoutedReducers } from './routing.js';
|
|
71
58
|
import type { IrcRuntime } from './runtime.js';
|
|
72
59
|
|
|
73
60
|
/**
|
|
@@ -87,6 +74,16 @@ import type { IrcRuntime } from './runtime.js';
|
|
|
87
74
|
*/
|
|
88
75
|
export interface ActorChannelAccess {
|
|
89
76
|
getOrCreateChannel(name: ChanName): ChannelState;
|
|
77
|
+
/**
|
|
78
|
+
* Peek-only channel lookup that does NOT create the channel as a side
|
|
79
|
+
* effect (unlike {@link getOrCreateChannel}). Returns the authoritative
|
|
80
|
+
* {@link ChannelState} when the channel exists, or `undefined` otherwise.
|
|
81
|
+
*
|
|
82
|
+
* Used by query-only commands (CHATHISTORY) that must distinguish "no
|
|
83
|
+
* such channel" from an empty one. Optional: runtimes that have not
|
|
84
|
+
* implemented it fall back to {@link getOrCreateChannel} in the actor.
|
|
85
|
+
*/
|
|
86
|
+
getChannel?(name: ChanName): ChannelState | undefined;
|
|
90
87
|
refreshChannel?(name: ChanName): Promise<void>;
|
|
91
88
|
}
|
|
92
89
|
|
|
@@ -98,6 +95,38 @@ export interface ConnectionActorOptions {
|
|
|
98
95
|
clock: Clock;
|
|
99
96
|
ids: IdFactory;
|
|
100
97
|
motd?: MotdProvider;
|
|
98
|
+
/**
|
|
99
|
+
* Chat-history persistence source (absent when chathistory playback is
|
|
100
|
+
* disabled). When bound, `ctx.messages` is defined so the CHATHISTORY
|
|
101
|
+
* reducer can query backlog and the PRIVMSG/NOTICE channel reducer
|
|
102
|
+
* records accepted messages. Omitted (→ `ctx.messages` undefined)
|
|
103
|
+
* preserves the no-store behaviour for deployments/tests that do not
|
|
104
|
+
* opt in.
|
|
105
|
+
*/
|
|
106
|
+
readonly messages?: MessageStore;
|
|
107
|
+
/**
|
|
108
|
+
* SASL account verification source (absent when no accounts are
|
|
109
|
+
* configured). When bound, `ctx.accounts` is defined so the
|
|
110
|
+
* `AUTHENTICATE` reducer can verify PLAIN credentials end-to-end.
|
|
111
|
+
* Omitted (→ `ctx.accounts` undefined) preserves the existing
|
|
112
|
+
* behaviour for callers/tests that do not opt in: a client completing
|
|
113
|
+
* `AUTHENTICATE PLAIN` receives `904 ERR_SASLFAIL` because no store
|
|
114
|
+
* can vouch for the credentials.
|
|
115
|
+
*/
|
|
116
|
+
readonly accounts?: AccountStore;
|
|
117
|
+
/**
|
|
118
|
+
* Structured-logger port. When omitted the actor runs
|
|
119
|
+
* silently — observability is purely opt-in. When supplied, every
|
|
120
|
+
* frame.receive / frame.parse-error / dispatch record carries the
|
|
121
|
+
* actor's bound `traceId` and `connectionId`.
|
|
122
|
+
*/
|
|
123
|
+
readonly logger?: Logger;
|
|
124
|
+
/**
|
|
125
|
+
* Per-request trace id. When omitted the actor allocates
|
|
126
|
+
* one fresh per inbound frame via `ids.traceId()` so every frame is
|
|
127
|
+
* still uniquely identifiable in logs.
|
|
128
|
+
*/
|
|
129
|
+
readonly traceId?: string;
|
|
101
130
|
}
|
|
102
131
|
|
|
103
132
|
export class ConnectionActor {
|
|
@@ -108,6 +137,21 @@ export class ConnectionActor {
|
|
|
108
137
|
private readonly clock: Clock;
|
|
109
138
|
private readonly ids: IdFactory;
|
|
110
139
|
private readonly motd: MotdProvider;
|
|
140
|
+
private readonly messages: MessageStore | undefined;
|
|
141
|
+
private readonly accounts: AccountStore | undefined;
|
|
142
|
+
private readonly reducers: RoutedReducers;
|
|
143
|
+
/**
|
|
144
|
+
* Bound logger. Defaults to a no-op so the actor pipeline has no
|
|
145
|
+
* observable side effects when callers do not opt in.
|
|
146
|
+
*/
|
|
147
|
+
private readonly logger: Logger;
|
|
148
|
+
/**
|
|
149
|
+
* Pinned trace id. When the caller did not supply one the actor generates
|
|
150
|
+
* a fresh one per inbound frame via {@link IdFactory.traceId}; this pinned
|
|
151
|
+
* value is only used in that latter case as a fallback for code paths
|
|
152
|
+
* that need a non-undefined id in test assertions.
|
|
153
|
+
*/
|
|
154
|
+
private readonly pinnedTraceId: string | undefined;
|
|
111
155
|
|
|
112
156
|
constructor(opts: ConnectionActorOptions) {
|
|
113
157
|
this.state = opts.state;
|
|
@@ -117,6 +161,21 @@ export class ConnectionActor {
|
|
|
117
161
|
this.clock = opts.clock;
|
|
118
162
|
this.ids = opts.ids;
|
|
119
163
|
this.motd = opts.motd ?? EmptyMotdProvider;
|
|
164
|
+
this.messages = opts.messages;
|
|
165
|
+
this.accounts = opts.accounts;
|
|
166
|
+
this.logger = opts.logger ?? NoopLoggerInstance;
|
|
167
|
+
this.pinnedTraceId = opts.traceId;
|
|
168
|
+
// Bind the connection id once so every record the actor emits carries
|
|
169
|
+
// the cross-cutting connection identifier. The child inherits traceId
|
|
170
|
+
// and rebinds per-frame; connectionId stays constant for the actor's
|
|
171
|
+
// lifetime.
|
|
172
|
+
if (opts.logger !== undefined) {
|
|
173
|
+
this.logger = opts.logger.child({ connectionId: this.state.id });
|
|
174
|
+
}
|
|
175
|
+
// Build once: every routed reducer is flood-control-wrapped (or the raw
|
|
176
|
+
// reducer when flood control is disabled) so a flooding client is bounded
|
|
177
|
+
// regardless of which authority owns the command's state.
|
|
178
|
+
this.reducers = buildRoutedReducers(opts.serverConfig);
|
|
120
179
|
}
|
|
121
180
|
|
|
122
181
|
/**
|
|
@@ -125,13 +184,17 @@ export class ConnectionActor {
|
|
|
125
184
|
* one line emits a `421` and processing continues with the next line.
|
|
126
185
|
*/
|
|
127
186
|
async receiveTextFrame(text: string): Promise<void> {
|
|
128
|
-
|
|
187
|
+
const lines = splitFrameLines(text);
|
|
188
|
+
if (lines.length === 0) return;
|
|
189
|
+
const traceId = this.pinnedTraceId ?? this.ids.traceId();
|
|
190
|
+
const log = this.logger.child({ traceId });
|
|
191
|
+
for (const line of lines) {
|
|
129
192
|
if (line.length === 0) continue;
|
|
130
|
-
await this.processLine(line);
|
|
193
|
+
await this.processLine(line, log, traceId);
|
|
131
194
|
}
|
|
132
195
|
}
|
|
133
196
|
|
|
134
|
-
private async processLine(line: string): Promise<void> {
|
|
197
|
+
private async processLine(line: string, log: Logger, traceId: string): Promise<void> {
|
|
135
198
|
let msg: IrcMessage;
|
|
136
199
|
let parseFailed = false;
|
|
137
200
|
try {
|
|
@@ -145,17 +208,30 @@ export class ConnectionActor {
|
|
|
145
208
|
msg = { tags: {}, command: '', params: [] };
|
|
146
209
|
}
|
|
147
210
|
if (parseFailed) {
|
|
211
|
+
log.warn('frame.parse-error', {
|
|
212
|
+
line,
|
|
213
|
+
token: firstToken(line),
|
|
214
|
+
});
|
|
148
215
|
const token = firstToken(line);
|
|
149
|
-
await dispatch(
|
|
216
|
+
await dispatch(
|
|
217
|
+
[this.unknownCommandEffect(token)],
|
|
218
|
+
this.runtime,
|
|
219
|
+
this.dispatchOpts(log, traceId),
|
|
220
|
+
);
|
|
150
221
|
return;
|
|
151
222
|
}
|
|
223
|
+
log.debug('frame.receive', { command: msg.command, lines: 1 });
|
|
152
224
|
// Pull authoritative channel state for any channel the upcoming reducer
|
|
153
225
|
// will read. In-memory runtimes no-op; distributed runtimes fetch from
|
|
154
226
|
// the channel authority (e.g. CF ChannelDO). Done before `route` so the
|
|
155
227
|
// synchronous reducers observe up-to-date rosters / modes / topics.
|
|
156
228
|
await this.prefetchChannelState(msg);
|
|
157
229
|
const effects = await this.route(msg);
|
|
158
|
-
await dispatch(effects, this.runtime);
|
|
230
|
+
await dispatch(effects, this.runtime, this.dispatchOpts(log, traceId));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private dispatchOpts(log: Logger, traceId: string): DispatchOptions {
|
|
234
|
+
return { logger: log, traceId, connectionId: this.state.id };
|
|
159
235
|
}
|
|
160
236
|
|
|
161
237
|
/**
|
|
@@ -178,26 +254,34 @@ export class ConnectionActor {
|
|
|
178
254
|
ids: this.ids,
|
|
179
255
|
motd: this.motd,
|
|
180
256
|
connection: this.state,
|
|
257
|
+
...(this.messages !== undefined ? { messages: this.messages } : {}),
|
|
258
|
+
...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
|
|
181
259
|
});
|
|
182
260
|
|
|
183
261
|
switch (msg.command) {
|
|
184
262
|
// -------- Connection-authority commands --------
|
|
185
263
|
case 'PING':
|
|
186
|
-
return
|
|
264
|
+
return this.reducers.ping(this.state, msg, ctx).effects;
|
|
187
265
|
case 'PONG':
|
|
188
|
-
return
|
|
266
|
+
return this.reducers.pong(this.state, msg, ctx).effects;
|
|
189
267
|
case 'NICK':
|
|
190
|
-
return
|
|
268
|
+
return this.reducers.nick(this.state, msg, ctx).effects;
|
|
191
269
|
case 'USER':
|
|
192
|
-
return
|
|
270
|
+
return this.reducers.user(this.state, msg, ctx).effects;
|
|
193
271
|
case 'PASS':
|
|
194
|
-
return
|
|
272
|
+
return this.reducers.pass(this.state, msg, ctx).effects;
|
|
195
273
|
case 'QUIT':
|
|
196
|
-
return
|
|
274
|
+
return this.reducers.quit(this.state, msg, ctx).effects;
|
|
197
275
|
case 'MOTD':
|
|
198
|
-
return
|
|
276
|
+
return this.reducers.motd(this.state, msg, ctx).effects;
|
|
277
|
+
case 'OPER':
|
|
278
|
+
return this.reducers.oper(this.state, msg, ctx).effects;
|
|
199
279
|
case 'CAP':
|
|
200
|
-
return
|
|
280
|
+
return this.reducers.cap(this.state, msg, ctx).effects;
|
|
281
|
+
case 'AUTHENTICATE':
|
|
282
|
+
return this.reducers.authenticate(this.state, msg, ctx).effects;
|
|
283
|
+
case 'AWAY':
|
|
284
|
+
return this.reducers.away(this.state, msg, ctx).effects;
|
|
201
285
|
|
|
202
286
|
// -------- Channel-authority commands --------
|
|
203
287
|
case 'JOIN':
|
|
@@ -205,32 +289,54 @@ export class ConnectionActor {
|
|
|
205
289
|
case 'PART':
|
|
206
290
|
return this.routeMultiChannel(
|
|
207
291
|
msg,
|
|
208
|
-
(chan, singleParamMsg) =>
|
|
292
|
+
(chan, singleParamMsg) => this.reducers.part(chan, singleParamMsg, ctx).effects,
|
|
209
293
|
);
|
|
210
294
|
case 'TOPIC':
|
|
211
|
-
return this.routeSingleChannel(msg, (chan) =>
|
|
295
|
+
return this.routeSingleChannel(msg, (chan) => this.reducers.topic(chan, msg, ctx).effects);
|
|
212
296
|
case 'NAMES':
|
|
213
|
-
return this.routeSingleChannel(msg, (chan) =>
|
|
297
|
+
return this.routeSingleChannel(msg, (chan) => this.reducers.names(chan, msg, ctx).effects);
|
|
214
298
|
case 'KICK':
|
|
215
|
-
return this.routeSingleChannel(msg, (chan) =>
|
|
299
|
+
return this.routeSingleChannel(msg, (chan) => this.reducers.kick(chan, msg, ctx).effects);
|
|
216
300
|
case 'INVITE':
|
|
217
|
-
return this.routeSingleChannel(msg, (chan) =>
|
|
301
|
+
return this.routeSingleChannel(msg, (chan) => this.reducers.invite(chan, msg, ctx).effects);
|
|
218
302
|
case 'LIST':
|
|
219
303
|
return this.routeList(msg, ctx);
|
|
220
304
|
case 'WHO':
|
|
221
305
|
return this.routeWho(msg, ctx);
|
|
222
306
|
case 'WHOIS':
|
|
223
307
|
return this.routeWhois(msg, ctx);
|
|
308
|
+
case 'CHATHISTORY':
|
|
309
|
+
return this.routeChathistory(msg, ctx);
|
|
224
310
|
|
|
225
311
|
// -------- Target-dependent commands --------
|
|
226
312
|
case 'PRIVMSG':
|
|
227
|
-
return this.routeMessageTarget(
|
|
313
|
+
return this.routeMessageTarget(
|
|
314
|
+
msg,
|
|
315
|
+
this.reducers.privmsgChannel,
|
|
316
|
+
this.reducers.privmsgUser,
|
|
317
|
+
ctx,
|
|
318
|
+
);
|
|
228
319
|
case 'NOTICE':
|
|
229
|
-
return this.routeMessageTarget(
|
|
320
|
+
return this.routeMessageTarget(
|
|
321
|
+
msg,
|
|
322
|
+
this.reducers.noticeChannel,
|
|
323
|
+
this.reducers.noticeUser,
|
|
324
|
+
ctx,
|
|
325
|
+
);
|
|
326
|
+
case 'TAGMSG':
|
|
327
|
+
return this.routeMessageTarget(
|
|
328
|
+
msg,
|
|
329
|
+
this.reducers.tagmsgChannel,
|
|
330
|
+
this.reducers.tagmsgUser,
|
|
331
|
+
ctx,
|
|
332
|
+
);
|
|
230
333
|
case 'MODE':
|
|
231
334
|
return isChannelTarget(msg.params[0] ?? '')
|
|
232
|
-
? this.routeSingleChannel(
|
|
233
|
-
|
|
335
|
+
? this.routeSingleChannel(
|
|
336
|
+
msg,
|
|
337
|
+
(chan) => this.reducers.channelMode(chan, msg, ctx).effects,
|
|
338
|
+
)
|
|
339
|
+
: this.reducers.userMode(this.state, msg, ctx).effects;
|
|
234
340
|
|
|
235
341
|
default:
|
|
236
342
|
return [this.unknownCommandEffect(msg.command)];
|
|
@@ -281,6 +387,41 @@ export class ConnectionActor {
|
|
|
281
387
|
return whoisReducer(target, channels, msg, ctx).effects;
|
|
282
388
|
}
|
|
283
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Routes `CHATHISTORY <sub> <target> [<args>...]` to the chathistory
|
|
392
|
+
* reducer. The command is query-only and must NOT create a channel as a
|
|
393
|
+
* side effect: the target channel (`msg.params[1]`) is resolved via the
|
|
394
|
+
* peek-only {@link ActorChannelAccess.getChannel} when the runtime
|
|
395
|
+
* implements it, so a non-existent channel yields `undefined` → the
|
|
396
|
+
* reducer emits `403 ERR_NOSUCHCHANNEL`. The `TARGETS` subcommand uses a
|
|
397
|
+
* timestamp in that position; `getChannel` simply returns `undefined`
|
|
398
|
+
* for it, which the reducer ignores (TARGETS never reads the channel).
|
|
399
|
+
*
|
|
400
|
+
* Cap-gating, param validation, and batch framing all live in the pure
|
|
401
|
+
* reducer — this method only resolves the channel and delegates.
|
|
402
|
+
*/
|
|
403
|
+
private async routeChathistory(
|
|
404
|
+
msg: IrcMessage,
|
|
405
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
406
|
+
): Promise<EffectType[]> {
|
|
407
|
+
const target = msg.params[1];
|
|
408
|
+
const channel = target !== undefined ? this.peekChannel(target) : undefined;
|
|
409
|
+
return chathistoryReducer(channel, msg, ctx).effects;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Resolves a channel by name WITHOUT creating it. Prefers the
|
|
414
|
+
* peek-only {@link ActorChannelAccess.getChannel}; falls back to
|
|
415
|
+
* {@link ActorChannelAccess.getOrCreateChannel} for runtimes that have
|
|
416
|
+
* not implemented the peek.
|
|
417
|
+
*/
|
|
418
|
+
private peekChannel(name: ChanName): ChannelState | undefined {
|
|
419
|
+
if (this.channels.getChannel !== undefined) {
|
|
420
|
+
return this.channels.getChannel(name);
|
|
421
|
+
}
|
|
422
|
+
return this.channels.getOrCreateChannel(name);
|
|
423
|
+
}
|
|
424
|
+
|
|
284
425
|
private routeJoin(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): EffectType[] {
|
|
285
426
|
if (msg.params[0] === '0') {
|
|
286
427
|
return handleJoinZero(ctx);
|
|
@@ -292,14 +433,14 @@ export class ConnectionActor {
|
|
|
292
433
|
// No channel argument: defer to the reducer so it emits the proper
|
|
293
434
|
// 461 ERR_NEEDMOREPARAMS rather than the actor silently doing nothing.
|
|
294
435
|
const chan = this.channels.getOrCreateChannel('');
|
|
295
|
-
return
|
|
436
|
+
return this.reducers.join(chan, msg, ctx).effects;
|
|
296
437
|
}
|
|
297
438
|
for (const [i, name] of names.entries()) {
|
|
298
439
|
const chan = this.channels.getOrCreateChannel(name);
|
|
299
440
|
const key = keys[i];
|
|
300
441
|
const singleParamMsg: IrcMessage =
|
|
301
442
|
key === undefined ? { ...msg, params: [name] } : { ...msg, params: [name, key] };
|
|
302
|
-
effects.push(...
|
|
443
|
+
effects.push(...this.reducers.join(chan, singleParamMsg, ctx).effects);
|
|
303
444
|
}
|
|
304
445
|
return effects;
|
|
305
446
|
}
|
|
@@ -439,6 +580,7 @@ function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
|
|
|
439
580
|
case 'MODE':
|
|
440
581
|
case 'PRIVMSG':
|
|
441
582
|
case 'NOTICE':
|
|
583
|
+
case 'TAGMSG':
|
|
442
584
|
return splitCsv(msg.params[0] ?? '').filter(isChannelTarget) as ChanName[];
|
|
443
585
|
case 'INVITE':
|
|
444
586
|
// INVITE <nick> <channel> — the channel is the second param.
|