@cosmicdrift/kumiko-framework 0.326.1 → 0.328.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 (55) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/auth-middleware-transport.test.ts +23 -1
  3. package/src/api/__tests__/client-ip.test.ts +20 -0
  4. package/src/api/__tests__/origin-middleware.test.ts +65 -0
  5. package/src/api/__tests__/websocket-route.test.ts +552 -0
  6. package/src/api/auth-middleware.ts +29 -7
  7. package/src/api/client-ip.ts +15 -2
  8. package/src/api/index.ts +24 -1
  9. package/src/api/jwt.ts +5 -0
  10. package/src/api/origin-middleware.ts +27 -1
  11. package/src/api/server.ts +161 -2
  12. package/src/api/websocket-route.ts +327 -0
  13. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +94 -1
  14. package/src/bun-db/query.ts +160 -23
  15. package/src/changes.json +54 -0
  16. package/src/crypto/__tests__/system-event-pii.test.ts +77 -0
  17. package/src/crypto/event-pii.ts +20 -4
  18. package/src/crypto/system-event-pii.ts +57 -0
  19. package/src/db/__tests__/apply-entity-event-removed-column.integration.test.ts +109 -0
  20. package/src/db/queries/backfill-pii.ts +184 -85
  21. package/src/db/queries/pii-backfill-state.ts +33 -0
  22. package/src/engine/__tests__/build-app-schema.test.ts +26 -0
  23. package/src/engine/__tests__/describe-workflow-step-error.test.ts +17 -0
  24. package/src/engine/__tests__/steps-mail-send.test.ts +6 -5
  25. package/src/engine/__tests__/steps-webhook-send.test.ts +16 -8
  26. package/src/engine/build-app-schema.ts +2 -0
  27. package/src/engine/define-feature.ts +1 -0
  28. package/src/engine/feature-builder-state.ts +3 -0
  29. package/src/engine/feature-ui-extensions.ts +52 -0
  30. package/src/engine/index.ts +5 -0
  31. package/src/engine/steps/describe-workflow-step-error.ts +6 -0
  32. package/src/engine/steps/mail-send.ts +5 -1
  33. package/src/engine/steps/retry.ts +13 -1
  34. package/src/engine/steps/webhook-send.ts +6 -16
  35. package/src/engine/types/index.ts +8 -0
  36. package/src/event-store/index.ts +4 -0
  37. package/src/event-store/provenance-append.ts +1 -1
  38. package/src/event-store/transfer.ts +2 -2
  39. package/src/i18n/required-surface-keys.ts +8 -0
  40. package/src/jobs/__tests__/jobs.integration.test.ts +31 -0
  41. package/src/jobs/job-runner.ts +18 -0
  42. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +45 -3
  43. package/src/migrations/__tests__/pii-event-backfill.integration.test.ts +457 -0
  44. package/src/migrations/index.ts +11 -0
  45. package/src/migrations/pending-rebuilds.ts +29 -3
  46. package/src/migrations/pii-event-backfill.ts +422 -0
  47. package/src/pipeline/append-event-core.ts +1 -8
  48. package/src/pipeline/irreversible-operation-gate.ts +4 -3
  49. package/src/pipeline/msp-rebuild.ts +7 -1
  50. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +65 -0
  51. package/src/search/__tests__/search-adapter.test.ts +15 -0
  52. package/src/search/in-memory-adapter.ts +6 -0
  53. package/src/search/meilisearch-adapter.ts +24 -0
  54. package/src/stack/test-stack.ts +26 -2
  55. package/src/ui-types/index.ts +1 -0
package/src/api/jwt.ts CHANGED
@@ -25,6 +25,8 @@ export type JwtPayload = {
25
25
  // Present when the app wires a `sessionCreator` callback (see sessions
26
26
  // feature). Absent → stateless-JWT mode, no revocation possible.
27
27
  jti?: string;
28
+ // Expiry as epoch seconds, so long-lived consumers (WebSocket) can enforce it after the upgrade.
29
+ exp?: number;
28
30
  };
29
31
 
30
32
  export type JwtHelper = {
@@ -184,6 +186,9 @@ export function createJwtHelper(
184
186
  if (typeof payload.jti === "string") {
185
187
  result.jti = payload.jti;
186
188
  }
189
+ if (typeof payload.exp === "number") {
190
+ result.exp = payload.exp;
191
+ }
187
192
  return result;
188
193
  },
189
194
  ttlSeconds,
@@ -31,7 +31,7 @@ export function isForeignCookieOrigin(c: Context, allowlist: ReadonlySet<string>
31
31
  return c.req.header("sec-fetch-site") === "cross-site";
32
32
  }
33
33
 
34
- function rejectOrigin(c: Context): Response {
34
+ export function rejectOrigin(c: Context): Response {
35
35
  return c.json(
36
36
  {
37
37
  error: {
@@ -45,6 +45,32 @@ function rejectOrigin(c: Context): Response {
45
45
  );
46
46
  }
47
47
 
48
+ // WebSocket upgrades are GETs, so originMiddleware/csrfMiddleware never see
49
+ // them — yet a cookie-authenticated upgrade from a foreign page is a
50
+ // cross-site WebSocket hijack. Bearer transports can't be set by a browser
51
+ // WebSocket and need no check.
52
+ export function isWebSocketOriginAllowed(
53
+ c: Context,
54
+ normalizedAllowlist: ReadonlySet<string> | undefined,
55
+ ): boolean {
56
+ if (getAuthTransport(c) !== "cookie") return true;
57
+ const origin = c.req.header("origin");
58
+ if (origin === undefined || origin === "null") return false;
59
+ let originHost: string;
60
+ try {
61
+ originHost = new URL(origin).host.toLowerCase();
62
+ } catch {
63
+ return false;
64
+ }
65
+ // With an allowlist there is deliberately no same-host fallback: wide
66
+ // cookieDomain setups are exactly where a sibling subdomain is the attacker.
67
+ if (normalizedAllowlist !== undefined && normalizedAllowlist.size > 0) {
68
+ return isOriginAllowed(origin, normalizedAllowlist);
69
+ }
70
+ const requestHost = c.req.header("host")?.toLowerCase();
71
+ return requestHost !== undefined && originHost === requestHost;
72
+ }
73
+
48
74
  // Server-side Origin-allowlist guard — an additional CSRF-hardening layer on
49
75
  // top of the double-submit token (csrf-middleware) for deployments that widen
50
76
  // the auth cookie across subdomains via AuthRoutesConfig.cookieDomain. A wide
package/src/api/server.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  type RunIn,
16
16
  type SessionUser,
17
17
  type TenantId,
18
+ type WebSocketRouteDefinition,
18
19
  type WriteResult,
19
20
  } from "../engine/types";
20
21
  import { createFileContext } from "../files/file-handle";
@@ -22,6 +23,7 @@ import type { FileRoutesOptions } from "../files/file-routes";
22
23
  import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes";
23
24
  import { makeFileProviderResolver } from "../files/provider-resolver";
24
25
  import type { Lifecycle } from "../lifecycle";
26
+ import { createFallbackLogger } from "../logging";
25
27
  import {
26
28
  createNoopProvider,
27
29
  DEFAULT_SENSITIVE_CONFIG,
@@ -60,7 +62,9 @@ import { assertUnreachable, generateId } from "../utils";
60
62
  import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS, Routes } from "./api-constants";
61
63
  import {
62
64
  type AnonymousAccessResolved,
65
+ type AuthSessionChecker,
63
66
  authMiddleware,
67
+ getAuthTokenExpiry,
64
68
  getUser,
65
69
  type TenantLifecycleStatusResolver,
66
70
  } from "./auth-middleware";
@@ -82,11 +86,17 @@ import {
82
86
  import { computeStrongEtag, etagMatches } from "./http-cache";
83
87
  import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
84
88
  import { observabilityMiddleware } from "./observability-middleware";
85
- import { assertOriginGuardConfig, normalizeOrigin, originMiddleware } from "./origin-middleware";
89
+ import {
90
+ assertOriginGuardConfig,
91
+ isWebSocketOriginAllowed,
92
+ normalizeOrigin,
93
+ originMiddleware,
94
+ rejectOrigin,
95
+ } from "./origin-middleware";
86
96
  import { patRouteGuard } from "./pat-route-guard";
87
97
  import { piiCiphertextResponseGuard } from "./pii-leak-guard";
88
98
  import { createDefaultSseBroker, type RedisSseBroker } from "./redis-sse-broker";
89
- import { requestContext } from "./request-context";
99
+ import { type RequestContextData, requestContext } from "./request-context";
90
100
  import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware";
91
101
  import {
92
102
  DEFAULT_MAX_REQUEST_BYTES,
@@ -98,6 +108,15 @@ import {
98
108
  import { createApiRoutes } from "./routes";
99
109
  import type { SseBroker } from "./sse-broker";
100
110
  import { createSseRoute } from "./sse-route";
111
+ import {
112
+ buildWebSocketSessionRevalidator,
113
+ createWebSocketConnectionLimiter,
114
+ hasWebSocketUpgradeServer,
115
+ isWebSocketUpgradeRequest,
116
+ WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER,
117
+ WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES,
118
+ type WebSocketConnectionLimiter,
119
+ } from "./websocket-route";
101
120
 
102
121
  export type ServerOptions = {
103
122
  registry: Registry;
@@ -985,6 +1004,39 @@ export function buildServer(options: ServerOptions): KumikoServer {
985
1004
  }
986
1005
  }
987
1006
 
1007
+ // r.webSocketRoute — GET routes under /api/ws/*, so the jwt/pat/rate-limit
1008
+ // chain above already ran. Origin/CSRF guards skip GETs, hence the own check.
1009
+ const webSocketOriginAllowlist = allowedOrigins?.length
1010
+ ? new Set(allowedOrigins.map(normalizeOrigin))
1011
+ : undefined;
1012
+ const webSocketRoutePaths = new Map<string, string>();
1013
+ const webSocketConnectionLimiter = createWebSocketConnectionLimiter();
1014
+ for (const feature of options.registry.features.values()) {
1015
+ for (const route of Object.values(feature.webSocketRoutes)) {
1016
+ const owner = webSocketRoutePaths.get(route.path);
1017
+ if (owner !== undefined) {
1018
+ throw new Error(
1019
+ `[kumiko] WebSocket route "${route.path}" is declared by both feature "${owner}" and ` +
1020
+ `"${feature.name}" — paths must be unique across features.`,
1021
+ );
1022
+ }
1023
+ webSocketRoutePaths.set(route.path, feature.name);
1024
+ app.get(
1025
+ route.path,
1026
+ buildWebSocketRouteHandler(route, {
1027
+ dispatcher,
1028
+ clientIpResolver,
1029
+ originAllowlist: webSocketOriginAllowlist,
1030
+ ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
1031
+ ...(tenantLifecycleResolver
1032
+ ? { resolveTenantLifecycleStatus: tenantLifecycleResolver }
1033
+ : {}),
1034
+ connectionLimiter: webSocketConnectionLimiter,
1035
+ }),
1036
+ );
1037
+ }
1038
+ }
1039
+
988
1040
  // extraRoutes (kumiko-framework#3050) — declarative HTTP-routes with a
989
1041
  // Pflicht `entry` tier. Mounted after r.httpRoute for the same reason: an
990
1042
  // extraRoute dispatching through `dispatcher` builds Hono's matcher, so
@@ -1218,6 +1270,113 @@ function unauthenticatedResponse(c: import("hono").Context): Response {
1218
1270
  );
1219
1271
  }
1220
1272
 
1273
+ type WebSocketRouteHandlerDeps = {
1274
+ readonly dispatcher: Dispatcher;
1275
+ readonly clientIpResolver: ClientIpResolver;
1276
+ readonly originAllowlist: ReadonlySet<string> | undefined;
1277
+ readonly sessionChecker?: AuthSessionChecker;
1278
+ readonly resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver;
1279
+ readonly connectionLimiter: WebSocketConnectionLimiter;
1280
+ };
1281
+
1282
+ const webSocketLog = createFallbackLogger("websocket");
1283
+
1284
+ function webSocketErrorResponse(
1285
+ c: import("hono").Context,
1286
+ status: 400 | 426 | 429 | 501,
1287
+ code: string,
1288
+ message: string,
1289
+ ): Response {
1290
+ return c.json({ error: { code, httpStatus: status, message } }, status);
1291
+ }
1292
+
1293
+ function buildWebSocketRouteHandler(
1294
+ route: WebSocketRouteDefinition,
1295
+ shared: WebSocketRouteHandlerDeps,
1296
+ // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
1297
+ ): (c: import("hono").Context<any, any>) => Promise<Response> {
1298
+ return async (c) => {
1299
+ if (!isWebSocketOriginAllowed(c, shared.originAllowlist)) return rejectOrigin(c);
1300
+ const user = getUser(c);
1301
+ if (isMissingOrAnonymousUser(user)) return unauthenticatedResponse(c);
1302
+ if (!isWebSocketUpgradeRequest(c.req.raw)) {
1303
+ return webSocketErrorResponse(
1304
+ c,
1305
+ 426,
1306
+ "websocket_upgrade_required",
1307
+ "this route only accepts WebSocket upgrade requests",
1308
+ );
1309
+ }
1310
+ const honoEnv: unknown = c.env;
1311
+ if (!hasWebSocketUpgradeServer(honoEnv)) {
1312
+ const message = "webSocketUpgradeFetch not wired: pass it to buildBunServeOptions";
1313
+ webSocketLog.error(message, { path: route.path });
1314
+ return webSocketErrorResponse(c, 501, "websocket_upgrade_not_wired", message);
1315
+ }
1316
+ // The slot is taken before connect (a refused attempt must not run it) and stays synchronous with
1317
+ // the check, so concurrent upgrades can't overshoot the cap. Every path without a live socket frees it.
1318
+ const releaseConnectionSlot = shared.connectionLimiter.tryAcquire(
1319
+ `${route.path}\n${user.tenantId}\n${user.id}`,
1320
+ route.maxConnectionsPerUser ?? WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER,
1321
+ );
1322
+ if (!releaseConnectionSlot) {
1323
+ return webSocketErrorResponse(
1324
+ c,
1325
+ 429,
1326
+ "websocket_connection_limit",
1327
+ "too many open WebSocket connections for this user",
1328
+ );
1329
+ }
1330
+ let upgraded = false;
1331
+ try {
1332
+ const handlers = await route.connect(c, {
1333
+ user,
1334
+ query: (type, payload) => shared.dispatcher.query(type, payload, user),
1335
+ write: (type, payload) => shared.dispatcher.write(type, payload, user),
1336
+ clientIp: shared.clientIpResolver.resolve(clientIpSourceFromHonoContext(c)),
1337
+ });
1338
+ if (handlers instanceof Response) return handlers;
1339
+
1340
+ const captured = requestContext.get();
1341
+ const requestContextData = captured ? withoutAbortSignal(captured) : undefined;
1342
+ const revalidateSession = buildWebSocketSessionRevalidator({
1343
+ user,
1344
+ sessionChecker: shared.sessionChecker,
1345
+ resolveTenantLifecycleStatus: shared.resolveTenantLifecycleStatus,
1346
+ tokenExpiresAtSec: getAuthTokenExpiry(c),
1347
+ });
1348
+ upgraded = honoEnv.server.upgrade(c.req.raw, {
1349
+ data: {
1350
+ handlers,
1351
+ maxMessageBytes: route.maxMessageBytes ?? WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES,
1352
+ requestContextData,
1353
+ revalidateSession,
1354
+ releaseConnectionSlot,
1355
+ },
1356
+ });
1357
+ if (!upgraded) {
1358
+ return webSocketErrorResponse(
1359
+ c,
1360
+ 400,
1361
+ "websocket_upgrade_failed",
1362
+ "the WebSocket upgrade was rejected",
1363
+ );
1364
+ }
1365
+ // Bun ignores the returned Response after a successful upgrade.
1366
+ return new Response(null);
1367
+ } finally {
1368
+ if (!upgraded) releaseConnectionSlot();
1369
+ }
1370
+ };
1371
+ }
1372
+
1373
+ // The request's AbortSignal fires when the upgrade request ends, which would
1374
+ // abort every later handler running under the captured context.
1375
+ function withoutAbortSignal(data: RequestContextData): RequestContextData {
1376
+ const { signal: _signal, ...rest } = data;
1377
+ return rest;
1378
+ }
1379
+
1221
1380
  function isKnownExtraRouteEntry(entry: unknown): entry is ExtraRouteEntry {
1222
1381
  return (
1223
1382
  entry === ExtraRouteEntries.anonymous ||
@@ -0,0 +1,327 @@
1
+ import {
2
+ WEBSOCKET_BACKPRESSURE_LIMIT_BYTES,
3
+ WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER,
4
+ WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES,
5
+ WEBSOCKET_MAX_PAYLOAD_BYTES,
6
+ WEBSOCKET_ROUTE_PATH_PREFIX,
7
+ type WebSocketConnection,
8
+ type WebSocketMessageData,
9
+ type WebSocketSessionHandlers,
10
+ } from "@cosmicdrift/kumiko-types/websocket-route";
11
+ import type { SessionUser } from "../engine/types";
12
+ import { createFallbackLogger } from "../logging";
13
+ import {
14
+ type AuthSessionChecker,
15
+ resolveTenantTeardownStatus,
16
+ sessionCheckStatus,
17
+ type TenantLifecycleStatusResolver,
18
+ } from "./auth-middleware";
19
+ import { type RequestContextData, requestContext } from "./request-context";
20
+
21
+ export {
22
+ WEBSOCKET_BACKPRESSURE_LIMIT_BYTES,
23
+ WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER,
24
+ WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES,
25
+ WEBSOCKET_MAX_PAYLOAD_BYTES,
26
+ WEBSOCKET_ROUTE_PATH_PREFIX,
27
+ };
28
+
29
+ // Must stay below ingress-nginx's default 60 s proxy-read-timeout, or idle sockets get cut.
30
+ export const WEBSOCKET_HEARTBEAT_INTERVAL_MS = 25_000;
31
+
32
+ const CLOSE_POLICY_VIOLATION = 1008;
33
+ const CLOSE_MESSAGE_TOO_BIG = 1009;
34
+ const CLOSE_INTERNAL_ERROR = 1011;
35
+ const CLOSE_TRY_AGAIN_LATER = 1013;
36
+
37
+ // Consecutive failed session-store checks before a socket is closed: rides out a
38
+ // short store blip, but a socket must not stay unverified forever.
39
+ export const WEBSOCKET_REVALIDATION_FAILURE_LIMIT = 3;
40
+
41
+ const log = createFallbackLogger("websocket");
42
+
43
+ export type KumikoWebSocketData = {
44
+ readonly handlers: WebSocketSessionHandlers;
45
+ readonly maxMessageBytes: number;
46
+ // Socket callbacks run outside the Hono request, so the dispatcher's
47
+ // AsyncLocalStorage context is captured at upgrade time and re-entered.
48
+ readonly requestContextData: RequestContextData | undefined;
49
+ readonly revalidateSession: WebSocketSessionRevalidator | undefined;
50
+ // Frees this socket's per-user connection slot; called once on close.
51
+ readonly releaseConnectionSlot?: (() => void) | undefined;
52
+ };
53
+
54
+ export type WebSocketRevalidation = "live" | "expired" | "invalid";
55
+ export type WebSocketSessionRevalidator = () => Promise<WebSocketRevalidation>;
56
+
57
+ // Structural so Bun.Server satisfies it without this package depending on bun types.
58
+ export type WebSocketUpgradeServer = {
59
+ upgrade(req: Request, options: { data: KumikoWebSocketData }): boolean;
60
+ };
61
+
62
+ // Second argument to `app.fetch(req, env)` on upgrade requests; plain requests
63
+ // keep passing the bare socket-address string.
64
+ export type KumikoServeEnv = {
65
+ readonly socketAddress?: string;
66
+ readonly server?: WebSocketUpgradeServer;
67
+ };
68
+
69
+ export type KumikoServerWebSocket = {
70
+ readonly data: KumikoWebSocketData;
71
+ send(data: WebSocketMessageData): unknown;
72
+ close(code?: number, reason?: string): void;
73
+ ping(): unknown;
74
+ };
75
+
76
+ export function isWebSocketUpgradeRequest(req: Request): boolean {
77
+ return req.headers.get("upgrade")?.toLowerCase() === "websocket";
78
+ }
79
+
80
+ export function hasWebSocketUpgradeServer(
81
+ honoEnv: unknown,
82
+ ): honoEnv is { readonly server: WebSocketUpgradeServer } {
83
+ return (
84
+ typeof honoEnv === "object" &&
85
+ honoEnv !== null &&
86
+ "server" in honoEnv &&
87
+ typeof honoEnv.server === "object" &&
88
+ honoEnv.server !== null &&
89
+ "upgrade" in honoEnv.server &&
90
+ typeof honoEnv.server.upgrade === "function"
91
+ );
92
+ }
93
+
94
+ function sameRoles(a: readonly string[], b: readonly string[]): boolean {
95
+ const left = new Set(a);
96
+ return left.size === new Set(b).size && b.every((role) => left.has(role));
97
+ }
98
+
99
+ // Same checks the HTTP guard runs per request, plus the JWT's own expiry (the
100
+ // upgrade request passed the guard once, the socket outlives that). query/write
101
+ // stay bound to the upgrade-time user, so changed roles close the socket
102
+ // instead of silently serving stale permissions.
103
+ export function buildWebSocketSessionRevalidator(deps: {
104
+ readonly user: SessionUser;
105
+ readonly sessionChecker?: AuthSessionChecker | undefined;
106
+ readonly resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver | undefined;
107
+ /** JWT exp, epoch seconds. */
108
+ readonly tokenExpiresAtSec?: number | undefined;
109
+ readonly nowMs?: () => number;
110
+ }): WebSocketSessionRevalidator | undefined {
111
+ const { user, sessionChecker, resolveTenantLifecycleStatus, tokenExpiresAtSec } = deps;
112
+ const nowMs = deps.nowMs ?? Date.now;
113
+ const checkSession = sessionChecker && user.sid ? sessionChecker : undefined;
114
+ if (!checkSession && !resolveTenantLifecycleStatus && tokenExpiresAtSec === undefined) {
115
+ return undefined;
116
+ }
117
+ return async () => {
118
+ if (tokenExpiresAtSec !== undefined && nowMs() >= tokenExpiresAtSec * 1000) return "expired";
119
+ if (checkSession && user.sid) {
120
+ const result = await checkSession(user.sid, user.id);
121
+ if (sessionCheckStatus(result) !== "live") return "invalid";
122
+ if (typeof result === "object" && !sameRoles(result.roles, user.roles)) return "invalid";
123
+ }
124
+ const teardown = await resolveTenantTeardownStatus(user.tenantId, resolveTenantLifecycleStatus);
125
+ return teardown === undefined ? "live" : "invalid";
126
+ };
127
+ }
128
+
129
+ // ponytail: per-process counter, so the cap is per pod, not cluster-wide; move to Redis if that matters.
130
+ export type WebSocketConnectionLimiter = {
131
+ readonly tryAcquire: (key: string, max: number) => (() => void) | undefined;
132
+ };
133
+
134
+ export function createWebSocketConnectionLimiter(): WebSocketConnectionLimiter {
135
+ const counts = new Map<string, number>();
136
+ return {
137
+ tryAcquire(key, max) {
138
+ const current = counts.get(key) ?? 0;
139
+ if (current >= max) return undefined;
140
+ counts.set(key, current + 1);
141
+ let released = false;
142
+ return () => {
143
+ // skip: slot already released
144
+ if (released) return;
145
+ released = true;
146
+ const remaining = (counts.get(key) ?? 1) - 1;
147
+ if (remaining <= 0) counts.delete(key);
148
+ else counts.set(key, remaining);
149
+ };
150
+ },
151
+ };
152
+ }
153
+
154
+ type ConnectionState = {
155
+ timer: ReturnType<typeof setInterval> | undefined;
156
+ revalidationFailures: number;
157
+ revalidating: boolean;
158
+ // onOpen/onMessage run one after another in arrival order; steps never reject.
159
+ chain: Promise<void>;
160
+ closed: boolean;
161
+ readonly controller: AbortController;
162
+ };
163
+
164
+ const connectionStates = new WeakMap<KumikoWebSocketData, ConnectionState>();
165
+
166
+ function stateOf(ws: KumikoServerWebSocket): ConnectionState {
167
+ let state = connectionStates.get(ws.data);
168
+ if (!state) {
169
+ state = {
170
+ timer: undefined,
171
+ revalidationFailures: 0,
172
+ revalidating: false,
173
+ chain: Promise.resolve(),
174
+ closed: false,
175
+ controller: new AbortController(),
176
+ };
177
+ connectionStates.set(ws.data, state);
178
+ }
179
+ return state;
180
+ }
181
+
182
+ function toConnection(ws: KumikoServerWebSocket): WebSocketConnection {
183
+ return {
184
+ send: (data) => {
185
+ ws.send(data);
186
+ },
187
+ close: (code, reason) => ws.close(code, reason),
188
+ signal: stateOf(ws).controller.signal,
189
+ };
190
+ }
191
+
192
+ function reportHandlerFailure(error: unknown): void {
193
+ log.error("websocket handler failed", {
194
+ error: error instanceof Error ? error.message : String(error),
195
+ });
196
+ }
197
+
198
+ // Runs outside the chain: a hung onMessage must not hold back onClose cleanup.
199
+ function runInRequestContext(ws: KumikoServerWebSocket, run: () => Promise<void>): Promise<void> {
200
+ const { requestContextData } = ws.data;
201
+ return requestContextData ? requestContext.run(requestContextData, run) : run();
202
+ }
203
+
204
+ function enqueue(ws: KumikoServerWebSocket, run: () => void | Promise<void>): void {
205
+ const state = stateOf(ws);
206
+ // skip: the socket is gone, nothing may start after close
207
+ if (state.closed) return;
208
+ const step = async (): Promise<void> => {
209
+ // skip: queued behind a slower handler and the socket closed meanwhile
210
+ if (state.closed) return;
211
+ try {
212
+ await run();
213
+ } catch (error) {
214
+ reportHandlerFailure(error);
215
+ ws.close(CLOSE_INTERNAL_ERROR, "internal error");
216
+ }
217
+ };
218
+ state.chain = state.chain.then(() => runInRequestContext(ws, step));
219
+ }
220
+
221
+ function recordRevalidationFailure(
222
+ ws: KumikoServerWebSocket,
223
+ state: ConnectionState,
224
+ reason: string,
225
+ ): void {
226
+ // One failing check must not drop every live socket during a store blip, but persistent failure must.
227
+ state.revalidationFailures += 1;
228
+ log.error("websocket heartbeat failed", {
229
+ error: reason,
230
+ consecutiveFailures: state.revalidationFailures,
231
+ });
232
+ if (state.revalidationFailures >= WEBSOCKET_REVALIDATION_FAILURE_LIMIT) {
233
+ ws.close(CLOSE_TRY_AGAIN_LATER, "try again later");
234
+ }
235
+ }
236
+
237
+ async function heartbeat(ws: KumikoServerWebSocket): Promise<void> {
238
+ const state = stateOf(ws);
239
+ try {
240
+ ws.ping();
241
+ } catch (error) {
242
+ log.error("websocket ping failed", {
243
+ error: error instanceof Error ? error.message : String(error),
244
+ });
245
+ }
246
+ const { revalidateSession } = ws.data;
247
+ // skip: no revalidation wired for this socket
248
+ if (!revalidateSession) return;
249
+ // A store that never answers must not pile up checks; an unanswered one counts as a failed check.
250
+ if (state.revalidating) {
251
+ recordRevalidationFailure(ws, state, "previous session check still pending");
252
+ // skip: the pending check owns the verdict; the failure was logged above
253
+ return;
254
+ }
255
+ state.revalidating = true;
256
+ try {
257
+ const verdict = await revalidateSession();
258
+ state.revalidationFailures = 0;
259
+ if (verdict === "expired") ws.close(CLOSE_POLICY_VIOLATION, "session expired");
260
+ else if (verdict === "invalid") ws.close(CLOSE_POLICY_VIOLATION, "session changed");
261
+ } catch (error) {
262
+ recordRevalidationFailure(ws, state, error instanceof Error ? error.message : String(error));
263
+ } finally {
264
+ state.revalidating = false;
265
+ }
266
+ }
267
+
268
+ function toMessageData(message: string | Uint8Array): WebSocketMessageData {
269
+ if (typeof message === "string") return message;
270
+ // Copy: a Buffer view can sit on a larger pooled ArrayBuffer and be reused after the handler returns.
271
+ return new Uint8Array(message);
272
+ }
273
+
274
+ function messageByteLength(message: string | Uint8Array): number {
275
+ return typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
276
+ }
277
+
278
+ export function createKumikoWebSocketHandler(
279
+ options: { readonly heartbeatIntervalMs?: number } = {},
280
+ ) {
281
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? WEBSOCKET_HEARTBEAT_INTERVAL_MS;
282
+ return {
283
+ open(ws: KumikoServerWebSocket): void {
284
+ stateOf(ws).timer = setInterval(() => {
285
+ void heartbeat(ws);
286
+ }, heartbeatIntervalMs);
287
+ const { onOpen } = ws.data.handlers;
288
+ if (onOpen) enqueue(ws, () => onOpen(toConnection(ws)));
289
+ },
290
+ message(ws: KumikoServerWebSocket, message: string | Uint8Array): void {
291
+ const byteLength = messageByteLength(message);
292
+ if (byteLength > ws.data.maxMessageBytes) {
293
+ ws.close(CLOSE_MESSAGE_TOO_BIG, "message too big");
294
+ log.warn("websocket message over cap", {
295
+ byteLength,
296
+ maxMessageBytes: ws.data.maxMessageBytes,
297
+ });
298
+ return;
299
+ }
300
+ const { onMessage } = ws.data.handlers;
301
+ if (onMessage) enqueue(ws, () => onMessage(toMessageData(message), toConnection(ws)));
302
+ },
303
+ close(ws: KumikoServerWebSocket, code: number, reason: string): void {
304
+ const state = stateOf(ws);
305
+ if (state.timer !== undefined) {
306
+ clearInterval(state.timer);
307
+ state.timer = undefined;
308
+ }
309
+ ws.data.releaseConnectionSlot?.();
310
+ state.closed = true;
311
+ state.controller.abort();
312
+ const { onClose } = ws.data.handlers;
313
+ if (onClose) {
314
+ const cleanup = async (): Promise<void> => {
315
+ try {
316
+ await onClose(code, reason, toConnection(ws));
317
+ } catch (error) {
318
+ reportHandlerFailure(error);
319
+ }
320
+ };
321
+ void runInRequestContext(ws, cleanup);
322
+ }
323
+ },
324
+ };
325
+ }
326
+
327
+ export const kumikoWebSocketHandler = createKumikoWebSocketHandler();