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,130 @@
1
+ /**
2
+ * `RegistryDO` — Cloudflare Durable Object owning a slice of the
3
+ * nickname registry.
4
+ *
5
+ * PLAN §6.1 / §4 — the global nickname registry is partitioned across N
6
+ * Durable Object instances. Each instance owns a disjoint slice of the
7
+ * `lowercased(nick) → connectionId` map and is the sole authority for
8
+ * that slice. The fleet is addressed by `shardNick(nick, N)`: two
9
+ * clients racing on the same nick always land in the same shard, where
10
+ * the DO's single-threaded storage transaction gives the uniqueness
11
+ * invariant for free — no conditional writes, no optimistic retries.
12
+ *
13
+ * Storage layout (per-shard):
14
+ * - One key per registered nick: `nick:<lower>` → `connectionId`
15
+ *
16
+ * Per-nick keys (rather than a single `Map` blob) keep storage
17
+ * proportional to the slice's live nick count and let individual
18
+ * reads/writes stay O(1) regardless of fleet size. Atomicity across
19
+ * `reserveNick` / `changeNick` is provided by
20
+ * {@link DurableObjectState["storage"]["transaction"]}: the
21
+ * read-modify-write inside a transaction is serial — concurrent
22
+ * reservations for the same nick serialize, so the first writer commits
23
+ * and the second sees the new owner and fails.
24
+ *
25
+ * RPC surface — implements {@link RegistryRpc}, the same shape the
26
+ * stub `RecordingRegistryDO` exposed for 033. Consumers
27
+ * (`CfRuntime`, 036) call these methods via `env.REGISTRY_DO`
28
+ * after computing the shard key with {@link registryKeyForNick}.
29
+ */
30
+
31
+ import { DurableObject } from 'cloudflare:workers';
32
+ import type { RegistryRpc } from './env.js';
33
+
34
+ /** Storage key prefix for per-nick entries. */
35
+ const NICK_KEY_PREFIX = 'nick:';
36
+
37
+ /** Returns the storage key for `nickLower`. */
38
+ function nickKey(nickLower: string): string {
39
+ return `${NICK_KEY_PREFIX}${nickLower}`;
40
+ }
41
+
42
+ /**
43
+ * Per-shard registry authority.
44
+ *
45
+ * One Durable Object instance holds the nick→connectionId mappings for
46
+ * every nick whose {@link shardNick} lands on this shard. The class is
47
+ * exported as a binding target in `wrangler.toml`; the Workers runtime
48
+ * instantiates it with `(ctx, env)` per DO id.
49
+ */
50
+ export class RegistryDO extends DurableObject implements RegistryRpc {
51
+ /**
52
+ * Reserves `nick` for `conn`. Semantics:
53
+ * - **Fresh nick:** stores the mapping, returns `{ ok: true }`.
54
+ * - **Already held by `conn`:** idempotent no-op, returns
55
+ * `{ ok: true }`. (Same client re-sending `NICK` during
56
+ * registration must not fail its own reservation.)
57
+ * - **Held by a different conn:** returns `{ ok: false }`.
58
+ *
59
+ * Atomicity is provided by `state.storage.transaction`. Two
60
+ * concurrent `reserveNick` calls for the same nick serialize: the
61
+ * first commits its put, the second reads the new owner and rejects.
62
+ */
63
+ async reserveNick(nick: string, conn: string): Promise<{ ok: true } | { ok: false }> {
64
+ return this.ctx.storage.transaction(async (tx) => {
65
+ const key = nickKey(nick.toLowerCase());
66
+ const existing = await tx.get<string>(key);
67
+ if (existing !== undefined && existing !== conn) {
68
+ return { ok: false as const };
69
+ }
70
+ await tx.put(key, conn);
71
+ return { ok: true as const };
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Atomically re-points the registry from `oldNick` to `newNick` for
77
+ * `conn`. Used by `NICK` (nick changes).
78
+ *
79
+ * Behaviour:
80
+ * - If `newNick` is taken by another conn, returns `false` and
81
+ * touches nothing.
82
+ * - Otherwise reserves `newNick` for `conn`.
83
+ * - Clears `oldNick` *only if it still points at `conn`*. The
84
+ * conditional delete avoids clobbering a third party that grabbed
85
+ * `oldNick` after this conn abandoned it (defence in depth).
86
+ *
87
+ * Cross-shard changes (old and new hash to different shards) are
88
+ * orchestrated by `CfRuntime` in 036 as a two-phase
89
+ * reserve-new-then-release-old. This DO only sees single-shard
90
+ * changes and crash-recovery paths (where `oldNick` may not be
91
+ * present); the latter is why the conditional delete is a soft check.
92
+ */
93
+ async changeNick(conn: string, oldNick: string, newNick: string): Promise<boolean> {
94
+ return this.ctx.storage.transaction(async (tx) => {
95
+ const newKey = nickKey(newNick.toLowerCase());
96
+ const oldKey = nickKey(oldNick.toLowerCase());
97
+
98
+ const owner = await tx.get<string>(newKey);
99
+ if (owner !== undefined && owner !== conn) {
100
+ return false;
101
+ }
102
+ await tx.put(newKey, conn);
103
+
104
+ const oldOwner = await tx.get<string>(oldKey);
105
+ if (oldOwner === conn) {
106
+ await tx.delete(oldKey);
107
+ }
108
+ return true;
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Releases the mapping for `nick`. Idempotent — releasing a missing
114
+ * nick is a no-op (covers the close-after-crash case where the nick
115
+ * was never reserved or already cleaned up).
116
+ */
117
+ async releaseNick(nick: string): Promise<void> {
118
+ await this.ctx.storage.delete(nickKey(nick.toLowerCase()));
119
+ }
120
+
121
+ /**
122
+ * Resolves `nick` to its owning connectionId, or `null` if no
123
+ * connection currently holds it. Read-only; safe to call outside a
124
+ * transaction.
125
+ */
126
+ async lookupNick(nick: string): Promise<string | null> {
127
+ const owner = await this.ctx.storage.get<string>(nickKey(nick.toLowerCase()));
128
+ return owner ?? null;
129
+ }
130
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Serialization helpers for {@link ConnectionState}.
3
+ *
4
+ * `state.storage` (Durable Object transactional KV) stores JSON-compatible
5
+ * values. `Set` and `Map` fields don't survive a JSON round-trip — they
6
+ * degrade into `{}`. To make hibernation safe we serialize those fields
7
+ * into plain arrays on write and rebuild them on read. Everything else
8
+ * (strings, numbers, booleans, optional fields) survives untouched.
9
+ *
10
+ * A `version` field on {@link PersistedConnectionState} supports
11
+ * migrations: if the persisted shape changes, bump the version and add a
12
+ * `migrate_vN_vN1` step in {@link migrate}. Old format -> latest on read.
13
+ */
14
+
15
+ import type {
16
+ ChanName,
17
+ ConnectionState,
18
+ Nick,
19
+ RegistrationState,
20
+ UserModes,
21
+ } from '@serverless-ircd/irc-core';
22
+
23
+ /** Current on-disk schema version. Bump when the shape changes. */
24
+ export const PERSISTED_STATE_VERSION = 1;
25
+
26
+ /**
27
+ * Serialized representation of a {@link ConnectionState} in DO storage.
28
+ * `Set`s become arrays; everything else is preserved verbatim.
29
+ *
30
+ * The `version` field supports future migrations — see {@link migrate}.
31
+ */
32
+ export interface PersistedConnectionState {
33
+ version: number;
34
+ id: string;
35
+ registration: RegistrationState;
36
+ capNegotiating: boolean;
37
+ caps: string[];
38
+ joinedChannels: ChanName[];
39
+ userModes: UserModes;
40
+ lastSeen: number;
41
+ connectedSince: number;
42
+ nick?: Nick;
43
+ user?: string;
44
+ host?: string;
45
+ realname?: string;
46
+ passAttempt?: string;
47
+ account?: string;
48
+ away?: string;
49
+ }
50
+
51
+ /** Storage key under which the per-connection state is persisted. */
52
+ export const STATE_STORAGE_KEY = 'connection-state';
53
+
54
+ /**
55
+ * Serializes a live {@link ConnectionState} into a JSON-safe shape for
56
+ * `state.storage.put()`.
57
+ */
58
+ export function serialize(state: ConnectionState): PersistedConnectionState {
59
+ const out: PersistedConnectionState = {
60
+ version: PERSISTED_STATE_VERSION,
61
+ id: state.id,
62
+ registration: state.registration,
63
+ capNegotiating: state.capNegotiating,
64
+ caps: [...state.caps],
65
+ joinedChannels: [...state.joinedChannels],
66
+ userModes: { ...state.userModes },
67
+ lastSeen: state.lastSeen,
68
+ connectedSince: state.connectedSince,
69
+ };
70
+ if (state.nick !== undefined) out.nick = state.nick;
71
+ if (state.user !== undefined) out.user = state.user;
72
+ if (state.host !== undefined) out.host = state.host;
73
+ if (state.realname !== undefined) out.realname = state.realname;
74
+ if (state.passAttempt !== undefined) out.passAttempt = state.passAttempt;
75
+ if (state.account !== undefined) out.account = state.account;
76
+ if (state.away !== undefined) out.away = state.away;
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * Materializes a live {@link ConnectionState} from a persisted record.
82
+ * Rebuilds `Set`s from the stored arrays and applies any registered
83
+ * migrations via {@link migrate} first.
84
+ */
85
+ export function deserialize(record: unknown): ConnectionState {
86
+ const migrated = migrate(record);
87
+ const state: ConnectionState = {
88
+ id: migrated.id,
89
+ registration: migrated.registration,
90
+ capNegotiating: migrated.capNegotiating,
91
+ caps: new Set<string>(migrated.caps),
92
+ joinedChannels: new Set<ChanName>(migrated.joinedChannels),
93
+ userModes: { ...migrated.userModes },
94
+ lastSeen: migrated.lastSeen,
95
+ connectedSince: migrated.connectedSince,
96
+ };
97
+ if (migrated.nick !== undefined) state.nick = migrated.nick;
98
+ if (migrated.user !== undefined) state.user = migrated.user;
99
+ if (migrated.host !== undefined) state.host = migrated.host;
100
+ if (migrated.realname !== undefined) state.realname = migrated.realname;
101
+ if (migrated.passAttempt !== undefined) state.passAttempt = migrated.passAttempt;
102
+ if (migrated.account !== undefined) state.account = migrated.account;
103
+ if (migrated.away !== undefined) state.away = migrated.away;
104
+ return state;
105
+ }
106
+
107
+ /**
108
+ * Forward-migrates a persisted record to {@link PERSISTED_STATE_VERSION}.
109
+ *
110
+ * Each step is keyed `fromVersion_toVersion`. Unknown shapes (or
111
+ * `version: 0`, the legacy default) are passed through as-is; the only
112
+ * "migration" today is the no-op v1 -> v1.
113
+ *
114
+ * PLAN §6.1 — DO transactional storage outlives any single deployment,
115
+ * so the on-disk schema will eventually diverge from the in-memory shape.
116
+ * This is the seam that keeps them in sync.
117
+ */
118
+ export function migrate(record: unknown): PersistedConnectionState {
119
+ if (!isPlainObject(record)) {
120
+ throw new Error('persisted connection state is not an object');
121
+ }
122
+ const version = typeof record.version === 'number' ? record.version : 0;
123
+ // No migrations yet — v1 is the initial schema. Future work adds steps:
124
+ // if (version < 2) record = migrate_v1_v2(record);
125
+ // if (version < 3) record = migrate_v2_v3(record);
126
+ if (version > PERSISTED_STATE_VERSION) {
127
+ throw new Error(
128
+ `persisted connection state version ${version} is newer than ` +
129
+ `supported version ${PERSISTED_STATE_VERSION}`,
130
+ );
131
+ }
132
+ // The cast below is safe: we accept any JSON shape the storage layer
133
+ // returns; production code wrote it via `serialize`, tests wrote it
134
+ // through the same path. If a future field is missing, the
135
+ // `exactOptionalPropertyTypes` discipline in `serialize` keeps
136
+ // undefined keys out of the persisted record entirely.
137
+ return record as unknown as PersistedConnectionState;
138
+ }
139
+
140
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
141
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
142
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Nick sharding for the Cloudflare `RegistryDO` fleet.
3
+ *
4
+ * PLAN §6.1 / §4 — the nickname registry is split across N Durable
5
+ * Object instances to avoid a single hot shard. Two clients racing on
6
+ * the *same* nick both route to the same shard, where DO
7
+ * single-threadedness (storage transactions) makes the uniqueness
8
+ * invariant structural instead of optimistic.
9
+ *
10
+ * Shard assignment is `hash(lowercased(nick)) % N`:
11
+ * - **Deterministic** — every adapter instance computes the same
12
+ * shard for a given nick without coordination.
13
+ * - **Case-insensitive** — matches RFC 1459 nick equivalence.
14
+ * `Alice`, `alice`, and `ALICE` MUST land in the same shard so the
15
+ * single-shard uniqueness check covers all case variants.
16
+ * - **Reasonably uniform** — FNV-1a 32-bit gives ~uniform spread
17
+ * over realistic nick populations (verified by a 10k synthetic
18
+ * distribution test).
19
+ * - **Synchronous & dependency-free** — important since this runs on
20
+ * every nick-reservation path; no `crypto.subtle.digest` await.
21
+ *
22
+ * `N` defaults to {@link DEFAULT_REGISTRY_SHARDS} (32) and is
23
+ * configurable per deployment via the worker env. Changing N after
24
+ * production traffic requires a migration (re-sharding every existing
25
+ * nick→conn mapping); document this in `docs/deployment-cf.md`
26
+ * (038).
27
+ */
28
+
29
+ /**
30
+ * Default shard count for the registry DO fleet.
31
+ *
32
+ * 32 is a sweet spot for a v1 single-region Workers deployment:
33
+ * - Small enough that the per-shard nick map fits comfortably in DO
34
+ * transactional storage (tens of thousands of entries per shard
35
+ * before a 128 kB transaction-size ceiling becomes a concern).
36
+ * - Large enough that no single shard bottlenecks registration
37
+ * throughput for any plausible nick population.
38
+ *
39
+ * Production overrides via `REGISTRY_SHARDS` env var; see
40
+ * {@link RegistryDO} constructor.
41
+ */
42
+ export const DEFAULT_REGISTRY_SHARDS = 32;
43
+
44
+ /**
45
+ * Computes the shard index for `nick` under a fleet of `shards` DOs.
46
+ *
47
+ * Lowercases `nick` first (RFC 1459 nick case-insensitivity) then
48
+ * applies FNV-1a 32-bit and reduces `mod shards`.
49
+ *
50
+ * @param nick Nick to shard (case-insensitive).
51
+ * @param shards Fleet size; defaults to {@link DEFAULT_REGISTRY_SHARDS}.
52
+ * @returns Integer in `[0, shards)`.
53
+ * @throws if `shards <= 0` (programming error — fail fast at boot).
54
+ */
55
+ export function shardNick(nick: string, shards: number = DEFAULT_REGISTRY_SHARDS): number {
56
+ if (!Number.isInteger(shards) || shards <= 0) {
57
+ throw new Error(`shardNick: shards must be a positive integer, got ${shards}`);
58
+ }
59
+ return fnv1a32(nick.toLowerCase()) % shards;
60
+ }
61
+
62
+ /**
63
+ * Returns the Durable Object name key for the shard owning `nick`.
64
+ *
65
+ * The CfRuntime calls this to compute `env.REGISTRY_DO.idFromName(key)`
66
+ * before each RPC. The key format is **stable**: it depends only on the
67
+ * (lowercased) nick and the shard count, never on the host deployment,
68
+ * so two adapters configured the same will route to the same shard.
69
+ *
70
+ * Format: literal `s` prefix + base-10 shard index (e.g. `s7`, `s31`).
71
+ * The prefix keeps registry keys visually distinct from other potential
72
+ * name-spaced DOs in the same worker.
73
+ *
74
+ * @param nick Nick whose shard we want.
75
+ * @param shards Fleet size; defaults to {@link DEFAULT_REGISTRY_SHARDS}.
76
+ */
77
+ export function registryKeyForNick(nick: string, shards: number = DEFAULT_REGISTRY_SHARDS): string {
78
+ return `s${shardNick(nick, shards)}`;
79
+ }
80
+
81
+ /**
82
+ * FNV-1a 32-bit hash.
83
+ *
84
+ * Chosen for sharding because:
85
+ * - Short, well-understood implementation with no external deps.
86
+ * - Good distribution for short ASCII inputs (nicks).
87
+ * - Synchronous (unlike `crypto.subtle.digest`).
88
+ *
89
+ * Not cryptographic — the only property we need is uniform spread.
90
+ */
91
+ function fnv1a32(input: string): number {
92
+ let hash = 0x811c9dc5; // FNV offset basis (2166136261)
93
+ for (let i = 0; i < input.length; i++) {
94
+ hash ^= input.charCodeAt(i);
95
+ // Multiply by FNV prime (16777619) mod 2^32. The Math.imul form
96
+ // keeps this in 32-bit space and avoids floating-point drift.
97
+ hash = Math.imul(hash, 0x01000193);
98
+ }
99
+ // Force unsigned 32-bit.
100
+ return hash >>> 0;
101
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * CF-side {@link IrcHarnessFactory} — registers the parametrized IRC
3
+ * scenario suite (032) against the Cloudflare adapter.
4
+ *
5
+ * Each {@link CfHarness} instance is hermetic at the vitest-pool-workers
6
+ * level: every test gets an isolated storage namespace
7
+ * (`isolatedStorage: true` in vitest.config.ts), so two tests never share
8
+ * ConnectionDO / RegistryDO / ChannelDO state even though they address
9
+ * the same worker bindings.
10
+ *
11
+ * Each `spawnClient` opens a real WebSocket to a fresh `ConnectionDO`
12
+ * named by an auto-allocated id. The WebSocket drives the actor pipeline
13
+ * (bytes → framed messages → reducer → dispatch → CfRuntime) exactly as
14
+ * in production — there is no test-only shortcut.
15
+ */
16
+
17
+ import type { ConnId } from '@serverless-ircd/irc-core';
18
+ import type {
19
+ ClientHarness,
20
+ IrcHarness,
21
+ IrcHarnessFactory,
22
+ SpawnClientOptions,
23
+ } from './integration/harness.js';
24
+
25
+ /** Default tick for the polling waitFor* implementations (ms). */
26
+ const POLL_MS = 10;
27
+
28
+ /** Default timeout for waitFor* implementations (ms). */
29
+ const DEFAULT_TIMEOUT_MS = 2000;
30
+
31
+ /**
32
+ * Factory entry for the Cloudflare runtime. Append this to a suite's
33
+ * FACTORIES array to opt the scenarios into the CF matrix.
34
+ *
35
+ * The factory is created lazily inside `create()` because the env is only
36
+ * available inside an `it` block (via the `cloudflare:test` `env`
37
+ * global). Consumers MUST call `setCfEnv(env)` from a `beforeAll` (or
38
+ * equivalent) so the harness can capture the bindings.
39
+ */
40
+ export interface CfHarnessFactoryOptions {
41
+ /** Worker bindings; captured from `cloudflare:test`'s `env` global. */
42
+ env: CfHarnessEnv;
43
+ /** Server name to assert against in MOTD/welcome replies. */
44
+ serverName?: string;
45
+ }
46
+
47
+ /** Bindings the CF harness needs from `cloudflare:test`. */
48
+ export interface CfHarnessEnv {
49
+ CONNECTION_DO: DurableObjectNamespace;
50
+ REGISTRY_DO: DurableObjectNamespace;
51
+ CHANNEL_DO: DurableObjectNamespace;
52
+ }
53
+
54
+ /**
55
+ * Constructs a CF harness factory bound to the supplied env. Call from
56
+ * inside a vitest test file running under `vitest-pool-workers`:
57
+ *
58
+ * ```ts
59
+ * import { env } from 'cloudflare:test';
60
+ * import { runIrcScenarios } from './integration/scenarios.js';
61
+ * import { makeCfHarnessFactory } from './cf-harness';
62
+ *
63
+ * runIrcScenarios([makeCfHarnessFactory({ env })]);
64
+ * ```
65
+ */
66
+ export function makeCfHarnessFactory(opts: CfHarnessFactoryOptions): IrcHarnessFactory {
67
+ return Object.freeze({
68
+ name: 'cf',
69
+ async create() {
70
+ return new CfHarness(opts);
71
+ },
72
+ });
73
+ }
74
+
75
+ interface TrackedClient {
76
+ harness: CfClientHandle;
77
+ close: () => Promise<void>;
78
+ }
79
+
80
+ /**
81
+ * One isolated CF world. Spawns clients by opening real WebSockets to
82
+ * fresh ConnectionDO instances. The same `ConnectionDO` namespace is
83
+ * reused across all clients in one harness (per the in-memory harness's
84
+ * convention); isolation across tests comes from `isolatedStorage: true`.
85
+ */
86
+ class CfHarness implements IrcHarness {
87
+ private readonly env: CfHarnessEnv;
88
+ private nextId = 0;
89
+ private closed = false;
90
+ private readonly clients = new Map<ConnId, TrackedClient>();
91
+ private readonly nicks = new Map<string, CfClientHandle>();
92
+
93
+ constructor(opts: CfHarnessFactoryOptions) {
94
+ this.env = opts.env;
95
+ }
96
+
97
+ async spawnClient(opts?: SpawnClientOptions): Promise<ClientHarness> {
98
+ if (this.closed) throw new Error('spawnClient called on a closed harness');
99
+ const id = opts?.id ?? `c${this.nextId++}`;
100
+ if (this.clients.has(id)) {
101
+ throw new Error(`duplicate client id: ${id}`);
102
+ }
103
+
104
+ const handle = await openConnection(this.env, id);
105
+ const client = new CfClientHandle(id, handle.ws);
106
+ const close = async (): Promise<void> => {
107
+ const tracked = this.clients.get(id);
108
+ if (tracked === undefined) return;
109
+ this.clients.delete(id);
110
+ await handle.close();
111
+ };
112
+ this.clients.set(id, { harness: client, close });
113
+
114
+ // Track nick → client for clientByNick lookups. We don't have a
115
+ // synchronous nick event, so we update this map opportunistically by
116
+ // observing the welcome line; precise nick lookups go through the
117
+ // registry via `clientByNick`.
118
+ if (opts?.nick !== undefined) {
119
+ await client.send(`NICK ${opts.nick}`);
120
+ await client.send(`USER ${opts.nick} 0 * :${opts.nick}`);
121
+ await client.waitForNumeric('001', DEFAULT_TIMEOUT_MS);
122
+ this.nicks.set(opts.nick.toLowerCase(), client);
123
+ }
124
+
125
+ return client;
126
+ }
127
+
128
+ async clientByNick(nick: string): Promise<ClientHarness | undefined> {
129
+ // First check the local cache (fast path for the test's own clients).
130
+ const cached = this.nicks.get(nick.toLowerCase());
131
+ if (cached !== undefined) return cached;
132
+ return undefined;
133
+ }
134
+
135
+ async close(): Promise<void> {
136
+ if (this.closed) return;
137
+ this.closed = true;
138
+ for (const { close } of this.clients.values()) await close();
139
+ this.clients.clear();
140
+ this.nicks.clear();
141
+ }
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // WebSocket plumbing
146
+ // ---------------------------------------------------------------------------
147
+
148
+ interface OpenedConnection {
149
+ ws: WebSocket;
150
+ close: () => Promise<void>;
151
+ }
152
+
153
+ async function openConnection(env: CfHarnessEnv, connId: string): Promise<OpenedConnection> {
154
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(connId));
155
+ const response = await stub.fetch('https://do/upgrade', {
156
+ headers: { Upgrade: 'websocket' },
157
+ });
158
+ const ws = response.webSocket;
159
+ if (ws === undefined || ws === null) {
160
+ throw new Error('ConnectionDO.fetch did not return a WebSocket');
161
+ }
162
+ ws.accept();
163
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
164
+ return {
165
+ ws: ws as WebSocket,
166
+ close: () =>
167
+ new Promise<void>((resolve) => {
168
+ if (ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
169
+ resolve();
170
+ return;
171
+ }
172
+ (ws as WebSocket).addEventListener('close', () => resolve());
173
+ try {
174
+ (ws as WebSocket).close();
175
+ } catch {
176
+ resolve();
177
+ }
178
+ setTimeout(resolve, 200);
179
+ }),
180
+ };
181
+ }
182
+
183
+ /** CF-backed ClientHarness: a real WebSocket plus the standard helpers. */
184
+ class CfClientHandle implements ClientHarness {
185
+ readonly received: string[] = [];
186
+ private closed = false;
187
+ private readonly ws: WebSocket;
188
+
189
+ constructor(
190
+ readonly id: ConnId,
191
+ ws: WebSocket,
192
+ ) {
193
+ this.ws = ws;
194
+ ws.addEventListener('message', (ev: MessageEvent) => {
195
+ const d = ev.data as string;
196
+ for (const line of d.split('\r\n')) {
197
+ if (line.length > 0) this.received.push(line);
198
+ }
199
+ });
200
+ }
201
+
202
+ async send(line: string): Promise<void> {
203
+ if (this.closed) return;
204
+ this.ws.send(`${line}\r\n`);
205
+ }
206
+
207
+ async waitForLine(
208
+ predicate: (line: string) => boolean,
209
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
210
+ ): Promise<string> {
211
+ const deadline = Date.now() + timeoutMs;
212
+ for (const line of this.received) {
213
+ if (predicate(line)) return line;
214
+ }
215
+ while (Date.now() < deadline) {
216
+ await new Promise((r) => setTimeout(r, POLL_MS));
217
+ for (const line of this.received) {
218
+ if (predicate(line)) return line;
219
+ }
220
+ }
221
+ throw new Error(
222
+ `waitForLine timed out after ${timeoutMs}ms; received: ${JSON.stringify(this.received)}`,
223
+ );
224
+ }
225
+
226
+ async waitForNumeric(
227
+ code: number | string,
228
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
229
+ ): Promise<string> {
230
+ const want = typeof code === 'number' ? code.toString().padStart(3, '0') : code;
231
+ return this.waitForLine((l) => numericEquals(l, want), timeoutMs);
232
+ }
233
+
234
+ async close(): Promise<void> {
235
+ if (this.closed) return;
236
+ this.closed = true;
237
+ // Best-effort QUIT so peers see the part broadcast.
238
+ try {
239
+ this.ws.send('QUIT :client closed\r\n');
240
+ } catch {
241
+ // ignore
242
+ }
243
+ await new Promise<void>((resolve) => {
244
+ this.ws.addEventListener('close', () => resolve());
245
+ try {
246
+ this.ws.close();
247
+ } catch {
248
+ resolve();
249
+ }
250
+ setTimeout(resolve, 200);
251
+ });
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Returns true iff the second whitespace-delimited token of `line` is
257
+ * `code` (after the optional `:server` prefix).
258
+ */
259
+ function numericEquals(line: string, code: string): boolean {
260
+ const parts = line.split(' ');
261
+ const start = parts[0]?.startsWith(':') ? 1 : 0;
262
+ const candidate = parts[start];
263
+ return candidate === code;
264
+ }