serverless-ircd 0.1.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.
Files changed (165) hide show
  1. package/.github/workflows/ci.yml +47 -0
  2. package/.github/workflows/deploy-cf.yml +97 -0
  3. package/LICENSE +29 -0
  4. package/README.md +323 -0
  5. package/apps/cf-worker/package.json +37 -0
  6. package/apps/cf-worker/scripts/smoke.mjs +175 -0
  7. package/apps/cf-worker/src/worker.ts +59 -0
  8. package/apps/cf-worker/tests/smoke.test.ts +150 -0
  9. package/apps/cf-worker/tsconfig.build.json +17 -0
  10. package/apps/cf-worker/tsconfig.test.json +18 -0
  11. package/apps/cf-worker/vitest.config.ts +31 -0
  12. package/apps/cf-worker/wrangler.test.toml +33 -0
  13. package/apps/cf-worker/wrangler.toml +98 -0
  14. package/apps/local-cli/package.json +35 -0
  15. package/apps/local-cli/src/line-scanner.ts +60 -0
  16. package/apps/local-cli/src/main.ts +145 -0
  17. package/apps/local-cli/src/motd-file.ts +45 -0
  18. package/apps/local-cli/src/server.ts +409 -0
  19. package/apps/local-cli/tests/e2e.test.ts +346 -0
  20. package/apps/local-cli/tests/id-factory.test.ts +25 -0
  21. package/apps/local-cli/tests/line-scanner.test.ts +81 -0
  22. package/apps/local-cli/tests/motd-file.test.ts +71 -0
  23. package/apps/local-cli/tests/tcp.test.ts +358 -0
  24. package/apps/local-cli/tsconfig.build.json +17 -0
  25. package/apps/local-cli/tsconfig.test.json +15 -0
  26. package/apps/local-cli/vitest.config.ts +24 -0
  27. package/biome.json +52 -0
  28. package/package.json +35 -0
  29. package/packages/cf-adapter/package.json +38 -0
  30. package/packages/cf-adapter/src/cf-runtime.ts +308 -0
  31. package/packages/cf-adapter/src/channel-do.ts +374 -0
  32. package/packages/cf-adapter/src/connection-do.ts +542 -0
  33. package/packages/cf-adapter/src/env.ts +67 -0
  34. package/packages/cf-adapter/src/index.ts +38 -0
  35. package/packages/cf-adapter/src/registry-do.ts +130 -0
  36. package/packages/cf-adapter/src/serialize.ts +142 -0
  37. package/packages/cf-adapter/src/sharding.ts +101 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +264 -0
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
  41. package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
  42. package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
  43. package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
  44. package/packages/cf-adapter/tests/integration/harness.ts +102 -0
  45. package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
  46. package/packages/cf-adapter/tests/integration/index.ts +17 -0
  47. package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
  48. package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
  49. package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
  50. package/packages/cf-adapter/tests/sharding.test.ts +100 -0
  51. package/packages/cf-adapter/tests/worker/main.ts +33 -0
  52. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
  53. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
  54. package/packages/cf-adapter/tsconfig.build.json +16 -0
  55. package/packages/cf-adapter/tsconfig.test.json +18 -0
  56. package/packages/cf-adapter/vitest.config.ts +32 -0
  57. package/packages/cf-adapter/wrangler.test.toml +50 -0
  58. package/packages/in-memory-runtime/package.json +29 -0
  59. package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
  60. package/packages/in-memory-runtime/src/index.ts +9 -0
  61. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
  62. package/packages/in-memory-runtime/tsconfig.build.json +16 -0
  63. package/packages/in-memory-runtime/tsconfig.test.json +14 -0
  64. package/packages/in-memory-runtime/vitest.config.ts +21 -0
  65. package/packages/irc-core/package.json +29 -0
  66. package/packages/irc-core/src/caps/capabilities.ts +96 -0
  67. package/packages/irc-core/src/caps/index.ts +1 -0
  68. package/packages/irc-core/src/commands/away.ts +82 -0
  69. package/packages/irc-core/src/commands/cap.ts +257 -0
  70. package/packages/irc-core/src/commands/chghost.ts +30 -0
  71. package/packages/irc-core/src/commands/index.ts +40 -0
  72. package/packages/irc-core/src/commands/invite.ts +156 -0
  73. package/packages/irc-core/src/commands/isupport.ts +133 -0
  74. package/packages/irc-core/src/commands/join.ts +309 -0
  75. package/packages/irc-core/src/commands/kick.ts +162 -0
  76. package/packages/irc-core/src/commands/list.ts +126 -0
  77. package/packages/irc-core/src/commands/mode.ts +655 -0
  78. package/packages/irc-core/src/commands/motd.ts +146 -0
  79. package/packages/irc-core/src/commands/names.ts +164 -0
  80. package/packages/irc-core/src/commands/part.ts +118 -0
  81. package/packages/irc-core/src/commands/ping.ts +47 -0
  82. package/packages/irc-core/src/commands/privmsg.ts +256 -0
  83. package/packages/irc-core/src/commands/quit.ts +70 -0
  84. package/packages/irc-core/src/commands/registration.ts +251 -0
  85. package/packages/irc-core/src/commands/topic.ts +171 -0
  86. package/packages/irc-core/src/commands/who.ts +169 -0
  87. package/packages/irc-core/src/commands/whois.ts +165 -0
  88. package/packages/irc-core/src/effects.ts +184 -0
  89. package/packages/irc-core/src/index.ts +20 -0
  90. package/packages/irc-core/src/ports.ts +153 -0
  91. package/packages/irc-core/src/protocol/batch.ts +85 -0
  92. package/packages/irc-core/src/protocol/index.ts +18 -0
  93. package/packages/irc-core/src/protocol/messages.ts +36 -0
  94. package/packages/irc-core/src/protocol/numerics.ts +155 -0
  95. package/packages/irc-core/src/protocol/outbound.ts +145 -0
  96. package/packages/irc-core/src/protocol/parser.ts +175 -0
  97. package/packages/irc-core/src/protocol/serializer.ts +71 -0
  98. package/packages/irc-core/src/state/channel.ts +293 -0
  99. package/packages/irc-core/src/state/connection.ts +153 -0
  100. package/packages/irc-core/src/state/index.ts +25 -0
  101. package/packages/irc-core/src/state/registry.ts +22 -0
  102. package/packages/irc-core/src/types.ts +83 -0
  103. package/packages/irc-core/tests/batch.test.ts +79 -0
  104. package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
  105. package/packages/irc-core/tests/commands/away.test.ts +233 -0
  106. package/packages/irc-core/tests/commands/batch.test.ts +178 -0
  107. package/packages/irc-core/tests/commands/cap.test.ts +499 -0
  108. package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
  109. package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
  110. package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
  111. package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
  112. package/packages/irc-core/tests/commands/invite.test.ts +321 -0
  113. package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
  114. package/packages/irc-core/tests/commands/join.test.ts +687 -0
  115. package/packages/irc-core/tests/commands/kick.test.ts +316 -0
  116. package/packages/irc-core/tests/commands/list.test.ts +452 -0
  117. package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
  118. package/packages/irc-core/tests/commands/motd.test.ts +322 -0
  119. package/packages/irc-core/tests/commands/names.test.ts +342 -0
  120. package/packages/irc-core/tests/commands/part.test.ts +265 -0
  121. package/packages/irc-core/tests/commands/ping.test.ts +144 -0
  122. package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
  123. package/packages/irc-core/tests/commands/quit.test.ts +220 -0
  124. package/packages/irc-core/tests/commands/registration.test.ts +599 -0
  125. package/packages/irc-core/tests/commands/topic.test.ts +337 -0
  126. package/packages/irc-core/tests/commands/who.test.ts +441 -0
  127. package/packages/irc-core/tests/commands/whois.test.ts +422 -0
  128. package/packages/irc-core/tests/effects.test.ts +147 -0
  129. package/packages/irc-core/tests/message-tags.test.ts +182 -0
  130. package/packages/irc-core/tests/numerics.test.ts +98 -0
  131. package/packages/irc-core/tests/outbound.test.ts +232 -0
  132. package/packages/irc-core/tests/parser.test.ts +162 -0
  133. package/packages/irc-core/tests/ports.test.ts +101 -0
  134. package/packages/irc-core/tests/serializer.test.ts +164 -0
  135. package/packages/irc-core/tests/state/channel.test.ts +351 -0
  136. package/packages/irc-core/tests/state/connection.test.ts +122 -0
  137. package/packages/irc-core/tests/state/registry.test.ts +20 -0
  138. package/packages/irc-core/tests/types.test.ts +127 -0
  139. package/packages/irc-core/tsconfig.build.json +12 -0
  140. package/packages/irc-core/tsconfig.test.json +10 -0
  141. package/packages/irc-core/vitest.config.ts +21 -0
  142. package/packages/irc-server/package.json +28 -0
  143. package/packages/irc-server/src/actor.ts +449 -0
  144. package/packages/irc-server/src/dispatch.ts +120 -0
  145. package/packages/irc-server/src/index.ts +11 -0
  146. package/packages/irc-server/src/runtime.ts +61 -0
  147. package/packages/irc-server/tests/actor.test.ts +816 -0
  148. package/packages/irc-server/tests/dispatch.test.ts +512 -0
  149. package/packages/irc-server/tests/runtime.test.ts +52 -0
  150. package/packages/irc-server/tsconfig.build.json +13 -0
  151. package/packages/irc-server/tsconfig.test.json +11 -0
  152. package/packages/irc-server/vitest.config.ts +21 -0
  153. package/pnpm-workspace.yaml +4 -0
  154. package/tools/tcp-ws-forwarder/package.json +32 -0
  155. package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
  156. package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
  157. package/tools/tcp-ws-forwarder/src/main.ts +114 -0
  158. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
  159. package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
  160. package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
  161. package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
  162. package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
  163. package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
  164. package/tsconfig.base.json +25 -0
  165. package/turbo.json +29 -0
@@ -0,0 +1,25 @@
1
+ export * from './connection.js';
2
+ export {
3
+ type ChannelState,
4
+ type ChannelModes,
5
+ type ChannelTopic,
6
+ type RosterEntry,
7
+ type Roster,
8
+ type ChanName,
9
+ type ChanSnapshot,
10
+ type MembershipDelta,
11
+ type ChannelDelta,
12
+ type ModeChange,
13
+ type BooleanChannelMode,
14
+ type ParameterChannelMode,
15
+ type ChannelModeName,
16
+ type BanMaskChange,
17
+ type CreateChannelOptions,
18
+ emptyModes,
19
+ createChannel,
20
+ prefixOf,
21
+ toSnapshot as toChannelSnapshot,
22
+ emptyChannelDelta,
23
+ applyChannelDelta,
24
+ } from './channel.js';
25
+ export * from './registry.js';
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Nick registry state shape.
3
+ *
4
+ * The Registry entity owns nick uniqueness. PLAN §2.1 puts the
5
+ * collision-check authority here, so reservations are structural rather than
6
+ * optimistic. CF backs this with `RegistryDO`; AWS with conditional puts on
7
+ * the `Nicks` table.
8
+ */
9
+
10
+ import type { ConnId } from './connection.js';
11
+
12
+ /**
13
+ * Mutable authoritative nick registry. Map key is the lowercased nick.
14
+ */
15
+ export interface Registry {
16
+ nicks: Map<string, ConnId>;
17
+ }
18
+
19
+ /** Constructs an empty registry. */
20
+ export function createRegistry(): Registry {
21
+ return { nicks: new Map<string, ConnId>() };
22
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Reducer signature + the per-invocation {@link Ctx}.
3
+ *
4
+ * PLAN §2.1: every command handler is a pure function
5
+ * `(state, msg, ctx) => { state, effects }`.
6
+ * Side effects are values, not performed in the handler.
7
+ */
8
+
9
+ import type { Effect } from './effects.js';
10
+ import type { Clock, IdFactory, MotdProvider } from './ports.js';
11
+ import type { IrcMessage } from './protocol/messages.js';
12
+ import type { ConnectionState } from './state/connection.js';
13
+
14
+ /**
15
+ * Per-deployment server configuration. Adapters load this from a KV store
16
+ * (CF) or Secrets Manager / SSM (AWS); the core only reads it.
17
+ *
18
+ * This is the minimum the Phase 1 reducers need; later tickets (SASL,
19
+ * MOTD provider, channel-prefix policy) extend it.
20
+ */
21
+ export interface ServerConfig {
22
+ serverName: string;
23
+ networkName: string;
24
+ maxChannelsPerUser: number;
25
+ maxTargetsPerCommand: number;
26
+ maxListEntries: number;
27
+ nickLen: number;
28
+ channelLen: number;
29
+ topicLen: number;
30
+ quitMessage: string;
31
+ }
32
+
33
+ /** Per-invocation context handed to every reducer. */
34
+ export interface Ctx {
35
+ readonly serverConfig: ServerConfig;
36
+ /** Convenience: `serverConfig.serverName`. */
37
+ readonly serverName: string;
38
+ /** Convenience: `serverConfig.networkName`. */
39
+ readonly networkName: string;
40
+ readonly clock: Clock;
41
+ readonly ids: IdFactory;
42
+ /** Pre-loaded MOTD source for the `MOTD` reducer. */
43
+ readonly motd: MotdProvider;
44
+ /** The connection invoking the command. Reducers mutate this freely. */
45
+ readonly connection: ConnectionState;
46
+ /** Convenience: `connection.id`. */
47
+ readonly connId: string;
48
+ }
49
+
50
+ /** Result of a single reducer invocation. */
51
+ export interface ReducerResult<S> {
52
+ state: S;
53
+ effects: Effect[];
54
+ }
55
+
56
+ /**
57
+ * Pure command handler. Returns the next state and the side effects the
58
+ * actor layer should perform. Handlers MUST NOT await, throw on input
59
+ * errors (they emit error numerics instead), or read the wall clock.
60
+ */
61
+ export type Reducer<S> = (state: S, msg: IrcMessage, ctx: Ctx) => ReducerResult<S>;
62
+
63
+ export interface BuildCtxOptions {
64
+ serverConfig: ServerConfig;
65
+ clock: Clock;
66
+ ids: IdFactory;
67
+ motd: MotdProvider;
68
+ connection: ConnectionState;
69
+ }
70
+
71
+ /** Constructs a {@link Ctx} from the required parts. */
72
+ export function buildCtx(opts: BuildCtxOptions): Ctx {
73
+ return {
74
+ serverConfig: opts.serverConfig,
75
+ serverName: opts.serverConfig.serverName,
76
+ networkName: opts.serverConfig.networkName,
77
+ clock: opts.clock,
78
+ ids: opts.ids,
79
+ motd: opts.motd,
80
+ connection: opts.connection,
81
+ connId: opts.connection.id,
82
+ };
83
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { RawLine } from '../src/effects';
3
+ import { type BatchFrame, wrapBatch } from '../src/protocol/batch';
4
+
5
+ const L = (text: string): RawLine => ({ text });
6
+
7
+ // ============================================================================
8
+ // wrapBatch — IRCv3 batch framing
9
+ // ============================================================================
10
+
11
+ describe('wrapBatch — framing', () => {
12
+ it('wraps a non-empty line list with BATCH +id / -id markers', () => {
13
+ const lines = [L(':srv 353 alice = #foo :bob'), L(':srv 366 alice #foo :End of /NAMES list.')];
14
+
15
+ const out = wrapBatch(lines, 'abc', 'names');
16
+
17
+ expect(out).toEqual<RawLine[]>([
18
+ L('BATCH +abc names'),
19
+ L(':srv 353 alice = #foo :bob'),
20
+ L(':srv 366 alice #foo :End of /NAMES list.'),
21
+ L('BATCH -abc'),
22
+ ]);
23
+ });
24
+
25
+ it('emits BATCH +id type :args when args are supplied', () => {
26
+ const out = wrapBatch([L(':a JOIN #foo')], 'id1', 'netjoin', '#foo');
27
+
28
+ expect(out[0]?.text).toBe('BATCH +id1 netjoin #foo');
29
+ expect(out[out.length - 1]?.text).toBe('BATCH -id1');
30
+ });
31
+
32
+ it('returns the input unchanged when the line list is empty', () => {
33
+ const out = wrapBatch([], 'abc', 'names');
34
+ expect(out).toEqual<RawLine[]>([]);
35
+ });
36
+
37
+ it('preserves the order of the inner lines', () => {
38
+ const lines = [L('one'), L('two'), L('three'), L('four')];
39
+ const out = wrapBatch(lines, 'x', 'test');
40
+ expect(out.map((l) => l.text)).toEqual([
41
+ 'BATCH +x test',
42
+ 'one',
43
+ 'two',
44
+ 'three',
45
+ 'four',
46
+ 'BATCH -x',
47
+ ]);
48
+ });
49
+
50
+ it('produces a stable, round-trippable BatchFrame (start, body, end)', () => {
51
+ const frame: BatchFrame = wrapBatch([L('inner')], 'id', 'topic');
52
+ expect(frame.id).toBe('id');
53
+ expect(frame.type).toBe('topic');
54
+ expect(frame.start.text).toBe('BATCH +id topic');
55
+ expect(frame.end.text).toBe('BATCH -id');
56
+ expect(frame.body).toHaveLength(1);
57
+ expect(frame.body[0]?.text).toBe('inner');
58
+ });
59
+ });
60
+
61
+ // ============================================================================
62
+ // wrapBatch — nested batches
63
+ // ============================================================================
64
+
65
+ describe('wrapBatch — nested batches', () => {
66
+ it('supports an outer batch wrapping an already-batched inner sequence', () => {
67
+ const inner = wrapBatch([L(':a JOIN #foo'), L(':srv 353 a = #foo :a')], 'inner', 'join');
68
+ const outer = wrapBatch(inner, 'outer', 'netjoin');
69
+
70
+ expect(outer.map((l) => l.text)).toEqual([
71
+ 'BATCH +outer netjoin',
72
+ 'BATCH +inner join',
73
+ ':a JOIN #foo',
74
+ ':srv 353 a = #foo :a',
75
+ 'BATCH -inner',
76
+ 'BATCH -outer',
77
+ ]);
78
+ });
79
+ });
@@ -0,0 +1,148 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ type Capability,
4
+ SUPPORTED_CAPABILITIES,
5
+ SUPPORTED_CAP_NAMES,
6
+ capToLsString,
7
+ getLsString,
8
+ isSupportedCap,
9
+ splitCapsForWire,
10
+ } from '../../src/caps/capabilities';
11
+
12
+ describe('SUPPORTED_CAPABILITIES', () => {
13
+ it('includes every cap from PLAN §3', () => {
14
+ const names = SUPPORTED_CAPABILITIES.map((c) => c.name).sort();
15
+ expect(names).toEqual(
16
+ [
17
+ 'account-tag',
18
+ 'away-notify',
19
+ 'batch',
20
+ 'chghost',
21
+ 'echo-message',
22
+ 'extended-join',
23
+ 'invite-notify',
24
+ 'message-tags',
25
+ 'multi-prefix',
26
+ 'safelist',
27
+ 'sasl',
28
+ 'server-time',
29
+ ].sort(),
30
+ );
31
+ });
32
+
33
+ it('declares the sasl value as the advertised mechanism list', () => {
34
+ const sasl = SUPPORTED_CAPABILITIES.find((c) => c.name === 'sasl');
35
+ expect(sasl).toBeDefined();
36
+ expect(sasl?.value).toBe('PLAIN,EXTERNAL');
37
+ });
38
+
39
+ it('does not attach a value to caps that have no parameter', () => {
40
+ // sasl is the only cap with a value.
41
+ const noValue = SUPPORTED_CAPABILITIES.filter((c) => c.value === undefined);
42
+ expect(noValue.length).toBe(SUPPORTED_CAPABILITIES.length - 1);
43
+ });
44
+
45
+ it('advertises safelist as a bare name (no value parameter)', () => {
46
+ const safelist = SUPPORTED_CAPABILITIES.find((c) => c.name === 'safelist');
47
+ expect(safelist).toBeDefined();
48
+ expect(safelist?.value).toBeUndefined();
49
+ });
50
+ });
51
+
52
+ describe('SUPPORTED_CAP_NAMES', () => {
53
+ it('is a set matching the supported cap names', () => {
54
+ expect(SUPPORTED_CAP_NAMES.size).toBe(SUPPORTED_CAPABILITIES.length);
55
+ for (const cap of SUPPORTED_CAPABILITIES) {
56
+ expect(SUPPORTED_CAP_NAMES.has(cap.name)).toBe(true);
57
+ }
58
+ });
59
+ });
60
+
61
+ describe('isSupportedCap', () => {
62
+ it('returns true for a known cap name', () => {
63
+ expect(isSupportedCap('server-time')).toBe(true);
64
+ });
65
+
66
+ it('returns true for every advertised cap', () => {
67
+ for (const cap of SUPPORTED_CAPABILITIES) {
68
+ expect(isSupportedCap(cap.name)).toBe(true);
69
+ }
70
+ });
71
+
72
+ it('returns true for safelist', () => {
73
+ expect(isSupportedCap('safelist')).toBe(true);
74
+ });
75
+
76
+ it('returns false for an unknown cap name', () => {
77
+ expect(isSupportedCap('does-not-exist')).toBe(false);
78
+ });
79
+
80
+ it('returns false for the empty string', () => {
81
+ expect(isSupportedCap('')).toBe(false);
82
+ });
83
+ });
84
+
85
+ describe('capToLsString', () => {
86
+ it('returns just the name when no value is defined', () => {
87
+ const cap: Capability = { name: 'echo-message' };
88
+ expect(capToLsString(cap)).toBe('echo-message');
89
+ });
90
+
91
+ it('returns name=value when a value is defined', () => {
92
+ const cap: Capability = { name: 'sasl', value: 'PLAIN,EXTERNAL' };
93
+ expect(capToLsString(cap)).toBe('sasl=PLAIN,EXTERNAL');
94
+ });
95
+ });
96
+
97
+ describe('getLsString', () => {
98
+ it('joins every supported cap with a single space', () => {
99
+ const ls = getLsString();
100
+ const tokens = ls.split(' ');
101
+ expect(tokens.length).toBe(SUPPORTED_CAPABILITIES.length);
102
+ });
103
+
104
+ it('contains the sasl token with its value', () => {
105
+ expect(getLsString()).toContain('sasl=PLAIN,EXTERNAL');
106
+ });
107
+
108
+ it('contains server-time as a bare name', () => {
109
+ expect(getLsString()).toContain('server-time');
110
+ expect(getLsString()).not.toContain('server-time=');
111
+ });
112
+ });
113
+
114
+ describe('splitCapsForWire', () => {
115
+ it('returns a single chunk when everything fits', () => {
116
+ const tokens = ['a', 'b', 'c'];
117
+ expect(splitCapsForWire(tokens, 100)).toEqual(['a b c']);
118
+ });
119
+
120
+ it('returns an array containing an empty string when there are no tokens', () => {
121
+ expect(splitCapsForWire([], 100)).toEqual(['']);
122
+ });
123
+
124
+ it('splits tokens across multiple lines when they exceed the per-line budget', () => {
125
+ const tokens = ['aaaa', 'bbbb', 'cccc'];
126
+ // Budget is 9: "aaaa bbbb" is 9 bytes; "cccc" overflows to a new line.
127
+ const chunks = splitCapsForWire(tokens, 9);
128
+ expect(chunks).toEqual(['aaaa bbbb', 'cccc']);
129
+ });
130
+
131
+ it('starts a new line when a single token would overflow the current line', () => {
132
+ const tokens = ['short', 'verylongtoken'];
133
+ const chunks = splitCapsForWire(tokens, 10);
134
+ expect(chunks).toEqual(['short', 'verylongtoken']);
135
+ });
136
+
137
+ it('emits each over-long token on its own line when it alone exceeds the budget', () => {
138
+ const tokens = ['toolongfortoFit', 'next'];
139
+ const chunks = splitCapsForWire(tokens, 5);
140
+ expect(chunks).toEqual(['toolongfortoFit', 'next']);
141
+ });
142
+
143
+ it('packs greedily across many tokens', () => {
144
+ const tokens = ['x', 'y', 'z', 'w'];
145
+ const chunks = splitCapsForWire(tokens, 3);
146
+ expect(chunks).toEqual(['x y', 'z w']);
147
+ });
148
+ });
@@ -0,0 +1,233 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { awayReducer } from '../../src/commands/away';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
6
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
7
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
8
+
9
+ const serverConfig: ServerConfig = {
10
+ serverName: 'irc.example.com',
11
+ networkName: 'ExampleNet',
12
+ maxChannelsPerUser: 30,
13
+ maxTargetsPerCommand: 10,
14
+ maxListEntries: 50,
15
+ nickLen: 30,
16
+ channelLen: 50,
17
+ topicLen: 390,
18
+ quitMessage: 'Client Quit',
19
+ };
20
+
21
+ function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {
22
+ return buildCtx({
23
+ serverConfig,
24
+ clock,
25
+ ids: new SequentialIdFactory(),
26
+ motd: EmptyMotdProvider,
27
+ connection: conn,
28
+ });
29
+ }
30
+
31
+ function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
32
+ const s = createConnection({ id, connectedSince: 0 });
33
+ s.nick = nick;
34
+ s.user = nick;
35
+ s.host = 'example.com';
36
+ s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);
37
+ s.registration = 'registered';
38
+ return s;
39
+ }
40
+
41
+ const L = (text: string): RawLine => ({ text });
42
+
43
+ // ============================================================================
44
+ // awayReducer — setting AWAY
45
+ // ============================================================================
46
+
47
+ describe('awayReducer — setting away', () => {
48
+ it('emits 306 RPL_NOWAWAY to the sender when a reason is given (no away-notify peers)', () => {
49
+ const conn = makeConn();
50
+ const ctx = makeCtx(conn);
51
+
52
+ const out = awayReducer(conn, { command: 'AWAY', params: ['gone fishing'], tags: {} }, ctx);
53
+
54
+ expect(out.effects).toEqual<EffectType[]>([
55
+ Effect.send('c1', [L(':irc.example.com 306 alice :You have been marked as being away')]),
56
+ ]);
57
+ expect(conn.away).toBe('gone fishing');
58
+ });
59
+
60
+ it('unsets away when no parameter is given, emitting 305 RPL_UNAWAY', () => {
61
+ const conn = makeConn();
62
+ conn.away = 'old reason';
63
+ const ctx = makeCtx(conn);
64
+
65
+ const out = awayReducer(conn, { command: 'AWAY', params: [], tags: {} }, ctx);
66
+
67
+ expect(out.effects).toEqual<EffectType[]>([
68
+ Effect.send('c1', [L(':irc.example.com 305 alice :You are no longer marked as being away')]),
69
+ ]);
70
+ expect(conn.away).toBeUndefined();
71
+ });
72
+
73
+ it('unsets away when the parameter is empty, emitting 305 RPL_UNAWAY', () => {
74
+ const conn = makeConn();
75
+ conn.away = 'old reason';
76
+ const ctx = makeCtx(conn);
77
+
78
+ const out = awayReducer(conn, { command: 'AWAY', params: [''], tags: {} }, ctx);
79
+
80
+ expect(out.effects).toEqual<EffectType[]>([
81
+ Effect.send('c1', [L(':irc.example.com 305 alice :You are no longer marked as being away')]),
82
+ ]);
83
+ expect(conn.away).toBeUndefined();
84
+ });
85
+
86
+ it('overwrites an existing away reason when a new reason is given', () => {
87
+ const conn = makeConn();
88
+ conn.away = 'old';
89
+ const ctx = makeCtx(conn);
90
+
91
+ awayReducer(conn, { command: 'AWAY', params: ['new'], tags: {} }, ctx);
92
+
93
+ expect(conn.away).toBe('new');
94
+ });
95
+
96
+ it('updates lastSeen to ctx.clock.now()', () => {
97
+ const conn = makeConn();
98
+ const clock = new FakeClock(7_700);
99
+ const ctx = makeCtx(conn, clock);
100
+
101
+ awayReducer(conn, { command: 'AWAY', params: ['x'], tags: {} }, ctx);
102
+
103
+ expect(conn.lastSeen).toBe(7_700);
104
+ });
105
+ });
106
+
107
+ // ============================================================================
108
+ // awayReducer — away-notify cap fanout
109
+ // ============================================================================
110
+
111
+ describe('awayReducer — away-notify cap fanout', () => {
112
+ it('emits an AWAY broadcast with the reason to cap-enabled peers in shared channels', () => {
113
+ const conn = makeConn();
114
+ conn.joinedChannels.add('#foo');
115
+ conn.joinedChannels.add('#bar');
116
+ const ctx = makeCtx(conn);
117
+
118
+ const out = awayReducer(conn, { command: 'AWAY', params: ['gone'], tags: {} }, ctx);
119
+
120
+ // Two cap-only broadcasts (one per shared channel), plus the 306 numeric.
121
+ const broadcasts = out.effects.filter(
122
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
123
+ );
124
+ expect(broadcasts).toHaveLength(2);
125
+ expect(broadcasts[0]?.cap).toBe('away-notify');
126
+ expect(broadcasts[0]?.lines).toEqual<RawLine[]>([L(':alice!alice@example.com AWAY :gone')]);
127
+ expect(broadcasts[0]?.capLines).toBeUndefined();
128
+ expect(broadcasts[1]?.lines).toEqual<RawLine[]>([L(':alice!alice@example.com AWAY :gone')]);
129
+ });
130
+
131
+ it('emits an AWAY broadcast with no trailing param when unsetting (away-notify)', () => {
132
+ const conn = makeConn();
133
+ conn.joinedChannels.add('#foo');
134
+ conn.away = 'previously away';
135
+ const ctx = makeCtx(conn);
136
+
137
+ const out = awayReducer(conn, { command: 'AWAY', params: [], tags: {} }, ctx);
138
+
139
+ const broadcasts = out.effects.filter(
140
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
141
+ );
142
+ expect(broadcasts).toHaveLength(1);
143
+ expect(broadcasts[0]?.lines).toEqual<RawLine[]>([L(':alice!alice@example.com AWAY')]);
144
+ expect(broadcasts[0]?.cap).toBe('away-notify');
145
+ });
146
+
147
+ it('emits no broadcast when the user has no joined channels (away set)', () => {
148
+ const conn = makeConn();
149
+ const ctx = makeCtx(conn);
150
+
151
+ const out = awayReducer(conn, { command: 'AWAY', params: ['gone'], tags: {} }, ctx);
152
+
153
+ const broadcasts = out.effects.filter(
154
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
155
+ );
156
+ expect(broadcasts).toHaveLength(0);
157
+ // The numeric is still emitted.
158
+ expect(out.effects).toHaveLength(1);
159
+ expect(out.effects[0]?.tag).toBe('Send');
160
+ });
161
+
162
+ it('places the AWAY broadcast AFTER the numeric reply', () => {
163
+ const conn = makeConn();
164
+ conn.joinedChannels.add('#foo');
165
+ const ctx = makeCtx(conn);
166
+
167
+ const out = awayReducer(conn, { command: 'AWAY', params: ['gone'], tags: {} }, ctx);
168
+
169
+ expect(out.effects[0]?.tag).toBe('Send');
170
+ expect(out.effects[1]?.tag).toBe('Broadcast');
171
+ });
172
+
173
+ it('uses cap-only broadcast so non-cap peers receive nothing', () => {
174
+ const conn = makeConn();
175
+ conn.joinedChannels.add('#foo');
176
+ const ctx = makeCtx(conn);
177
+
178
+ const out = awayReducer(conn, { command: 'AWAY', params: ['gone'], tags: {} }, ctx);
179
+
180
+ const broadcast = out.effects.find(
181
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
182
+ );
183
+ expect(broadcast?.cap).toBe('away-notify');
184
+ expect(broadcast?.capLines).toBeUndefined();
185
+ });
186
+ });
187
+
188
+ // ============================================================================
189
+ // awayReducer — defensive paths
190
+ // ============================================================================
191
+
192
+ describe('awayReducer — defensive', () => {
193
+ it('uses * in the numeric reply when the connection has no nick', () => {
194
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
195
+ conn.registration = 'registered';
196
+ const ctx = makeCtx(conn);
197
+
198
+ const out = awayReducer(conn, { command: 'AWAY', params: ['gone'], tags: {} }, ctx);
199
+
200
+ expect(out.effects).toEqual<EffectType[]>([
201
+ Effect.send('c1', [L(':irc.example.com 306 * :You have been marked as being away')]),
202
+ ]);
203
+ });
204
+
205
+ it('falls back to nick-only hostmask when user/host are absent', () => {
206
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
207
+ conn.nick = 'alice';
208
+ conn.registration = 'registered';
209
+ conn.joinedChannels.add('#foo');
210
+ const ctx = makeCtx(conn);
211
+
212
+ const out = awayReducer(conn, { command: 'AWAY', params: ['gone'], tags: {} }, ctx);
213
+
214
+ const broadcast = out.effects.find(
215
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
216
+ );
217
+ expect(broadcast?.lines[0]?.text).toBe(':alice AWAY :gone');
218
+ });
219
+
220
+ it('falls back to ?-prefixed source when nick is also absent', () => {
221
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
222
+ conn.registration = 'registered';
223
+ conn.joinedChannels.add('#foo');
224
+ const ctx = makeCtx(conn);
225
+
226
+ const out = awayReducer(conn, { command: 'AWAY', params: ['gone'], tags: {} }, ctx);
227
+
228
+ const broadcast = out.effects.find(
229
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
230
+ );
231
+ expect(broadcast?.lines[0]?.text).toBe(':? AWAY :gone');
232
+ });
233
+ });