serverless-ircd 0.1.0 → 0.2.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/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- 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/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -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 +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -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 +67 -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/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 +158 -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 +427 -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/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- 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/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -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/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 +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -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 +66 -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/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -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/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -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 +162 -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 +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -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 +1 -1
- 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/progress.md +107 -0
- package/tickets.md +2485 -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/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/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -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;
|
|
@@ -35,39 +35,25 @@ import {
|
|
|
35
35
|
EmptyMotdProvider,
|
|
36
36
|
type IdFactory,
|
|
37
37
|
type IrcMessage,
|
|
38
|
+
type Logger,
|
|
39
|
+
type MessageStore,
|
|
38
40
|
type MotdProvider,
|
|
41
|
+
NoopLoggerInstance,
|
|
39
42
|
Numerics,
|
|
40
43
|
type RawLine,
|
|
41
44
|
type ServerConfig,
|
|
42
45
|
buildCtx,
|
|
43
|
-
|
|
44
|
-
channelModeReducer,
|
|
46
|
+
chathistoryReducer,
|
|
45
47
|
handleJoinZero,
|
|
46
|
-
inviteReducer,
|
|
47
48
|
isChannelTarget,
|
|
48
|
-
joinReducer,
|
|
49
|
-
kickReducer,
|
|
50
49
|
listReducer,
|
|
51
|
-
motdReducer,
|
|
52
|
-
namesReducer,
|
|
53
|
-
nickReducer,
|
|
54
|
-
noticeChannelReducer,
|
|
55
|
-
noticeUserReducer,
|
|
56
50
|
parse,
|
|
57
|
-
partReducer,
|
|
58
|
-
passReducer,
|
|
59
|
-
pingReducer,
|
|
60
|
-
pongReducer,
|
|
61
|
-
privmsgChannelReducer,
|
|
62
|
-
privmsgUserReducer,
|
|
63
|
-
quitReducer,
|
|
64
|
-
topicReducer,
|
|
65
|
-
userModeReducer,
|
|
66
|
-
userReducer,
|
|
67
51
|
whoReducer,
|
|
68
52
|
whoisReducer,
|
|
69
53
|
} from '@serverless-ircd/irc-core';
|
|
70
54
|
import { dispatch } from './dispatch.js';
|
|
55
|
+
import type { DispatchOptions } from './dispatch.js';
|
|
56
|
+
import { type RoutedReducers, buildRoutedReducers } from './routing.js';
|
|
71
57
|
import type { IrcRuntime } from './runtime.js';
|
|
72
58
|
|
|
73
59
|
/**
|
|
@@ -87,6 +73,16 @@ import type { IrcRuntime } from './runtime.js';
|
|
|
87
73
|
*/
|
|
88
74
|
export interface ActorChannelAccess {
|
|
89
75
|
getOrCreateChannel(name: ChanName): ChannelState;
|
|
76
|
+
/**
|
|
77
|
+
* Peek-only channel lookup that does NOT create the channel as a side
|
|
78
|
+
* effect (unlike {@link getOrCreateChannel}). Returns the authoritative
|
|
79
|
+
* {@link ChannelState} when the channel exists, or `undefined` otherwise.
|
|
80
|
+
*
|
|
81
|
+
* Used by query-only commands (CHATHISTORY) that must distinguish "no
|
|
82
|
+
* such channel" from an empty one. Optional: runtimes that have not
|
|
83
|
+
* implemented it fall back to {@link getOrCreateChannel} in the actor.
|
|
84
|
+
*/
|
|
85
|
+
getChannel?(name: ChanName): ChannelState | undefined;
|
|
90
86
|
refreshChannel?(name: ChanName): Promise<void>;
|
|
91
87
|
}
|
|
92
88
|
|
|
@@ -98,6 +94,28 @@ export interface ConnectionActorOptions {
|
|
|
98
94
|
clock: Clock;
|
|
99
95
|
ids: IdFactory;
|
|
100
96
|
motd?: MotdProvider;
|
|
97
|
+
/**
|
|
98
|
+
* Chat-history persistence source (absent when chathistory playback is
|
|
99
|
+
* disabled). When bound, `ctx.messages` is defined so the CHATHISTORY
|
|
100
|
+
* reducer can query backlog and the PRIVMSG/NOTICE channel reducer
|
|
101
|
+
* records accepted messages. Omitted (→ `ctx.messages` undefined)
|
|
102
|
+
* preserves the no-store behaviour for deployments/tests that do not
|
|
103
|
+
* opt in.
|
|
104
|
+
*/
|
|
105
|
+
readonly messages?: MessageStore;
|
|
106
|
+
/**
|
|
107
|
+
* Structured-logger port. When omitted the actor runs
|
|
108
|
+
* silently — observability is purely opt-in. When supplied, every
|
|
109
|
+
* frame.receive / frame.parse-error / dispatch record carries the
|
|
110
|
+
* actor's bound `traceId` and `connectionId`.
|
|
111
|
+
*/
|
|
112
|
+
readonly logger?: Logger;
|
|
113
|
+
/**
|
|
114
|
+
* Per-request trace id. When omitted the actor allocates
|
|
115
|
+
* one fresh per inbound frame via `ids.traceId()` so every frame is
|
|
116
|
+
* still uniquely identifiable in logs.
|
|
117
|
+
*/
|
|
118
|
+
readonly traceId?: string;
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
export class ConnectionActor {
|
|
@@ -108,6 +126,20 @@ export class ConnectionActor {
|
|
|
108
126
|
private readonly clock: Clock;
|
|
109
127
|
private readonly ids: IdFactory;
|
|
110
128
|
private readonly motd: MotdProvider;
|
|
129
|
+
private readonly messages: MessageStore | undefined;
|
|
130
|
+
private readonly reducers: RoutedReducers;
|
|
131
|
+
/**
|
|
132
|
+
* Bound logger. Defaults to a no-op so the actor pipeline has no
|
|
133
|
+
* observable side effects when callers do not opt in.
|
|
134
|
+
*/
|
|
135
|
+
private readonly logger: Logger;
|
|
136
|
+
/**
|
|
137
|
+
* Pinned trace id. When the caller did not supply one the actor generates
|
|
138
|
+
* a fresh one per inbound frame via {@link IdFactory.traceId}; this pinned
|
|
139
|
+
* value is only used in that latter case as a fallback for code paths
|
|
140
|
+
* that need a non-undefined id in test assertions.
|
|
141
|
+
*/
|
|
142
|
+
private readonly pinnedTraceId: string | undefined;
|
|
111
143
|
|
|
112
144
|
constructor(opts: ConnectionActorOptions) {
|
|
113
145
|
this.state = opts.state;
|
|
@@ -117,6 +149,20 @@ export class ConnectionActor {
|
|
|
117
149
|
this.clock = opts.clock;
|
|
118
150
|
this.ids = opts.ids;
|
|
119
151
|
this.motd = opts.motd ?? EmptyMotdProvider;
|
|
152
|
+
this.messages = opts.messages;
|
|
153
|
+
this.logger = opts.logger ?? NoopLoggerInstance;
|
|
154
|
+
this.pinnedTraceId = opts.traceId;
|
|
155
|
+
// Bind the connection id once so every record the actor emits carries
|
|
156
|
+
// the cross-cutting connection identifier. The child inherits traceId
|
|
157
|
+
// and rebinds per-frame; connectionId stays constant for the actor's
|
|
158
|
+
// lifetime.
|
|
159
|
+
if (opts.logger !== undefined) {
|
|
160
|
+
this.logger = opts.logger.child({ connectionId: this.state.id });
|
|
161
|
+
}
|
|
162
|
+
// Build once: every routed reducer is flood-control-wrapped (or the raw
|
|
163
|
+
// reducer when flood control is disabled) so a flooding client is bounded
|
|
164
|
+
// regardless of which authority owns the command's state.
|
|
165
|
+
this.reducers = buildRoutedReducers(opts.serverConfig);
|
|
120
166
|
}
|
|
121
167
|
|
|
122
168
|
/**
|
|
@@ -125,13 +171,17 @@ export class ConnectionActor {
|
|
|
125
171
|
* one line emits a `421` and processing continues with the next line.
|
|
126
172
|
*/
|
|
127
173
|
async receiveTextFrame(text: string): Promise<void> {
|
|
128
|
-
|
|
174
|
+
const lines = splitFrameLines(text);
|
|
175
|
+
if (lines.length === 0) return;
|
|
176
|
+
const traceId = this.pinnedTraceId ?? this.ids.traceId();
|
|
177
|
+
const log = this.logger.child({ traceId });
|
|
178
|
+
for (const line of lines) {
|
|
129
179
|
if (line.length === 0) continue;
|
|
130
|
-
await this.processLine(line);
|
|
180
|
+
await this.processLine(line, log, traceId);
|
|
131
181
|
}
|
|
132
182
|
}
|
|
133
183
|
|
|
134
|
-
private async processLine(line: string): Promise<void> {
|
|
184
|
+
private async processLine(line: string, log: Logger, traceId: string): Promise<void> {
|
|
135
185
|
let msg: IrcMessage;
|
|
136
186
|
let parseFailed = false;
|
|
137
187
|
try {
|
|
@@ -145,17 +195,30 @@ export class ConnectionActor {
|
|
|
145
195
|
msg = { tags: {}, command: '', params: [] };
|
|
146
196
|
}
|
|
147
197
|
if (parseFailed) {
|
|
198
|
+
log.warn('frame.parse-error', {
|
|
199
|
+
line,
|
|
200
|
+
token: firstToken(line),
|
|
201
|
+
});
|
|
148
202
|
const token = firstToken(line);
|
|
149
|
-
await dispatch(
|
|
203
|
+
await dispatch(
|
|
204
|
+
[this.unknownCommandEffect(token)],
|
|
205
|
+
this.runtime,
|
|
206
|
+
this.dispatchOpts(log, traceId),
|
|
207
|
+
);
|
|
150
208
|
return;
|
|
151
209
|
}
|
|
210
|
+
log.debug('frame.receive', { command: msg.command, lines: 1 });
|
|
152
211
|
// Pull authoritative channel state for any channel the upcoming reducer
|
|
153
212
|
// will read. In-memory runtimes no-op; distributed runtimes fetch from
|
|
154
213
|
// the channel authority (e.g. CF ChannelDO). Done before `route` so the
|
|
155
214
|
// synchronous reducers observe up-to-date rosters / modes / topics.
|
|
156
215
|
await this.prefetchChannelState(msg);
|
|
157
216
|
const effects = await this.route(msg);
|
|
158
|
-
await dispatch(effects, this.runtime);
|
|
217
|
+
await dispatch(effects, this.runtime, this.dispatchOpts(log, traceId));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private dispatchOpts(log: Logger, traceId: string): DispatchOptions {
|
|
221
|
+
return { logger: log, traceId, connectionId: this.state.id };
|
|
159
222
|
}
|
|
160
223
|
|
|
161
224
|
/**
|
|
@@ -178,26 +241,31 @@ export class ConnectionActor {
|
|
|
178
241
|
ids: this.ids,
|
|
179
242
|
motd: this.motd,
|
|
180
243
|
connection: this.state,
|
|
244
|
+
...(this.messages !== undefined ? { messages: this.messages } : {}),
|
|
181
245
|
});
|
|
182
246
|
|
|
183
247
|
switch (msg.command) {
|
|
184
248
|
// -------- Connection-authority commands --------
|
|
185
249
|
case 'PING':
|
|
186
|
-
return
|
|
250
|
+
return this.reducers.ping(this.state, msg, ctx).effects;
|
|
187
251
|
case 'PONG':
|
|
188
|
-
return
|
|
252
|
+
return this.reducers.pong(this.state, msg, ctx).effects;
|
|
189
253
|
case 'NICK':
|
|
190
|
-
return
|
|
254
|
+
return this.reducers.nick(this.state, msg, ctx).effects;
|
|
191
255
|
case 'USER':
|
|
192
|
-
return
|
|
256
|
+
return this.reducers.user(this.state, msg, ctx).effects;
|
|
193
257
|
case 'PASS':
|
|
194
|
-
return
|
|
258
|
+
return this.reducers.pass(this.state, msg, ctx).effects;
|
|
195
259
|
case 'QUIT':
|
|
196
|
-
return
|
|
260
|
+
return this.reducers.quit(this.state, msg, ctx).effects;
|
|
197
261
|
case 'MOTD':
|
|
198
|
-
return
|
|
262
|
+
return this.reducers.motd(this.state, msg, ctx).effects;
|
|
199
263
|
case 'CAP':
|
|
200
|
-
return
|
|
264
|
+
return this.reducers.cap(this.state, msg, ctx).effects;
|
|
265
|
+
case 'AUTHENTICATE':
|
|
266
|
+
return this.reducers.authenticate(this.state, msg, ctx).effects;
|
|
267
|
+
case 'AWAY':
|
|
268
|
+
return this.reducers.away(this.state, msg, ctx).effects;
|
|
201
269
|
|
|
202
270
|
// -------- Channel-authority commands --------
|
|
203
271
|
case 'JOIN':
|
|
@@ -205,32 +273,47 @@ export class ConnectionActor {
|
|
|
205
273
|
case 'PART':
|
|
206
274
|
return this.routeMultiChannel(
|
|
207
275
|
msg,
|
|
208
|
-
(chan, singleParamMsg) =>
|
|
276
|
+
(chan, singleParamMsg) => this.reducers.part(chan, singleParamMsg, ctx).effects,
|
|
209
277
|
);
|
|
210
278
|
case 'TOPIC':
|
|
211
|
-
return this.routeSingleChannel(msg, (chan) =>
|
|
279
|
+
return this.routeSingleChannel(msg, (chan) => this.reducers.topic(chan, msg, ctx).effects);
|
|
212
280
|
case 'NAMES':
|
|
213
|
-
return this.routeSingleChannel(msg, (chan) =>
|
|
281
|
+
return this.routeSingleChannel(msg, (chan) => this.reducers.names(chan, msg, ctx).effects);
|
|
214
282
|
case 'KICK':
|
|
215
|
-
return this.routeSingleChannel(msg, (chan) =>
|
|
283
|
+
return this.routeSingleChannel(msg, (chan) => this.reducers.kick(chan, msg, ctx).effects);
|
|
216
284
|
case 'INVITE':
|
|
217
|
-
return this.routeSingleChannel(msg, (chan) =>
|
|
285
|
+
return this.routeSingleChannel(msg, (chan) => this.reducers.invite(chan, msg, ctx).effects);
|
|
218
286
|
case 'LIST':
|
|
219
287
|
return this.routeList(msg, ctx);
|
|
220
288
|
case 'WHO':
|
|
221
289
|
return this.routeWho(msg, ctx);
|
|
222
290
|
case 'WHOIS':
|
|
223
291
|
return this.routeWhois(msg, ctx);
|
|
292
|
+
case 'CHATHISTORY':
|
|
293
|
+
return this.routeChathistory(msg, ctx);
|
|
224
294
|
|
|
225
295
|
// -------- Target-dependent commands --------
|
|
226
296
|
case 'PRIVMSG':
|
|
227
|
-
return this.routeMessageTarget(
|
|
297
|
+
return this.routeMessageTarget(
|
|
298
|
+
msg,
|
|
299
|
+
this.reducers.privmsgChannel,
|
|
300
|
+
this.reducers.privmsgUser,
|
|
301
|
+
ctx,
|
|
302
|
+
);
|
|
228
303
|
case 'NOTICE':
|
|
229
|
-
return this.routeMessageTarget(
|
|
304
|
+
return this.routeMessageTarget(
|
|
305
|
+
msg,
|
|
306
|
+
this.reducers.noticeChannel,
|
|
307
|
+
this.reducers.noticeUser,
|
|
308
|
+
ctx,
|
|
309
|
+
);
|
|
230
310
|
case 'MODE':
|
|
231
311
|
return isChannelTarget(msg.params[0] ?? '')
|
|
232
|
-
? this.routeSingleChannel(
|
|
233
|
-
|
|
312
|
+
? this.routeSingleChannel(
|
|
313
|
+
msg,
|
|
314
|
+
(chan) => this.reducers.channelMode(chan, msg, ctx).effects,
|
|
315
|
+
)
|
|
316
|
+
: this.reducers.userMode(this.state, msg, ctx).effects;
|
|
234
317
|
|
|
235
318
|
default:
|
|
236
319
|
return [this.unknownCommandEffect(msg.command)];
|
|
@@ -281,6 +364,41 @@ export class ConnectionActor {
|
|
|
281
364
|
return whoisReducer(target, channels, msg, ctx).effects;
|
|
282
365
|
}
|
|
283
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Routes `CHATHISTORY <sub> <target> [<args>...]` to the chathistory
|
|
369
|
+
* reducer. The command is query-only and must NOT create a channel as a
|
|
370
|
+
* side effect: the target channel (`msg.params[1]`) is resolved via the
|
|
371
|
+
* peek-only {@link ActorChannelAccess.getChannel} when the runtime
|
|
372
|
+
* implements it, so a non-existent channel yields `undefined` → the
|
|
373
|
+
* reducer emits `403 ERR_NOSUCHCHANNEL`. The `TARGETS` subcommand uses a
|
|
374
|
+
* timestamp in that position; `getChannel` simply returns `undefined`
|
|
375
|
+
* for it, which the reducer ignores (TARGETS never reads the channel).
|
|
376
|
+
*
|
|
377
|
+
* Cap-gating, param validation, and batch framing all live in the pure
|
|
378
|
+
* reducer — this method only resolves the channel and delegates.
|
|
379
|
+
*/
|
|
380
|
+
private async routeChathistory(
|
|
381
|
+
msg: IrcMessage,
|
|
382
|
+
ctx: ReturnType<typeof buildCtx>,
|
|
383
|
+
): Promise<EffectType[]> {
|
|
384
|
+
const target = msg.params[1];
|
|
385
|
+
const channel = target !== undefined ? this.peekChannel(target) : undefined;
|
|
386
|
+
return chathistoryReducer(channel, msg, ctx).effects;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Resolves a channel by name WITHOUT creating it. Prefers the
|
|
391
|
+
* peek-only {@link ActorChannelAccess.getChannel}; falls back to
|
|
392
|
+
* {@link ActorChannelAccess.getOrCreateChannel} for runtimes that have
|
|
393
|
+
* not implemented the peek.
|
|
394
|
+
*/
|
|
395
|
+
private peekChannel(name: ChanName): ChannelState | undefined {
|
|
396
|
+
if (this.channels.getChannel !== undefined) {
|
|
397
|
+
return this.channels.getChannel(name);
|
|
398
|
+
}
|
|
399
|
+
return this.channels.getOrCreateChannel(name);
|
|
400
|
+
}
|
|
401
|
+
|
|
284
402
|
private routeJoin(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): EffectType[] {
|
|
285
403
|
if (msg.params[0] === '0') {
|
|
286
404
|
return handleJoinZero(ctx);
|
|
@@ -292,14 +410,14 @@ export class ConnectionActor {
|
|
|
292
410
|
// No channel argument: defer to the reducer so it emits the proper
|
|
293
411
|
// 461 ERR_NEEDMOREPARAMS rather than the actor silently doing nothing.
|
|
294
412
|
const chan = this.channels.getOrCreateChannel('');
|
|
295
|
-
return
|
|
413
|
+
return this.reducers.join(chan, msg, ctx).effects;
|
|
296
414
|
}
|
|
297
415
|
for (const [i, name] of names.entries()) {
|
|
298
416
|
const chan = this.channels.getOrCreateChannel(name);
|
|
299
417
|
const key = keys[i];
|
|
300
418
|
const singleParamMsg: IrcMessage =
|
|
301
419
|
key === undefined ? { ...msg, params: [name] } : { ...msg, params: [name, key] };
|
|
302
|
-
effects.push(...
|
|
420
|
+
effects.push(...this.reducers.join(chan, singleParamMsg, ctx).effects);
|
|
303
421
|
}
|
|
304
422
|
return effects;
|
|
305
423
|
}
|