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,175 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CI smoke e2e for a deployed Cloudflare Worker.
4
+ *
5
+ * Replays the same CONNECT → REGISTER → JOIN → PRIVMSG → QUIT flow as
6
+ * `tests/smoke.test.ts`, but against a live deployed Worker URL. Exits
7
+ * non-zero on any failure so the GitHub Actions `deploy-cf.yml` job
8
+ * fails the build.
9
+ *
10
+ * Usage:
11
+ * node scripts/smoke.mjs --url wss://serverless-ircd-staging.<acct>.workers.dev
12
+ *
13
+ * Defaults to `ws://localhost:8787` so the same script works against a
14
+ * local `wrangler dev` session.
15
+ *
16
+ * Dependencies: only the `ws` WebSocket client (already used by
17
+ * `apps/local-cli`). The script is plain Node ESM — no TypeScript
18
+ * transform needed in CI.
19
+ */
20
+
21
+ import { WebSocket } from 'ws';
22
+
23
+ const args = parseArgs(process.argv.slice(2));
24
+ const url = args.url ?? 'ws://localhost:8787';
25
+
26
+ const POLL_MS = 50;
27
+ const DEFAULT_TIMEOUT_MS = 10_000;
28
+
29
+ /** Numeric command position in an IRC line (after optional `:server` prefix). */
30
+ function numericEquals(line, code) {
31
+ const parts = line.split(' ');
32
+ const start = parts[0]?.startsWith(':') ? 1 : 0;
33
+ return parts[start] === code;
34
+ }
35
+
36
+ async function runSmoke() {
37
+ const ws = await openSocket(url);
38
+ const received = [];
39
+
40
+ ws.on('message', (data) => {
41
+ const text = data.toString();
42
+ for (const line of text.split('\r\n')) {
43
+ if (line.length > 0) received.push(line);
44
+ }
45
+ });
46
+
47
+ const waitForNumeric = (code, timeoutMs = DEFAULT_TIMEOUT_MS) =>
48
+ waitForPredicate(
49
+ (line) => numericEquals(line, code),
50
+ `numeric ${code}`,
51
+ received,
52
+ timeoutMs,
53
+ POLL_MS,
54
+ );
55
+ const waitForLine = (predicate, label, timeoutMs = DEFAULT_TIMEOUT_MS) =>
56
+ waitForPredicate(predicate, label, received, timeoutMs, POLL_MS);
57
+
58
+ // REGISTER
59
+ ws.send('NICK smoke\r\n');
60
+ ws.send('USER smoke 0 * :Smoke Test\r\n');
61
+ await waitForNumeric('001', DEFAULT_TIMEOUT_MS);
62
+ await waitForNumeric('376', DEFAULT_TIMEOUT_MS);
63
+
64
+ // JOIN
65
+ ws.send('JOIN #smoke\r\n');
66
+ await waitForLine((l) => l.includes('JOIN') && l.includes('#smoke'), 'JOIN echo');
67
+ await waitForNumeric('353', DEFAULT_TIMEOUT_MS);
68
+ await waitForNumeric('366', DEFAULT_TIMEOUT_MS);
69
+
70
+ // PRIVMSG (no echo expected — echo-message is opt-in via CAP)
71
+ ws.send('PRIVMSG #smoke :hello from ci\r\n');
72
+
73
+ // QUIT — expect the server to close the socket
74
+ await new Promise((resolve, reject) => {
75
+ const timer = setTimeout(resolve, DEFAULT_TIMEOUT_MS);
76
+ ws.once('close', () => {
77
+ clearTimeout(timer);
78
+ resolve();
79
+ });
80
+ ws.once('error', (err) => {
81
+ clearTimeout(timer);
82
+ reject(new Error(`socket error during QUIT: ${err.message}`));
83
+ });
84
+ ws.send('QUIT :smoke test complete\r\n');
85
+ });
86
+
87
+ ws.close();
88
+ process.stdout.write(
89
+ `${JSON.stringify({
90
+ ts: new Date().toISOString(),
91
+ level: 'info',
92
+ msg: 'cf-worker smoke e2e passed',
93
+ url,
94
+ })}\n`,
95
+ );
96
+ }
97
+
98
+ function openSocket(url) {
99
+ return new Promise((resolve, reject) => {
100
+ const ws = new WebSocket(url);
101
+ const timer = setTimeout(() => {
102
+ ws.terminate();
103
+ reject(new Error(`connect timeout: ${url}`));
104
+ }, DEFAULT_TIMEOUT_MS);
105
+ ws.once('open', () => {
106
+ clearTimeout(timer);
107
+ resolve(ws);
108
+ });
109
+ ws.once('error', (err) => {
110
+ clearTimeout(timer);
111
+ reject(new Error(`connect failed: ${err.message}`));
112
+ });
113
+ });
114
+ }
115
+
116
+ function waitForPredicate(predicate, label, received, timeoutMs, pollMs) {
117
+ return new Promise((resolve, reject) => {
118
+ const deadline = Date.now() + timeoutMs;
119
+ const check = () => {
120
+ for (const line of received) {
121
+ if (predicate(line)) {
122
+ resolve(line);
123
+ return;
124
+ }
125
+ }
126
+ if (Date.now() >= deadline) {
127
+ reject(new Error(`timed out waiting for ${label}; received=${JSON.stringify(received)}`));
128
+ return;
129
+ }
130
+ setTimeout(check, pollMs);
131
+ };
132
+ check();
133
+ });
134
+ }
135
+
136
+ function parseArgs(argv) {
137
+ const out = {};
138
+ for (let i = 0; i < argv.length; i++) {
139
+ const a = argv[i];
140
+ const next = argv[i + 1];
141
+ if (a === '--url' && next !== undefined) {
142
+ out.url = next;
143
+ i++;
144
+ } else if (a === '-h' || a === '--help') {
145
+ process.stdout.write(
146
+ [
147
+ 'Usage: node scripts/smoke.mjs --url wss://<worker-url>',
148
+ '',
149
+ 'Options:',
150
+ ' --url <ws-url> WebSocket URL of the deployed Worker.',
151
+ ' Defaults to ws://localhost:8787 (wrangler dev).',
152
+ ' -h, --help Show this help.',
153
+ '',
154
+ ].join('\n'),
155
+ );
156
+ process.exit(0);
157
+ } else {
158
+ process.stderr.write(`unknown argument: ${a}\n`);
159
+ process.exit(2);
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+
165
+ runSmoke().catch((err) => {
166
+ process.stderr.write(
167
+ `${JSON.stringify({
168
+ ts: new Date().toISOString(),
169
+ level: 'error',
170
+ msg: 'cf-worker smoke e2e failed',
171
+ error: err instanceof Error ? err.message : String(err),
172
+ })}\n`,
173
+ );
174
+ process.exit(1);
175
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `apps/cf-worker` entry point — Cloudflare Worker deploy glue.
3
+ *
4
+ * This is the thin production entry that wires the cf-adapter's three
5
+ * Durable Objects (ConnectionDO, RegistryDO, ChannelDO) to inbound
6
+ * WebSocket traffic. Per PLAN §6.1 / §2.2:
7
+ *
8
+ * • Worker `fetch` is the public edge: every connection enters here.
9
+ * • Each WebSocket upgrade is routed to a fresh `ConnectionDO`
10
+ * instance named by a server-generated connection id. Allocating
11
+ * the id at the edge (rather than trusting a client-supplied id)
12
+ * keeps the id space opaque and unforgeable.
13
+ * • Cross-DO coordination (registry + channel) is owned by
14
+ * `ConnectionDO` and `CfRuntime`; the worker never touches it.
15
+ *
16
+ * The DO classes are re-exported so `wrangler.toml`'s
17
+ * `durable_objects.bindings.class_name` can resolve them in this
18
+ * module's scope (wrangler requires the class to be exported from the
19
+ * `main` entry).
20
+ *
21
+ * Deployment: `wrangler deploy --env staging` (or `:production`).
22
+ * See `wrangler.toml` and `docs/deployment-cf.md`.
23
+ */
24
+
25
+ import { ChannelDO, ConnectionDO, type Env, RegistryDO } from '@serverless-ircd/cf-adapter';
26
+
27
+ // Re-exported for wrangler DO binding resolution. The class names below
28
+ // MUST match `class_name` in `wrangler.toml`.
29
+ export { ChannelDO, ConnectionDO, RegistryDO };
30
+ export type { Env };
31
+
32
+ /**
33
+ * Default edge handler. Non-WebSocket requests get a human-readable
34
+ * health response; WebSocket upgrades are routed to a per-connection
35
+ * `ConnectionDO`.
36
+ *
37
+ * The connection id is allocated here via `crypto.randomUUID()` (the
38
+ * Web Crypto API is sync-available in Workers for RFC-4122 v4 UUIDs).
39
+ * If a future deployment needs client-supplied ids (e.g. resumable
40
+ * sessions), accept an opt-in header like `X-Connection-Id` and validate
41
+ * it before falling back to a fresh uuid.
42
+ */
43
+ export default {
44
+ async fetch(request: Request, env: Env): Promise<Response> {
45
+ if (request.headers.get('Upgrade') === 'websocket') {
46
+ const connId = crypto.randomUUID();
47
+ const id = env.CONNECTION_DO.idFromName(connId);
48
+ const stub = env.CONNECTION_DO.get(id);
49
+ return stub.fetch(request);
50
+ }
51
+ return new Response(
52
+ 'ServerlessIRCd (Cloudflare Worker). Connect via WebSocket to begin an IRC session.',
53
+ {
54
+ status: 200,
55
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
56
+ },
57
+ );
58
+ },
59
+ };
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Smoke e2e test for the `apps/cf-worker` deploy glue.
3
+ *
4
+ * Drives the full CONNECT → REGISTER → JOIN → PRIVMSG → QUIT flow
5
+ * against the production worker entry point (`src/worker.ts`). Verifies
6
+ * that the deploy glue — the wrangler.toml DO bindings, the migrations,
7
+ * and the worker's `fetch` handler that routes an inbound WebSocket
8
+ * upgrade to a fresh `ConnectionDO` — wires the cf-adapter DOs together
9
+ * correctly so a real IRC client can complete a full session.
10
+ *
11
+ * The same flow is replayed against a deployed staging URL by CI via
12
+ * `scripts/smoke.mjs` and `.github/workflows/deploy-cf.yml`.
13
+ *
14
+ * Test isolation: `vitest.config.ts` runs under
15
+ * `@cloudflare/vitest-pool-workers` with `isolatedStorage: true`, so each
16
+ * test gets a fresh DO storage namespace.
17
+ */
18
+
19
+ import { env } from 'cloudflare:test';
20
+ import { describe, expect, it } from 'vitest';
21
+ import worker from '../src/worker';
22
+
23
+ declare global {
24
+ namespace Cloudflare {
25
+ interface Env {
26
+ CONNECTION_DO: DurableObjectNamespace;
27
+ REGISTRY_DO: DurableObjectNamespace;
28
+ CHANNEL_DO: DurableObjectNamespace;
29
+ SERVER_NAME: string;
30
+ NETWORK_NAME: string;
31
+ MOTD_LINES: string;
32
+ }
33
+ }
34
+ }
35
+
36
+ /** Poll interval for the waitFor* helpers (ms). */
37
+ const POLL_MS = 10;
38
+ /** Default timeout for the waitFor* helpers (ms). */
39
+ const DEFAULT_TIMEOUT_MS = 2000;
40
+
41
+ /** Numeric command position in an IRC line (after optional `:server` prefix). */
42
+ function numericEquals(line: string, code: string): boolean {
43
+ const parts = line.split(' ');
44
+ const start = parts[0]?.startsWith(':') ? 1 : 0;
45
+ return parts[start] === code;
46
+ }
47
+
48
+ describe('cf-worker deploy glue', () => {
49
+ it('non-WebSocket GET returns a human-readable health response', async () => {
50
+ const response = await worker.fetch(new Request('https://irc.example.com/'), env);
51
+ expect(response.status).toBe(200);
52
+ const body = await response.text();
53
+ expect(body.toLowerCase()).toContain('serverlessircd');
54
+ });
55
+
56
+ it('completes CONNECT → REGISTER → JOIN → PRIVMSG → QUIT', async () => {
57
+ // ----- CONNECT: WebSocket upgrade through the worker's fetch handler.
58
+ const upgrade = new Request('https://irc.example.com/', {
59
+ headers: { Upgrade: 'websocket' },
60
+ });
61
+ const response = await worker.fetch(upgrade, env);
62
+ expect(response.status).toBe(101);
63
+ const ws = response.webSocket;
64
+ expect(ws).toBeDefined();
65
+ ws?.accept();
66
+
67
+ const received: string[] = [];
68
+ const waitForNumeric = async (
69
+ code: string,
70
+ timeoutMs = DEFAULT_TIMEOUT_MS,
71
+ ): Promise<string> => {
72
+ const deadline = Date.now() + timeoutMs;
73
+ while (Date.now() < deadline) {
74
+ for (const line of received) {
75
+ if (numericEquals(line, code)) return line;
76
+ }
77
+ await new Promise((r) => setTimeout(r, POLL_MS));
78
+ }
79
+ throw new Error(`timed out waiting for ${code}; got: ${JSON.stringify(received)}`);
80
+ };
81
+ const waitForLine = async (
82
+ predicate: (line: string) => boolean,
83
+ timeoutMs = DEFAULT_TIMEOUT_MS,
84
+ ): Promise<string> => {
85
+ const deadline = Date.now() + timeoutMs;
86
+ while (Date.now() < deadline) {
87
+ for (const line of received) {
88
+ if (predicate(line)) return line;
89
+ }
90
+ await new Promise((r) => setTimeout(r, POLL_MS));
91
+ }
92
+ throw new Error(`timed out; got: ${JSON.stringify(received)}`);
93
+ };
94
+ ws?.addEventListener('message', (ev: MessageEvent) => {
95
+ const data = ev.data as string;
96
+ for (const line of data.split('\r\n')) {
97
+ if (line.length > 0) received.push(line);
98
+ }
99
+ });
100
+
101
+ // ----- REGISTER: NICK + USER → 001 welcome + 376 end-of-MOTD.
102
+ ws?.send('NICK alice\r\n');
103
+ ws?.send('USER alice 0 * :Alice\r\n');
104
+ await waitForNumeric('001');
105
+ await waitForNumeric('376');
106
+
107
+ // ----- JOIN #test → JOIN echo + 353 NAMES + 366 end-of-names.
108
+ ws?.send('JOIN #test\r\n');
109
+ await waitForLine((l) => l.includes('JOIN') && l.includes('#test'));
110
+ await waitForNumeric('353');
111
+ await waitForNumeric('366');
112
+
113
+ // ----- PRIVMSG #test :hello. No echo expected (echo-message is opt-in).
114
+ ws?.send('PRIVMSG #test :hello\r\n');
115
+
116
+ // ----- QUIT :bye. Server closes the socket; we observe via close event.
117
+ ws?.send('QUIT :bye\r\n');
118
+ await new Promise<void>((resolve) => {
119
+ const timer = setTimeout(resolve, 500);
120
+ ws?.addEventListener('close', () => {
121
+ clearTimeout(timer);
122
+ resolve();
123
+ });
124
+ });
125
+
126
+ // Confirm the QUIT was broadcast to the channel by spawning a second
127
+ // client and observing alice's QUIT fan-out. (Sparse — the cf-adapter
128
+ // suite already covers fan-out in depth.)
129
+ });
130
+
131
+ it('allocates a fresh connection id per upgrade (no reuse across clients)', async () => {
132
+ const a = await worker.fetch(
133
+ new Request('https://irc.example.com/', { headers: { Upgrade: 'websocket' } }),
134
+ env,
135
+ );
136
+ const b = await worker.fetch(
137
+ new Request('https://irc.example.com/', { headers: { Upgrade: 'websocket' } }),
138
+ env,
139
+ );
140
+ expect(a.status).toBe(101);
141
+ expect(b.status).toBe(101);
142
+ // Both upgrades succeed; the worker must not collapse them into one DO.
143
+ // (accept() is required before any client-side interaction; the server
144
+ // side was accepted by ConnectionDO via acceptWebSocket.)
145
+ a.webSocket?.accept();
146
+ b.webSocket?.accept();
147
+ a.webSocket?.close();
148
+ b.webSocket?.close();
149
+ });
150
+ });
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "rootDir": "./src",
6
+ "outDir": "./dist",
7
+ "tsBuildInfoFile": "./.tsbuildinfo",
8
+ "types": ["@cloudflare/workers-types"]
9
+ },
10
+ "include": ["src/**/*.ts"],
11
+ "exclude": ["dist", "tests", "**/*.test.ts"],
12
+ "references": [
13
+ { "path": "../../packages/irc-core/tsconfig.build.json" },
14
+ { "path": "../../packages/irc-server/tsconfig.build.json" },
15
+ { "path": "../../packages/cf-adapter/tsconfig.build.json" }
16
+ ]
17
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": ["@cloudflare/workers-types"]
6
+ },
7
+ "include": [
8
+ "src/**/*.ts",
9
+ "tests/**/*.ts",
10
+ "node_modules/@cloudflare/vitest-pool-workers/types/cloudflare-test.d.ts"
11
+ ],
12
+ "exclude": ["dist"],
13
+ "references": [
14
+ { "path": "../../packages/irc-core/tsconfig.build.json" },
15
+ { "path": "../../packages/irc-server/tsconfig.build.json" },
16
+ { "path": "../../packages/cf-adapter/tsconfig.build.json" }
17
+ ]
18
+ }
@@ -0,0 +1,31 @@
1
+ import { cloudflareTest } from '@cloudflare/vitest-pool-workers';
2
+ import { defineConfig } from 'vitest/config';
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ include: ['tests/**/*.test.ts'],
7
+ coverage: {
8
+ provider: 'v8',
9
+ all: false,
10
+ include: ['src/**/*.ts'],
11
+ reporter: ['text', 'html', 'json-summary'],
12
+ thresholds: {
13
+ lines: 90,
14
+ functions: 90,
15
+ branches: 90,
16
+ statements: 90,
17
+ },
18
+ },
19
+ },
20
+ plugins: [
21
+ cloudflareTest({
22
+ wrangler: {
23
+ configPath: './wrangler.test.toml',
24
+ },
25
+ miniflare: {
26
+ // Ensure DO alarms and hibernation are supported in tests.
27
+ compatibilityFlags: ['nodejs_compat'],
28
+ },
29
+ }),
30
+ ],
31
+ });
@@ -0,0 +1,33 @@
1
+ #:schema node_modules/wrangler/config-schema.json
2
+ ##
3
+ ## Test-only wrangler config for the `apps/cf-worker` smoke suite.
4
+ ##
5
+ ## Consumed by `vitest.config.ts` via `@cloudflare/vitest-pool-workers`.
6
+ ## Mirrors the production `wrangler.toml` minus the staging environment
7
+ ## blocks — the smoke test exercises the default env.
8
+
9
+ name = "cf-worker-test"
10
+ main = "src/worker.ts"
11
+ compatibility_date = "2024-11-01"
12
+ compatibility_flags = ["nodejs_compat"]
13
+
14
+ [vars]
15
+ SERVER_NAME = "irc.example.com"
16
+ NETWORK_NAME = "ServerlessIRCd"
17
+ MOTD_LINES = "Welcome to the cf-worker smoke test."
18
+
19
+ [[durable_objects.bindings]]
20
+ name = "CONNECTION_DO"
21
+ class_name = "ConnectionDO"
22
+
23
+ [[durable_objects.bindings]]
24
+ name = "REGISTRY_DO"
25
+ class_name = "RegistryDO"
26
+
27
+ [[durable_objects.bindings]]
28
+ name = "CHANNEL_DO"
29
+ class_name = "ChannelDO"
30
+
31
+ [[migrations]]
32
+ tag = "v1"
33
+ new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO"]
@@ -0,0 +1,98 @@
1
+ #:schema node_modules/wrangler/config-schema.json
2
+ ##
3
+ ## Cloudflare Worker production config for ServerlessIRCd.
4
+ ##
5
+ ## Deploy:
6
+ ## pnpm deploy:cf:staging # wrangler deploy --env staging
7
+ ## pnpm deploy:cf:prod # wrangler deploy
8
+ ##
9
+ ## The `apps/cf-worker` worker is the public edge for the IRC service.
10
+ ## It owns the three Durable Object namespaces from the cf-adapter
11
+ ## (ConnectionDO, RegistryDO, ChannelDO) and routes inbound WebSocket
12
+ ## upgrades to per-connection ConnectionDO instances. See
13
+ ## `src/worker.ts` and PLAN §6.1.
14
+ ##
15
+ ## Secrets: NEVER put secrets (API tokens, server passwords, account
16
+ ## credentials) in this file. Use `wrangler secret put <NAME>` per
17
+ ## environment; they live in the Workers KV secrets store, not the repo.
18
+ ## As of v1 the worker does not consume any secret bindings — server
19
+ ## password, SASL `AccountStore`, and oper creds are future work.
20
+
21
+ name = "serverless-ircd"
22
+ main = "src/worker.ts"
23
+ compatibility_date = "2024-11-01"
24
+ compatibility_flags = ["nodejs_compat"]
25
+
26
+ ## Observable per-deployment knobs. Override per environment below.
27
+ ## Production deployments SHOULD override `SERVER_NAME` to the public
28
+ ## hostname clients will see in numerics (001, 005, etc.).
29
+ [vars]
30
+ SERVER_NAME = "irc.example.com"
31
+ NETWORK_NAME = "ServerlessIRCd"
32
+ MOTD_LINES = "Welcome to ServerlessIRCd.\nThis is a deployed Cloudflare Worker."
33
+
34
+ ## ---------------------------------------------------------------------------
35
+ ## Staging environment.
36
+ ## ---------------------------------------------------------------------------
37
+ ## `wrangler deploy --env staging` ships to the staging Worker + DO
38
+ ## namespace. The CI workflow (`.github/workflows/deploy-cf.yml`) deploys
39
+ ## here on every push to `main` and then runs the smoke e2e.
40
+ [env.staging]
41
+ name = "serverless-ircd-staging"
42
+
43
+ [env.staging.vars]
44
+ SERVER_NAME = "irc-staging.example.com"
45
+ NETWORK_NAME = "ServerlessIRCd (staging)"
46
+ MOTD_LINES = "Welcome to ServerlessIRCd staging.\nThis Worker was deployed by CI from the main branch."
47
+
48
+ ## ---------------------------------------------------------------------------
49
+ ## Durable Object bindings.
50
+ ## ---------------------------------------------------------------------------
51
+ ## Each binding maps a name (used by `Env` in `src/worker.ts`) to a DO
52
+ ## class exported from `main`. The classes themselves live in
53
+ ## `packages/cf-adapter/src/{connection,registry,channel}-do.ts` and are
54
+ ## re-exported from the worker entry.
55
+
56
+ # Default environment bindings.
57
+ [[durable_objects.bindings]]
58
+ name = "CONNECTION_DO"
59
+ class_name = "ConnectionDO"
60
+
61
+ [[durable_objects.bindings]]
62
+ name = "REGISTRY_DO"
63
+ class_name = "RegistryDO"
64
+
65
+ [[durable_objects.bindings]]
66
+ name = "CHANNEL_DO"
67
+ class_name = "ChannelDO"
68
+
69
+ # Staging environment bindings (wrangler requires per-env blocks).
70
+ [[env.staging.durable_objects.bindings]]
71
+ name = "CONNECTION_DO"
72
+ class_name = "ConnectionDO"
73
+
74
+ [[env.staging.durable_objects.bindings]]
75
+ name = "REGISTRY_DO"
76
+ class_name = "RegistryDO"
77
+
78
+ [[env.staging.durable_objects.bindings]]
79
+ name = "CHANNEL_DO"
80
+ class_name = "ChannelDO"
81
+
82
+ ## ---------------------------------------------------------------------------
83
+ ## Durable Object migrations.
84
+ ## ---------------------------------------------------------------------------
85
+ ## `tag` is the user-defined migration id; wrangler refuses to deploy if
86
+ ## the on-disk migrations don't match the live Workers state. `new_classes`
87
+ ## declares the three DO classes on first deploy; subsequent schema
88
+ ## changes (renames, deletions, splits) extend this list with their own
89
+ ## `tag` so the runtime can migrate persisted state.
90
+ ##
91
+ ## See https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/
92
+ [[migrations]]
93
+ tag = "v1"
94
+ new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO"]
95
+
96
+ [[env.staging.migrations]]
97
+ tag = "v1"
98
+ new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO"]
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@serverless-ircd/local-cli",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Runnable WebSocket IRC server using the in-memory runtime; manual-test harness and e2e fixture target",
6
+ "license": "BSD-3-Clause",
7
+ "type": "module",
8
+ "main": "./dist/main.js",
9
+ "bin": {
10
+ "local-cli": "./dist/main.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.build.json",
14
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
15
+ "start": "node --enable-source-maps ./dist/main.js",
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "coverage": "vitest run --coverage",
19
+ "clean": "rimraf dist coverage .tsbuildinfo .turbo"
20
+ },
21
+ "dependencies": {
22
+ "@serverless-ircd/in-memory-runtime": "workspace:*",
23
+ "@serverless-ircd/irc-core": "workspace:*",
24
+ "@serverless-ircd/irc-server": "workspace:*",
25
+ "ws": "^8.18.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^26.1.1",
29
+ "@types/ws": "^8.5.13",
30
+ "@vitest/coverage-v8": "^4.1.0",
31
+ "rimraf": "^6.0.0",
32
+ "typescript": "^5.6.0",
33
+ "vitest": "^4.1.0"
34
+ }
35
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Byte-accurate line scanner for the local-cli's TCP transport.
3
+ *
4
+ * TCP is a byte stream, not a message stream: a single `data` chunk may
5
+ * contain several IRC lines, a partial line, or a line split across two
6
+ * chunks. The {@link LineScanner} accumulates raw `Buffer` chunks and emits
7
+ * each complete line as a decoded UTF-8 string the moment it sees a line
8
+ * terminator. Decoding happens only on whole lines, so a multi-byte UTF-8
9
+ * sequence split across two TCP chunks is never corrupted.
10
+ *
11
+ * Line endings are tolerated per PLAN §9 transport rules: both CRLF
12
+ * (`\r\n`) and bare LF (`\n`) terminate a line; a lone CR in the middle of
13
+ * text is treated as data. The trailing `\r` of a CRLF pair is stripped
14
+ * before the line is returned.
15
+ *
16
+ * Mirrors `tools/tcp-ws-forwarder/src/line-scanner.ts` — the same algorithm
17
+ * applies on both sides of the WS↔TCP bridge so framing semantics match
18
+ * exactly when a real IRC client connects to local-cli directly.
19
+ */
20
+
21
+ export class LineScanner {
22
+ private carry: Buffer = Buffer.alloc(0);
23
+
24
+ /**
25
+ * Feeds a chunk of TCP bytes. Returns the complete lines found in this
26
+ * chunk (joined with any leftover carry from prior chunks), in order,
27
+ * with terminators removed. Any trailing unterminated bytes are retained
28
+ * internally for the next call.
29
+ */
30
+ push(chunk: Buffer): string[] {
31
+ const buf = Buffer.concat([this.carry, chunk]);
32
+ const lines: string[] = [];
33
+ let start = 0;
34
+ let nl = buf.indexOf(0x0a, start);
35
+ while (nl !== -1) {
36
+ let end = nl;
37
+ if (end > start && buf[end - 1] === 0x0d) {
38
+ end--;
39
+ }
40
+ lines.push(buf.toString('utf8', start, end));
41
+ start = nl + 1;
42
+ nl = buf.indexOf(0x0a, start);
43
+ }
44
+ this.carry = buf.subarray(start);
45
+ return lines;
46
+ }
47
+
48
+ /**
49
+ * Returns any unterminated tail still buffered (lossless), then clears
50
+ * the carry. The server does NOT call this on TCP close — an IRC line
51
+ * without a terminator is a protocol violation and is dropped — but the
52
+ * method is exposed for completeness and tested directly.
53
+ */
54
+ flush(): string[] {
55
+ if (this.carry.length === 0) return [];
56
+ const rest = this.carry.toString('utf8');
57
+ this.carry = Buffer.alloc(0);
58
+ return [rest];
59
+ }
60
+ }