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.
- package/.github/workflows/ci.yml +47 -0
- package/.github/workflows/deploy-cf.yml +97 -0
- package/LICENSE +29 -0
- package/README.md +323 -0
- package/apps/cf-worker/package.json +37 -0
- package/apps/cf-worker/scripts/smoke.mjs +175 -0
- package/apps/cf-worker/src/worker.ts +59 -0
- package/apps/cf-worker/tests/smoke.test.ts +150 -0
- package/apps/cf-worker/tsconfig.build.json +17 -0
- package/apps/cf-worker/tsconfig.test.json +18 -0
- package/apps/cf-worker/vitest.config.ts +31 -0
- package/apps/cf-worker/wrangler.test.toml +33 -0
- package/apps/cf-worker/wrangler.toml +98 -0
- package/apps/local-cli/package.json +35 -0
- package/apps/local-cli/src/line-scanner.ts +60 -0
- package/apps/local-cli/src/main.ts +145 -0
- package/apps/local-cli/src/motd-file.ts +45 -0
- package/apps/local-cli/src/server.ts +409 -0
- package/apps/local-cli/tests/e2e.test.ts +346 -0
- package/apps/local-cli/tests/id-factory.test.ts +25 -0
- package/apps/local-cli/tests/line-scanner.test.ts +81 -0
- package/apps/local-cli/tests/motd-file.test.ts +71 -0
- package/apps/local-cli/tests/tcp.test.ts +358 -0
- package/apps/local-cli/tsconfig.build.json +17 -0
- package/apps/local-cli/tsconfig.test.json +15 -0
- package/apps/local-cli/vitest.config.ts +24 -0
- package/biome.json +52 -0
- package/package.json +35 -0
- package/packages/cf-adapter/package.json +38 -0
- package/packages/cf-adapter/src/cf-runtime.ts +308 -0
- package/packages/cf-adapter/src/channel-do.ts +374 -0
- package/packages/cf-adapter/src/connection-do.ts +542 -0
- package/packages/cf-adapter/src/env.ts +67 -0
- package/packages/cf-adapter/src/index.ts +38 -0
- package/packages/cf-adapter/src/registry-do.ts +130 -0
- package/packages/cf-adapter/src/serialize.ts +142 -0
- package/packages/cf-adapter/src/sharding.ts +101 -0
- package/packages/cf-adapter/tests/cf-harness.ts +264 -0
- package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
- package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
- package/packages/cf-adapter/tests/integration/harness.ts +102 -0
- package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
- package/packages/cf-adapter/tests/integration/index.ts +17 -0
- package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
- package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
- package/packages/cf-adapter/tests/sharding.test.ts +100 -0
- package/packages/cf-adapter/tests/worker/main.ts +33 -0
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
- package/packages/cf-adapter/tsconfig.build.json +16 -0
- package/packages/cf-adapter/tsconfig.test.json +18 -0
- package/packages/cf-adapter/vitest.config.ts +32 -0
- package/packages/cf-adapter/wrangler.test.toml +50 -0
- package/packages/in-memory-runtime/package.json +29 -0
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
- package/packages/in-memory-runtime/src/index.ts +9 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
- package/packages/in-memory-runtime/tsconfig.build.json +16 -0
- package/packages/in-memory-runtime/tsconfig.test.json +14 -0
- package/packages/in-memory-runtime/vitest.config.ts +21 -0
- package/packages/irc-core/package.json +29 -0
- package/packages/irc-core/src/caps/capabilities.ts +96 -0
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/commands/away.ts +82 -0
- package/packages/irc-core/src/commands/cap.ts +257 -0
- package/packages/irc-core/src/commands/chghost.ts +30 -0
- package/packages/irc-core/src/commands/index.ts +40 -0
- package/packages/irc-core/src/commands/invite.ts +156 -0
- package/packages/irc-core/src/commands/isupport.ts +133 -0
- package/packages/irc-core/src/commands/join.ts +309 -0
- package/packages/irc-core/src/commands/kick.ts +162 -0
- package/packages/irc-core/src/commands/list.ts +126 -0
- package/packages/irc-core/src/commands/mode.ts +655 -0
- package/packages/irc-core/src/commands/motd.ts +146 -0
- package/packages/irc-core/src/commands/names.ts +164 -0
- package/packages/irc-core/src/commands/part.ts +118 -0
- package/packages/irc-core/src/commands/ping.ts +47 -0
- package/packages/irc-core/src/commands/privmsg.ts +256 -0
- package/packages/irc-core/src/commands/quit.ts +70 -0
- package/packages/irc-core/src/commands/registration.ts +251 -0
- package/packages/irc-core/src/commands/topic.ts +171 -0
- package/packages/irc-core/src/commands/who.ts +169 -0
- package/packages/irc-core/src/commands/whois.ts +165 -0
- package/packages/irc-core/src/effects.ts +184 -0
- package/packages/irc-core/src/index.ts +20 -0
- package/packages/irc-core/src/ports.ts +153 -0
- package/packages/irc-core/src/protocol/batch.ts +85 -0
- package/packages/irc-core/src/protocol/index.ts +18 -0
- package/packages/irc-core/src/protocol/messages.ts +36 -0
- package/packages/irc-core/src/protocol/numerics.ts +155 -0
- package/packages/irc-core/src/protocol/outbound.ts +145 -0
- package/packages/irc-core/src/protocol/parser.ts +175 -0
- package/packages/irc-core/src/protocol/serializer.ts +71 -0
- package/packages/irc-core/src/state/channel.ts +293 -0
- package/packages/irc-core/src/state/connection.ts +153 -0
- package/packages/irc-core/src/state/index.ts +25 -0
- package/packages/irc-core/src/state/registry.ts +22 -0
- package/packages/irc-core/src/types.ts +83 -0
- package/packages/irc-core/tests/batch.test.ts +79 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
- package/packages/irc-core/tests/commands/away.test.ts +233 -0
- package/packages/irc-core/tests/commands/batch.test.ts +178 -0
- package/packages/irc-core/tests/commands/cap.test.ts +499 -0
- package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
- package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
- package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
- package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
- package/packages/irc-core/tests/commands/invite.test.ts +321 -0
- package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
- package/packages/irc-core/tests/commands/join.test.ts +687 -0
- package/packages/irc-core/tests/commands/kick.test.ts +316 -0
- package/packages/irc-core/tests/commands/list.test.ts +452 -0
- package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
- package/packages/irc-core/tests/commands/motd.test.ts +322 -0
- package/packages/irc-core/tests/commands/names.test.ts +342 -0
- package/packages/irc-core/tests/commands/part.test.ts +265 -0
- package/packages/irc-core/tests/commands/ping.test.ts +144 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
- package/packages/irc-core/tests/commands/quit.test.ts +220 -0
- package/packages/irc-core/tests/commands/registration.test.ts +599 -0
- package/packages/irc-core/tests/commands/topic.test.ts +337 -0
- package/packages/irc-core/tests/commands/who.test.ts +441 -0
- package/packages/irc-core/tests/commands/whois.test.ts +422 -0
- package/packages/irc-core/tests/effects.test.ts +147 -0
- package/packages/irc-core/tests/message-tags.test.ts +182 -0
- package/packages/irc-core/tests/numerics.test.ts +98 -0
- package/packages/irc-core/tests/outbound.test.ts +232 -0
- package/packages/irc-core/tests/parser.test.ts +162 -0
- package/packages/irc-core/tests/ports.test.ts +101 -0
- package/packages/irc-core/tests/serializer.test.ts +164 -0
- package/packages/irc-core/tests/state/channel.test.ts +351 -0
- package/packages/irc-core/tests/state/connection.test.ts +122 -0
- package/packages/irc-core/tests/state/registry.test.ts +20 -0
- package/packages/irc-core/tests/types.test.ts +127 -0
- package/packages/irc-core/tsconfig.build.json +12 -0
- package/packages/irc-core/tsconfig.test.json +10 -0
- package/packages/irc-core/vitest.config.ts +21 -0
- package/packages/irc-server/package.json +28 -0
- package/packages/irc-server/src/actor.ts +449 -0
- package/packages/irc-server/src/dispatch.ts +120 -0
- package/packages/irc-server/src/index.ts +11 -0
- package/packages/irc-server/src/runtime.ts +61 -0
- package/packages/irc-server/tests/actor.test.ts +816 -0
- package/packages/irc-server/tests/dispatch.test.ts +512 -0
- package/packages/irc-server/tests/runtime.test.ts +52 -0
- package/packages/irc-server/tsconfig.build.json +13 -0
- package/packages/irc-server/tsconfig.test.json +11 -0
- package/packages/irc-server/vitest.config.ts +21 -0
- package/pnpm-workspace.yaml +4 -0
- package/tools/tcp-ws-forwarder/package.json +32 -0
- package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
- package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
- package/tools/tcp-ws-forwarder/src/main.ts +114 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
- package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
- package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
- package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
- package/tsconfig.base.json +25 -0
- 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
|
+
}
|