serverless-ircd 0.7.0 → 0.8.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 (135) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +273 -29
  4. package/README.md +155 -55
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-worker/package.json +1 -1
  10. package/apps/cf-worker/src/worker.ts +4 -4
  11. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  12. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  13. package/apps/cf-worker/wrangler.test.toml +6 -6
  14. package/apps/cf-worker/wrangler.toml +7 -5
  15. package/apps/local-cli/package.json +1 -1
  16. package/apps/local-cli/src/config-loader.ts +8 -0
  17. package/apps/local-cli/src/main.ts +16 -0
  18. package/apps/local-cli/src/server.ts +1 -0
  19. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  20. package/apps/web/landing/index.html +14 -16
  21. package/apps/web/package.json +2 -2
  22. package/apps/web/scripts/build.mjs +23 -16
  23. package/apps/web/src/build-env.ts +1 -1
  24. package/apps/web/src/config-schema.ts +6 -6
  25. package/apps/web/tests/build-smoke.test.ts +14 -14
  26. package/apps/web/tests/config-schema.test.ts +1 -1
  27. package/docs/AWS-Deployment.md +21 -8
  28. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  29. package/docs/PlanExtensions.md +113 -3
  30. package/docs/Release-Process.md +23 -13
  31. package/docs/Services.md +546 -0
  32. package/docs/WebClientGuide.md +32 -31
  33. package/package.json +2 -2
  34. package/packages/aws-adapter/package.json +1 -1
  35. package/packages/aws-adapter/src/aws-runtime.ts +5 -0
  36. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  37. package/packages/aws-adapter/src/config-loader.ts +11 -0
  38. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  39. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  40. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  41. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  42. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  43. package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
  44. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  45. package/packages/aws-adapter/src/index.ts +4 -0
  46. package/packages/aws-adapter/src/stats.ts +6 -1
  47. package/packages/aws-adapter/src/tables.ts +34 -4
  48. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  49. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  50. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  51. package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
  52. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  53. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  54. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  55. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  56. package/packages/cf-adapter/package.json +1 -1
  57. package/packages/cf-adapter/src/config-loader.ts +11 -0
  58. package/packages/cf-adapter/src/connection-do.ts +112 -2
  59. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  60. package/packages/cf-adapter/src/env.ts +8 -0
  61. package/packages/cf-adapter/src/index.ts +5 -0
  62. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  63. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  64. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  65. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  66. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  67. package/packages/in-memory-runtime/package.json +1 -1
  68. package/packages/irc-core/package.json +1 -1
  69. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  70. package/packages/irc-core/src/commands/account-auth.ts +172 -0
  71. package/packages/irc-core/src/commands/chanserv.ts +882 -0
  72. package/packages/irc-core/src/commands/hostserv.ts +487 -0
  73. package/packages/irc-core/src/commands/index.ts +12 -0
  74. package/packages/irc-core/src/commands/join.ts +164 -8
  75. package/packages/irc-core/src/commands/markread.ts +202 -0
  76. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  77. package/packages/irc-core/src/commands/mode.ts +96 -4
  78. package/packages/irc-core/src/commands/nickserv.ts +390 -0
  79. package/packages/irc-core/src/commands/oper.ts +18 -1
  80. package/packages/irc-core/src/commands/operserv.ts +346 -0
  81. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  82. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  83. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  84. package/packages/irc-core/src/commands/registration.ts +61 -6
  85. package/packages/irc-core/src/commands/sasl.ts +18 -49
  86. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  87. package/packages/irc-core/src/commands/topic.ts +37 -0
  88. package/packages/irc-core/src/config.ts +36 -5
  89. package/packages/irc-core/src/effects.ts +56 -1
  90. package/packages/irc-core/src/ports.ts +1653 -84
  91. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  92. package/packages/irc-core/src/state/channel.ts +21 -1
  93. package/packages/irc-core/src/state/connection.ts +25 -1
  94. package/packages/irc-core/src/types.ts +48 -12
  95. package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
  96. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  97. package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
  98. package/packages/irc-core/tests/commands/join.test.ts +393 -1
  99. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  100. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  101. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  102. package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
  103. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  104. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  105. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  106. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  107. package/packages/irc-core/tests/commands/registration.test.ts +788 -14
  108. package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
  109. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  111. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  112. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  113. package/packages/irc-core/tests/config.test.ts +49 -5
  114. package/packages/irc-core/tests/effects.test.ts +19 -0
  115. package/packages/irc-core/tests/message-store.test.ts +63 -0
  116. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  117. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  118. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  119. package/packages/irc-server/package.json +1 -1
  120. package/packages/irc-server/src/actor.ts +71 -16
  121. package/packages/irc-server/src/dispatch.ts +94 -7
  122. package/packages/irc-server/src/routing.ts +19 -0
  123. package/packages/irc-server/tests/actor.test.ts +623 -12
  124. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  125. package/packages/irc-server/tests/routing.test.ts +6 -0
  126. package/packages/irc-test-support/package.json +1 -1
  127. package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
  128. package/packages/irc-test-support/src/index.ts +1 -0
  129. package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
  130. package/tools/ci-hardening/package.json +1 -1
  131. package/tools/load-test/package.json +1 -1
  132. package/tools/tcp-ws-forwarder/package.json +1 -1
  133. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
  134. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  135. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -1,10 +1,11 @@
1
1
  # Web Client Guide
2
2
 
3
3
  End-to-end contributor/operator doc for the ServerlessIRCd web client: a
4
- vendored **Kiwi IRC** SPA served at `/app/` and a static project landing
5
- page served at `/` by the Cloudflare Worker. The browser opens a native
6
- `wss://` WebSocket straight to the Worker's existing IRC-over-WebSocket
7
- endpoint — no proxy, gateway, or transport adaptation in the hot path.
4
+ vendored **Kiwi IRC** SPA served at `/webclient/` and a static project
5
+ landing page served at `/` by the Cloudflare Worker. The browser opens a
6
+ native `wss://` WebSocket straight to the Worker's existing
7
+ IRC-over-WebSocket endpoint — no proxy, gateway, or transport adaptation
8
+ in the hot path.
8
9
 
9
10
  Cross-reference: `PLAN.md` §6.1 (CF mapping), `docs/PlanWebClient.md`
10
11
  (design), `docs/Cloudflare-Deployment-Guide.md` (Worker deploy),
@@ -23,7 +24,7 @@ following only this doc.
23
24
  ┌──────────────────────────────────────────────────────────────┐
24
25
  │ Browser │
25
26
  │ GET / → landing page (project front door) │
26
- │ GET /app/ → Kiwi IRC SPA (index.html + JS bundle) │
27
+ │ GET /webclient/ → Kiwi IRC SPA (index.html + JS bundle) │
27
28
  │ wss://.../ → one IRC message per WS text frame │
28
29
  └──────────────────────────────────────────────────────────────┘
29
30
  │ │
@@ -48,8 +49,8 @@ Path layout on the Worker:
48
49
  | Path | Served by | Content |
49
50
  |------------|--------------------------|----------------------------------|
50
51
  | `/` | `[assets]` binding | `dist/index.html` (landing page) |
51
- | `/app/` | `[assets]` binding | `dist/app/index.html` (Kiwi SPA) |
52
- | `/app/static/...` | `[assets]` binding | Kiwi JS/CSS/themes + baked config |
52
+ | `/webclient/` | `[assets]` binding | `dist/webclient/index.html` (Kiwi SPA) |
53
+ | `/webclient/static/...` | `[assets]` binding | Kiwi JS/CSS/themes + baked config |
53
54
  | `/health` | `worker.ts` handler | Plaintext liveness string |
54
55
  | `/` (WS upgrade) | `worker.ts` → `ConnectionDO` | IRC session |
55
56
 
@@ -94,8 +95,8 @@ pnpm install
94
95
  pnpm --filter web build:staging
95
96
  # Equivalent: pnpm --filter @serverless-ircd/web build:staging
96
97
  # → apps/web/dist/index.html (landing page)
97
- # → apps/web/dist/app/index.html (Kiwi SPA)
98
- # → apps/web/dist/app/static/config.json (baked, env-specific)
98
+ # → apps/web/dist/webclient/index.html (Kiwi SPA)
99
+ # → apps/web/dist/webclient/static/config.json (baked, env-specific)
99
100
 
100
101
  # 4. Build the rest of the workspace (Worker imports compiled dist/).
101
102
  pnpm build
@@ -103,7 +104,7 @@ pnpm build
103
104
  # 5. Serve everything locally (Worker + SPA + landing page).
104
105
  pnpm --filter @serverless-ircd/cf-worker dev
105
106
  # → http://localhost:8787/ landing page
106
- # → http://localhost:8787/app/ Kiwi SPA
107
+ # → http://localhost:8787/webclient/ Kiwi SPA
107
108
  # → ws://localhost:8787/ IRC-over-WebSocket (same endpoint)
108
109
 
109
110
  # 6. Deploy staging (Worker + assets in one command).
@@ -111,9 +112,9 @@ pnpm deploy:cf:staging
111
112
  # → wrangler deploy --env staging
112
113
  ```
113
114
 
114
- Open `http://localhost:8787/app/` in a browser — Kiwi boots, reads
115
- `/app/static/config.json`, and opens a `wss://` (or `ws://` in local
116
- dev) connection to the same origin.
115
+ Open `http://localhost:8787/webclient/` in a browser — Kiwi boots, reads
116
+ `/webclient/static/config.json`, and opens a `wss://` (or `ws://` in
117
+ local dev) connection to the same origin.
117
118
 
118
119
  > **Forgot the submodule?** `apps/web/scripts/build.mjs` fails fast:
119
120
  > `Kiwi upstream missing at .../upstream. Run
@@ -128,7 +129,7 @@ The build is an orchestrated layering over the upstream Kiwi build —
128
129
 
129
130
  ### 4.1 Per-environment config matrix
130
131
 
131
- | `pnpm ...` command | Reads | Bakes into `dist/app/static/config.json` |
132
+ | `pnpm ...` command | Reads | Bakes into `dist/webclient/static/config.json` |
132
133
  |---|---|---|
133
134
  | `--filter web build` | `static/config.json` | default (staging-shaped) config |
134
135
  | `--filter web build:staging` | `static/config.staging.json` | staging config |
@@ -149,17 +150,17 @@ malformed config fails the build rather than shipping a broken SPA.
149
150
  Node ≥ 17 the OpenSSL legacy provider is enabled for webpack 5's md4
150
151
  hashing.
151
152
  3. **Patch `upstream/vue.config.js`** `publicPath: ''` →
152
- `publicPath: '/app/'`, run `yarn build`, then **restore** the file
153
- in `finally` so the submodule stays clean. This is why every hashed
154
- asset URL comes out `/app/static/js/app.<hash>.js` instead of
155
- `static/js/...`, which would 404 at root.
156
- 4. **Copy `upstream/dist/` → `apps/web/dist/app/`.**
153
+ `publicPath: '/webclient/'`, run `yarn build`, then **restore** the
154
+ file in `finally` so the submodule stays clean. This is why every
155
+ hashed asset URL comes out `/webclient/static/js/app.<hash>.js`
156
+ instead of `static/js/...`, which would 404 at root.
157
+ 4. **Copy `upstream/dist/` → `apps/web/dist/webclient/`.**
157
158
  5. **Rewrite bare `static/...` refs** in the baked `index.html` (e.g.
158
159
  the favicon, which webpack doesn't run through `publicPath`) to
159
- `/app/static/...`.
160
+ `/webclient/static/...`.
160
161
  6. **Validate + bake the per-env config**: read
161
162
  `static/config.<env>.json`, run `parseKiwiConfig`, re-serialise
162
- (stable key order), write to `dist/app/static/config.json`.
163
+ (stable key order), write to `dist/webclient/static/config.json`.
163
164
  7. **Copy the landing page** `landing/index.html` → `dist/index.html`.
164
165
 
165
166
  Output is reproducible: same checkout + same `--env` → byte-identical
@@ -308,8 +309,8 @@ pnpm --filter @serverless-ircd/cf-worker dev
308
309
  Then:
309
310
 
310
311
  - `http://localhost:8787/` — landing page.
311
- - `http://localhost:8787/app/` — Kiwi SPA (reads
312
- `/app/static/config.json`, opens `ws://localhost:8787/`).
312
+ - `http://localhost:8787/webclient/` — Kiwi SPA (reads
313
+ `/webclient/static/config.json`, opens `ws://localhost:8787/`).
313
314
  - `http://localhost:8787/health` — plaintext liveness.
314
315
 
315
316
  Smoke the WS path directly (no browser needed):
@@ -403,7 +404,7 @@ deployment; don't change the default config.
403
404
 
404
405
  The landing page (`apps/web/landing/index.html`) is the project's front
405
406
  door: a visitor hitting `/` learns what ServerlessIRCd is and clicks
406
- through to `/app/` to launch the client. It is intentionally
407
+ through to `/webclient/` to launch the client. It is intentionally
407
408
  framework-free (pure HTML + inline CSS, no JS) so it renders without
408
409
  JavaScript and costs nothing to serve.
409
410
 
@@ -413,16 +414,16 @@ To customise:
413
414
  blocks directly. The CSS variables at the top of the `<style>` block
414
415
  (`--bg`, `--surface`, `--accent`, …) theme the whole page in one
415
416
  place.
416
- - **Links** — the page links to `/app/` (the SPA), `docs/`, and the
417
- source repo. Update the repo URLs and the docs path to match your
418
- deployment.
417
+ - **Links** — the page links to `/webclient/` (the SPA), `docs/`, and
418
+ the source repo. Update the repo URLs and the docs path to match
419
+ your deployment.
419
420
  - **Accessibility** — keep the declared viewport meta tag and the
420
421
  no-`<script>` invariant; both are asserted by
421
422
  `tests/build-smoke.test.ts`. The build copies the file verbatim to
422
423
  `dist/index.html`, so what you author is exactly what is served.
423
424
 
424
425
  Re-run `pnpm --filter web build` (any `--env`) after edits; the build
425
- test suite will fail loudly if you accidentally remove the `/app/`
426
+ test suite will fail loudly if you accidentally remove the `/webclient/`
426
427
  link, the docs link, or the viewport tag.
427
428
 
428
429
  ---
@@ -457,7 +458,7 @@ and check the WS upgrade:
457
458
  `WEB_ORIGINS` if you're cross-origin, or confirm the SPA and Worker
458
459
  are same-origin.
459
460
 
460
- ### 10.3 Asset 404s at `/static/...` (instead of `/app/static/...`)
461
+ ### 10.3 Asset 404s at `/static/...` (instead of `/webclient/static/...`)
461
462
 
462
463
  The `publicPath` patch in step 3 of the build didn't take. Confirm
463
464
  `upstream/vue.config.js` still contains a `publicPath: ''` literal to
@@ -483,8 +484,8 @@ packages.
483
484
  ### 10.6 Build-smoke tests fail in CI but pass locally
484
485
 
485
486
  The `dist/`-dependent assertions in `tests/build-smoke.test.ts` are
486
- gated on `existsSync(dist/app/index.html)` and are skipped when the
487
- SPA hasn't been built. CI runs `pnpm --filter web build` (or
487
+ gated on `existsSync(dist/webclient/index.html)` and are skipped when
488
+ the SPA hasn't been built. CI runs `pnpm --filter web build` (or
488
489
  `pnpm build`) before `pnpm --filter web test`, so the assertions fire
489
490
  there. If you run `pnpm --filter web test` locally without building,
490
491
  only the landing-page source assertions run — that's expected, not a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serverless-ircd",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
6
6
  "license": "BSD-3-Clause",
@@ -17,7 +17,7 @@
17
17
  "typescript": "^5.9.3",
18
18
  "vite": "^7.3.6",
19
19
  "vitest": "^4.1.10",
20
- "@serverless-ircd/aws-adapter": "0.7.0"
20
+ "@serverless-ircd/aws-adapter": "0.8.0"
21
21
  },
22
22
  "scripts": {
23
23
  "build": "turbo run build",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-adapter",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "description": "AWS Lambda + DynamoDB adapter: AwsRuntime implementing IrcRuntime + $connect/$disconnect/$default handlers",
6
6
  "license": "BSD-3-Clause",
@@ -57,6 +57,7 @@ import {
57
57
  toSnapshot as toConnSnapshot,
58
58
  } from '@serverless-ircd/irc-core';
59
59
  import type { IrcRuntime } from '@serverless-ircd/irc-server';
60
+ import { decrementConnectionCount } from './connection-counter.js';
60
61
  import {
61
62
  type MarshalledChannelMember,
62
63
  type MarshalledChannelMeta,
@@ -733,6 +734,10 @@ export async function cleanupConnection(
733
734
  await dynamo.send(
734
735
  new DeleteCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
735
736
  );
737
+ // Release the admission slot this connection reserved at `$connect`. The
738
+ // meta counter is best-effort (decrement floors at 0; a missing/zero
739
+ // counter is a silent no-op), so a drifted count never fails teardown.
740
+ await decrementConnectionCount(dynamo, tables.Connections);
736
741
 
737
742
  if (item.nick !== undefined) {
738
743
  try {
@@ -70,4 +70,10 @@ export const TABLE_DEFS: Record<TableName, TableProps> = {
70
70
  partitionKey: { name: 'account', type: AttributeType.STRING },
71
71
  billingMode: BillingMode.PAY_PER_REQUEST,
72
72
  },
73
+ Services: {
74
+ tableName: 'Services',
75
+ partitionKey: { name: 'pk', type: AttributeType.STRING },
76
+ sortKey: { name: 'sk', type: AttributeType.STRING },
77
+ billingMode: BillingMode.PAY_PER_REQUEST,
78
+ },
73
79
  };
@@ -35,6 +35,14 @@ export interface LambdaConfigEnv {
35
35
  CHANNEL_PREFIXES?: string;
36
36
  OPER_USER?: string;
37
37
  OPER_PASSWORD?: string;
38
+ /**
39
+ * Server-password gate. Treat as an AWS secret (Secrets Manager or
40
+ * SSM `SecureString`); the loader threads it through to
41
+ * `ServerConfig.serverPassword` so the registration reducer requires
42
+ * `PASS <value>` (or a SASL-identified connection) before
43
+ * `001 RPL_WELCOME`. Undefined / empty disables the gate.
44
+ */
45
+ SERVER_PASSWORD?: string;
38
46
  MAX_CHANNELS_PER_USER?: string;
39
47
  MAX_TARGETS_PER_COMMAND?: string;
40
48
  NICK_LEN?: string;
@@ -87,6 +95,9 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
87
95
  },
88
96
  ];
89
97
  }
98
+ if (env.SERVER_PASSWORD !== undefined) {
99
+ input.serverPassword = env.SERVER_PASSWORD;
100
+ }
90
101
  if (env.MAX_CHANNELS_PER_USER !== undefined) {
91
102
  input.maxChannelsPerUser = Number.parseInt(env.MAX_CHANNELS_PER_USER, 10);
92
103
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * O(1) admission counter for the AWS `$connect` handler.
3
+ *
4
+ * Replaces the previous full-table `Scan COUNT` over `Connections` (which
5
+ * grew linearly with the connection count and made `$connect` latency
6
+ * spike into the multi-second range). The live connection count is kept in
7
+ * a single meta row inside the `Connections` table and updated atomically:
8
+ *
9
+ * - `$connect` reserves a slot with {@link incrementConnectionCount}.
10
+ * - `$disconnect` / the gone-connection sweeper release a slot with
11
+ * {@link decrementConnectionCount} (via `cleanupConnection`).
12
+ *
13
+ * The meta row is keyed by the {@link CONNECTION_COUNT_META_ID} sentinel
14
+ * and deliberately carries **no** `idleSince`, so the sweeper (which only
15
+ * tears down rows with a stale numeric `idleSince`) skips it; stats and
16
+ * the sweeper filter this id out of their connection lists.
17
+ */
18
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
19
+ import { UpdateCommand } from '@aws-sdk/lib-dynamodb';
20
+
21
+ /**
22
+ * Sentinel partition key for the connection-count meta row. Real APIGW
23
+ * connection ids are opaque alphanumeric tokens, so this `__meta:`-prefixed
24
+ * value never collides.
25
+ */
26
+ export const CONNECTION_COUNT_META_ID = '__meta:connectionCount__';
27
+
28
+ /** Attribute name holding the integer count on the meta row. */
29
+ export const CONNECTION_COUNT_ATTR = 'count';
30
+
31
+ /**
32
+ * Atomically reserves one connection slot: increments the counter and
33
+ * returns the **new** value (i.e. including this reservation). Creates the
34
+ * meta row on first use via `if_not_exists`.
35
+ *
36
+ * Idempotent at the DynamoDB level: a single `UpdateItem` with
37
+ * `ReturnValues=UPDATED_NEW` is the whole operation — no read-modify-write
38
+ * loop, so concurrent `$connect` invocations never lose an increment.
39
+ */
40
+ export async function incrementConnectionCount(
41
+ dynamo: DynamoDBDocumentClient,
42
+ tableName: string,
43
+ ): Promise<number> {
44
+ const res = await dynamo.send(
45
+ new UpdateCommand({
46
+ TableName: tableName,
47
+ Key: { connectionId: CONNECTION_COUNT_META_ID },
48
+ UpdateExpression: 'SET #c = if_not_exists(#c, :zero) + :one',
49
+ ExpressionAttributeNames: { '#c': CONNECTION_COUNT_ATTR },
50
+ ExpressionAttributeValues: { ':zero': 0, ':one': 1 },
51
+ ReturnValues: 'UPDATED_NEW',
52
+ }),
53
+ );
54
+ const updated = res.Attributes as Record<string, unknown> | undefined;
55
+ const count = updated?.[CONNECTION_COUNT_ATTR];
56
+ return typeof count === 'number' ? count : 0;
57
+ }
58
+
59
+ /**
60
+ * Atomically releases one connection slot: decrements the counter, floored
61
+ * at 0. The `attribute_exists(count) AND count > :zero` condition makes a
62
+ * stray decrement (e.g. a sweeper pass after a counter-reset deploy) a
63
+ * silent no-op instead of driving the count negative.
64
+ *
65
+ * Errors are swallowed: the counter is best-effort and self-correcting
66
+ * (a drifted count only tightens or loosens the admission cap slightly;
67
+ * it never crashes connection teardown).
68
+ */
69
+ export async function decrementConnectionCount(
70
+ dynamo: DynamoDBDocumentClient,
71
+ tableName: string,
72
+ ): Promise<void> {
73
+ try {
74
+ await dynamo.send(
75
+ new UpdateCommand({
76
+ TableName: tableName,
77
+ Key: { connectionId: CONNECTION_COUNT_META_ID },
78
+ UpdateExpression: 'SET #c = #c - :one',
79
+ ConditionExpression: 'attribute_exists(#c) AND #c > :zero',
80
+ ExpressionAttributeNames: { '#c': CONNECTION_COUNT_ATTR },
81
+ ExpressionAttributeValues: { ':zero': 0, ':one': 1 },
82
+ }),
83
+ );
84
+ } catch {
85
+ // ConditionalCheckFailedException (counter missing / already 0) → no-op.
86
+ // Any other error is also swallowed: teardown must not fail because the
87
+ // admission counter drifted.
88
+ }
89
+ }