ocuclaw 1.2.4 → 1.3.1

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 (60) hide show
  1. package/README.md +21 -6
  2. package/dist/config/runtime-config.js +84 -3
  3. package/dist/domain/activity-status-adapter.js +138 -605
  4. package/dist/domain/activity-status-arbiter.js +109 -0
  5. package/dist/domain/activity-status-labels.js +906 -0
  6. package/dist/domain/code-span-regions.js +103 -0
  7. package/dist/domain/conversation-state.js +14 -1
  8. package/dist/domain/debug-store.js +56 -182
  9. package/dist/domain/glasses-ui-content-summary.js +62 -0
  10. package/dist/domain/glasses-ui-system-prompt.js +28 -0
  11. package/dist/domain/message-emoji-allowlist.js +16 -0
  12. package/dist/domain/message-emoji-filter.js +33 -55
  13. package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
  14. package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
  15. package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
  16. package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
  17. package/dist/domain/tagged-span-parser.js +121 -0
  18. package/dist/domain/tagged-span-strip.js +38 -0
  19. package/dist/even-ai/even-ai-endpoint.js +91 -0
  20. package/dist/even-ai/even-ai-run-waiter.js +14 -0
  21. package/dist/even-ai/even-ai-settings-store.js +14 -0
  22. package/dist/gateway/gateway-bridge.js +14 -2
  23. package/dist/gateway/gateway-timing-ledger.js +457 -0
  24. package/dist/gateway/openclaw-client.js +462 -38
  25. package/dist/index.js +28 -1
  26. package/dist/runtime/downstream-handler.js +754 -83
  27. package/dist/runtime/ocuclaw-settings-store.js +74 -31
  28. package/dist/runtime/plugin-version-service.js +23 -0
  29. package/dist/runtime/protocol-adapter.js +9 -0
  30. package/dist/runtime/provider-usage-select.js +168 -0
  31. package/dist/runtime/relay-client-nudge-controller.js +553 -0
  32. package/dist/runtime/relay-core.js +1293 -225
  33. package/dist/runtime/relay-health-monitor.js +172 -0
  34. package/dist/runtime/relay-operation-registry.js +263 -0
  35. package/dist/runtime/relay-service.js +201 -1
  36. package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
  37. package/dist/runtime/relay-worker-entry.js +32 -0
  38. package/dist/runtime/relay-worker-health.js +272 -0
  39. package/dist/runtime/relay-worker-protocol.js +281 -0
  40. package/dist/runtime/relay-worker-queue.js +202 -0
  41. package/dist/runtime/relay-worker-supervisor.js +1004 -0
  42. package/dist/runtime/relay-worker-transport.js +1051 -0
  43. package/dist/runtime/session-context-service.js +189 -0
  44. package/dist/runtime/session-service.js +638 -27
  45. package/dist/runtime/upstream-runtime.js +1167 -60
  46. package/dist/tools/device-info-tool.js +242 -0
  47. package/dist/tools/glasses-ui-cron.js +427 -0
  48. package/dist/tools/glasses-ui-descriptors.js +261 -0
  49. package/dist/tools/glasses-ui-limits.js +21 -0
  50. package/dist/tools/glasses-ui-paint-floor.js +99 -0
  51. package/dist/tools/glasses-ui-recipes.js +581 -0
  52. package/dist/tools/glasses-ui-surfaces.js +278 -0
  53. package/dist/tools/glasses-ui-template.js +182 -0
  54. package/dist/tools/glasses-ui-tool.js +1111 -0
  55. package/dist/tools/session-title-tool.js +209 -0
  56. package/dist/version.js +2 -0
  57. package/openclaw.plugin.json +163 -15
  58. package/package.json +14 -5
  59. package/skills/glasses-ui/SKILL.md +156 -0
  60. package/dist/runtime/downstream-server.js +0 -1891
@@ -1,1891 +0,0 @@
1
- import * as WebSocketModule from "ws";
2
-
3
- // `ws` exposes different server entrypoints across ESM/CJS consumers.
4
- const WebSocket =
5
- WebSocketModule.default || WebSocketModule.WebSocket || WebSocketModule;
6
- const WebSocketServer =
7
- WebSocketModule.WebSocketServer || WebSocketModule.Server || WebSocket.Server;
8
-
9
- function normalizeLogger(logger) {
10
- if (!logger || typeof logger !== "object") {
11
- return console;
12
- }
13
- return {
14
- info: typeof logger.info === "function" ? logger.info.bind(logger) : console.log,
15
- warn: typeof logger.warn === "function" ? logger.warn.bind(logger) : console.warn,
16
- error: typeof logger.error === "function" ? logger.error.bind(logger) : console.error,
17
- debug:
18
- typeof logger.debug === "function" ? logger.debug.bind(logger) : console.debug,
19
- };
20
- }
21
-
22
- // --- Factory ---
23
-
24
- /**
25
- * Create a downstream WebSocket server.
26
- *
27
- * Manages WebSocket transport for downstream clients (Even App,
28
- * commander.html). Handles token-based authentication, client tracking,
29
- * and message delegation to the downstream handler.
30
- *
31
- * @param {object} opts
32
- * @param {object} opts.handler
33
- * Downstream handler instance (from createDownstreamHandler).
34
- * @param {() => string|null} opts.getCurrentPages
35
- * Returns formatted pages JSON string, or null if no pages yet.
36
- * @param {() => string|null} opts.getCurrentStatus
37
- * Returns formatted status JSON string, or null if no status yet.
38
- * @param {() => string|null} [opts.getCurrentDebugConfig]
39
- * Returns formatted debug-config snapshot JSON string for app/WebUI clients.
40
- * @param {() => {pagesRevision?: number, statusRevision?: number}|null} [opts.getCurrentResumeState]
41
- * Returns current server-side resume revisions for pages/status.
42
- * @param {number} opts.port
43
- * WebSocket server port (0 for OS-assigned).
44
- * @param {string} opts.host
45
- * WebSocket server bind address.
46
- * @param {string} opts.token
47
- * Expected authentication token.
48
- * @param {(meta: object) => void} [opts.onClientConnected]
49
- * Optional callback for client connection events.
50
- * @param {(meta: object) => void} [opts.onClientDisconnected]
51
- * Optional callback for client disconnection events.
52
- * @param {(meta: object) => void} [opts.onTransportControl]
53
- * Optional callback for transport control frames handled before app message routing.
54
- * @returns {object} Server instance with broadcast, unicast, getClientIds, close.
55
- */
56
- function createDownstreamServer(opts) {
57
- const logger = normalizeLogger(opts.logger);
58
- const handler = opts.handler;
59
- const getCurrentPages = opts.getCurrentPages;
60
- const getCurrentStatus = opts.getCurrentStatus;
61
- const getCurrentDebugConfig = opts.getCurrentDebugConfig || null;
62
- const getCurrentResumeState = opts.getCurrentResumeState || null;
63
- const token = opts.token;
64
- const onClientConnected = opts.onClientConnected || null;
65
- const onClientDisconnected = opts.onClientDisconnected || null;
66
- const onTransportControl = opts.onTransportControl || null;
67
- const protocolHelloTimeoutMs = Number.isFinite(opts.protocolHelloTimeoutMs)
68
- ? Math.max(0, Math.floor(opts.protocolHelloTimeoutMs))
69
- : 120;
70
- const externalDebugToolsEnabled = opts.externalDebugToolsEnabled !== false;
71
- const resumeHandshakeTimeoutMs = Number.isFinite(opts.resumeHandshakeTimeoutMs)
72
- ? Math.max(0, Math.floor(opts.resumeHandshakeTimeoutMs))
73
- : 120;
74
- const nudgeActiveIntervalMs = Number.isFinite(opts.nudgeActiveIntervalMs)
75
- ? Math.max(1, Math.floor(opts.nudgeActiveIntervalMs))
76
- : 150;
77
- const nudgeSlowIntervalMs = Number.isFinite(opts.nudgeSlowIntervalMs)
78
- ? Math.max(1, Math.floor(opts.nudgeSlowIntervalMs))
79
- : 1000;
80
- const nudgeIdleDeactivateMs = Number.isFinite(opts.nudgeIdleDeactivateMs)
81
- ? Math.max(0, Math.floor(opts.nudgeIdleDeactivateMs))
82
- : 5000;
83
- const nudgeHeartbeatIntervalMs = Number.isFinite(opts.nudgeHeartbeatIntervalMs)
84
- ? Math.max(1, Math.floor(opts.nudgeHeartbeatIntervalMs))
85
- : 10000;
86
- const nudgeHardTimeoutMs = Number.isFinite(opts.nudgeHardTimeoutMs)
87
- ? Math.max(1, Math.floor(opts.nudgeHardTimeoutMs))
88
- : 60000;
89
- const nudgeStaleHeartbeatThresholdMs = nudgeHeartbeatIntervalMs * 2;
90
- const RENDER_NUDGE_FRAME = JSON.stringify({ type: "render_nudge" });
91
-
92
- /** @type {Map<string, WebSocket>} */
93
- const clients = new Map();
94
- /** @type {Map<string, {selectedVersion: "v2"|null, supportedProtocolVersions: string[], clientName: string|null, clientVersion: string|null, sessionKey: string|null, reason: string|null}>} */
95
- const protocolState = new Map();
96
- /** @type {Map<string, {pagesSynced: boolean, statusSynced: boolean, approvalsSynced: boolean, debugConfigSynced: boolean, resumeReceived: boolean, timer: any}>} */
97
- const syncState = new Map();
98
- /** @type {Map<string, {visibilityState: "hidden"|"visible"|"blurred"|null, streamChars: number|null, lastHeartbeatAtMs: number|null, lastRelayStreamingActivityAtMs: number|null, interactionStage: "idle"|"listening"|"voice_handoff"|"thinking"|"streaming"|"post_turn_drain", cadenceBucket: "idle"|"active_non_stream"|"active_stream", nudgeActive: boolean, nudgeIntervalMs: number|null, nudgeStartedAtMs: number|null, lastNudgeAtMs: number|null, stalledHeartbeatCount: number, nudgeTimer: any, idleDeactivateTimer: any, staleHeartbeatTimer: any, hardTimeoutTimer: any}>} */
99
- const clientNudgeState = new Map();
100
- /** @type {Map<string, {clientDebugEnabled: boolean|null, runtimeDiagnosticsVisible: boolean|null, perfNoisyDebugMuted: boolean|null, perfPayloadLiteMode: boolean|null, activeSessionKey: string|null, bundleIdentity: {kind: string|null, mode: string|null, host: string|null, port: number|null, servedDistPath: string|null}|null, emittedAtMs: number|null, updatedAtMs: number}>} */
101
- const clientReadinessSnapshotState = new Map();
102
- /** @type {Map<string, {requesterClientId: string, targetClientId: string, createdAtMs: number}>} */
103
- const pendingReadinessProbeRequests = new Map();
104
- /** @type {Map<string, string>} */
105
- const unresolvedApprovals = new Map();
106
- let nextClientId = 1;
107
- const APP_PROTOCOL = {
108
- approvalRequest: "ocuclaw.approval.request",
109
- approvalResolved: "ocuclaw.approval.resolved",
110
- debugConfigSnapshot: "ocuclaw.debug.config.snapshot",
111
- pages: "ocuclaw.view.pages.snapshot",
112
- readinessProbeAck: "ocuclaw.readiness.probe.ack",
113
- readinessProbeRequest: "ocuclaw.readiness.probe.request",
114
- readinessSnapshot: "ocuclaw.readiness.snapshot",
115
- resume: "ocuclaw.sync.resume",
116
- resumeAck: "ocuclaw.sync.resume.ack",
117
- status: "ocuclaw.runtime.status",
118
- };
119
- const REMOVED_V1_APP_TYPES = new Set([
120
- "approvalResponse",
121
- "eventDebug",
122
- "getEvenAiSettings",
123
- "getOcuClawSettings",
124
- "getModelsCatalog",
125
- "getSkills",
126
- "getSessionModelConfig",
127
- "getSessions",
128
- "newChat",
129
- "newSession",
130
- "resume",
131
- "send",
132
- "setEvenAiSettings",
133
- "setOcuClawSettings",
134
- "setSessionModelConfig",
135
- "slashCommand",
136
- "subscribeProtocol",
137
- "switchSession",
138
- ]);
139
-
140
- function truncateForLog(value, maxChars = 120) {
141
- if (typeof value !== "string") return "";
142
- if (value.length <= maxChars) return value;
143
- return `${value.slice(0, maxChars)}...`;
144
- }
145
-
146
- function updateClientRole(meta, messageType, clientKind = "unknown") {
147
- if (!meta || typeof messageType !== "string") return;
148
- if (!meta.firstMessageType) meta.firstMessageType = messageType;
149
-
150
- if (clientKind === "debug") {
151
- meta.role = "debug";
152
- return;
153
- }
154
- if (meta.role === "debug") {
155
- return;
156
- }
157
-
158
- const nextRole =
159
- messageType === "remote-control" ? "control" : "app";
160
- if (meta.role === "unknown") {
161
- meta.role = nextRole;
162
- return;
163
- }
164
- if (meta.role !== nextRole) {
165
- meta.role = "mixed";
166
- }
167
- }
168
-
169
- function parseRevision(value) {
170
- if (!Number.isFinite(Number(value))) return null;
171
- const num = Math.floor(Number(value));
172
- if (num < 0) return null;
173
- return num;
174
- }
175
-
176
- function parseBool(value) {
177
- if (value === true || value === false) return value;
178
- if (typeof value === "number") return value !== 0;
179
- if (typeof value !== "string") return false;
180
- const normalized = value.trim().toLowerCase();
181
- return normalized === "true" || normalized === "1" || normalized === "yes";
182
- }
183
-
184
- function parseOptionalBoolean(value) {
185
- if (value === true || value === false) return value;
186
- if (typeof value === "number") return value !== 0;
187
- if (typeof value !== "string") return null;
188
- const normalized = value.trim().toLowerCase();
189
- if (normalized === "true" || normalized === "1" || normalized === "yes") {
190
- return true;
191
- }
192
- if (normalized === "false" || normalized === "0" || normalized === "no") {
193
- return false;
194
- }
195
- return null;
196
- }
197
-
198
- function parseOptionalTrimmedString(value) {
199
- if (typeof value !== "string") return null;
200
- const trimmed = value.trim();
201
- return trimmed ? trimmed : null;
202
- }
203
-
204
- function parseVisibilityState(value) {
205
- const normalized = parseOptionalTrimmedString(value);
206
- if (!normalized) return null;
207
- const lowered = normalized.toLowerCase();
208
- return lowered === "hidden" || lowered === "visible" || lowered === "blurred"
209
- ? lowered
210
- : null;
211
- }
212
-
213
- function parseVisibilityControl(value) {
214
- if (!value || value.type !== "visibility") {
215
- return null;
216
- }
217
- const state = parseVisibilityState(value.state);
218
- if (!state) {
219
- return null;
220
- }
221
- return { type: "visibility", state };
222
- }
223
-
224
- function parseOptionalNonNegativeInt(value) {
225
- if (value === null || value === undefined) return null;
226
- if (!Number.isFinite(Number(value))) return null;
227
- const num = Math.floor(Number(value));
228
- return num >= 0 ? num : null;
229
- }
230
-
231
- function parseDrainCompleteControl(value) {
232
- if (!value || value.type !== "drain_complete") {
233
- return null;
234
- }
235
- return { type: "drain_complete" };
236
- }
237
-
238
- function parseEnrichedPing(value) {
239
- if (!value || value.type !== "ping") {
240
- return null;
241
- }
242
- return {
243
- type: "ping",
244
- ts: Number.isFinite(Number(value.ts)) ? Number(value.ts) : null,
245
- streamChars: parseOptionalNonNegativeInt(value.streamChars),
246
- visibilityState: parseVisibilityState(value.visibilityState),
247
- };
248
- }
249
-
250
- function normalizeProtocolVersion(value) {
251
- const normalized = parseOptionalTrimmedString(value);
252
- if (!normalized) return null;
253
- const lowered = normalized.toLowerCase();
254
- return lowered === "v1" || lowered === "v2" ? lowered : null;
255
- }
256
-
257
- function parseSupportedProtocolVersions(value) {
258
- if (!Array.isArray(value)) {
259
- return [];
260
- }
261
- const versions = [];
262
- for (const entry of value) {
263
- const version = normalizeProtocolVersion(entry);
264
- if (version && !versions.includes(version)) {
265
- versions.push(version);
266
- }
267
- }
268
- return versions;
269
- }
270
-
271
- function parseProtocolHello(value) {
272
- if (!value || value.type !== "protocolHello") {
273
- return null;
274
- }
275
- return {
276
- supportedProtocolVersions: parseSupportedProtocolVersions(
277
- value.supportedProtocolVersions || value.supportedVersions,
278
- ),
279
- preferredProtocolVersion:
280
- normalizeProtocolVersion(value.preferredProtocolVersion) || "v2",
281
- clientName:
282
- parseOptionalTrimmedString(value.clientName) ||
283
- parseOptionalTrimmedString(value.clientId),
284
- clientVersion: parseOptionalTrimmedString(value.clientVersion),
285
- sessionKey: parseOptionalTrimmedString(value.sessionKey),
286
- readinessSnapshot: parseReadinessSnapshot(value.readinessSnapshot),
287
- };
288
- }
289
-
290
- function parseReadinessBundleIdentity(value) {
291
- if (!value || typeof value !== "object") {
292
- return null;
293
- }
294
- const kind = parseOptionalTrimmedString(value.kind || value.lane);
295
- const mode = parseOptionalTrimmedString(value.mode);
296
- const host = parseOptionalTrimmedString(value.host);
297
- const port = parseOptionalNonNegativeInt(value.port);
298
- const servedDistPath =
299
- parseOptionalTrimmedString(value.servedDistPath) ||
300
- parseOptionalTrimmedString(value.staticDir);
301
- if (!kind && !mode && !host && port === null && !servedDistPath) {
302
- return null;
303
- }
304
- return {
305
- kind: kind || null,
306
- mode: mode || null,
307
- host: host || null,
308
- port,
309
- servedDistPath: servedDistPath || null,
310
- };
311
- }
312
-
313
- function parseReadinessSnapshot(value) {
314
- if (!value || typeof value !== "object") {
315
- return null;
316
- }
317
- const clientDebugEnabled = parseOptionalBoolean(value.clientDebugEnabled);
318
- const runtimeDiagnosticsVisible = parseOptionalBoolean(
319
- value.runtimeDiagnosticsVisible,
320
- );
321
- const perfNoisyDebugMuted = parseOptionalBoolean(value.perfNoisyDebugMuted);
322
- const perfPayloadLiteMode = parseOptionalBoolean(value.perfPayloadLiteMode);
323
- const activeSessionKey =
324
- parseOptionalTrimmedString(value.activeSessionKey) ||
325
- parseOptionalTrimmedString(value.sessionKey);
326
- const bundleIdentity = parseReadinessBundleIdentity(
327
- value.bundleIdentity || value.bundle,
328
- );
329
- const emittedAtMs = parseOptionalNonNegativeInt(value.emittedAtMs);
330
- if (
331
- clientDebugEnabled === null &&
332
- runtimeDiagnosticsVisible === null &&
333
- perfNoisyDebugMuted === null &&
334
- perfPayloadLiteMode === null &&
335
- !activeSessionKey &&
336
- !bundleIdentity &&
337
- emittedAtMs === null
338
- ) {
339
- return null;
340
- }
341
- return {
342
- clientDebugEnabled,
343
- runtimeDiagnosticsVisible,
344
- perfNoisyDebugMuted,
345
- perfPayloadLiteMode,
346
- activeSessionKey: activeSessionKey || null,
347
- bundleIdentity,
348
- emittedAtMs,
349
- updatedAtMs: Date.now(),
350
- };
351
- }
352
-
353
- function parseReadinessProbeAck(value) {
354
- if (!value || value.type !== APP_PROTOCOL.readinessProbeAck) {
355
- return null;
356
- }
357
- const requestId = parseOptionalTrimmedString(value.requestId);
358
- if (!requestId) {
359
- return null;
360
- }
361
- return {
362
- requestId,
363
- ok: value.ok !== false,
364
- reasonCode: parseOptionalTrimmedString(value.reasonCode),
365
- message: parseOptionalTrimmedString(value.message),
366
- activeSessionKey:
367
- parseOptionalTrimmedString(value.activeSessionKey) ||
368
- parseOptionalTrimmedString(value.sessionKey),
369
- emittedAtMs: parseOptionalNonNegativeInt(value.emittedAtMs),
370
- };
371
- }
372
-
373
- function describeProtocolClient(state) {
374
- if (!state) return "unknown";
375
- const clientName = parseOptionalTrimmedString(state.clientName);
376
- const clientVersion = parseOptionalTrimmedString(state.clientVersion);
377
- if (clientName && clientVersion) {
378
- return `${clientName}/${clientVersion}`;
379
- }
380
- return clientName || "unknown";
381
- }
382
-
383
- function classifyClientKindFromName(clientName) {
384
- const normalizedName = parseOptionalTrimmedString(clientName);
385
- if (!normalizedName) return "unknown";
386
- const normalized = normalizedName.toLowerCase();
387
- if (normalized === "debugctl") {
388
- return "debug";
389
- }
390
- return "app";
391
- }
392
-
393
- function classifyClientKind(state) {
394
- if (!state) return "unknown";
395
- const clientName = parseOptionalTrimmedString(state.clientName);
396
- if (!clientName) return "unknown";
397
- return classifyClientKindFromName(clientName);
398
- }
399
-
400
- function countConnectedAppClients(excludeClientId = null) {
401
- let count = 0;
402
- for (const [clientId, ws] of clients) {
403
- if (excludeClientId && clientId === excludeClientId) {
404
- continue;
405
- }
406
- if (!ws || ws.readyState !== WebSocket.OPEN) {
407
- continue;
408
- }
409
- if (classifyClientKind(protocolState.get(clientId)) !== "app") {
410
- continue;
411
- }
412
- count += 1;
413
- }
414
- return count;
415
- }
416
-
417
- function isAppClient(clientId) {
418
- return classifyClientKind(protocolState.get(clientId)) === "app";
419
- }
420
-
421
- function closeConnectedAppClients(opts = {}) {
422
- const excludeClientId = parseOptionalTrimmedString(opts.excludeClientId);
423
- const requestedReason = parseOptionalTrimmedString(opts.reason);
424
- const reason = requestedReason || "debug_injected_transport_loss";
425
- const closedClientIds = [];
426
- for (const [clientId, ws] of clients) {
427
- if (excludeClientId && clientId === excludeClientId) {
428
- continue;
429
- }
430
- if (!isAppClient(clientId)) {
431
- continue;
432
- }
433
- if (
434
- !ws ||
435
- (ws.readyState !== WebSocket.OPEN && ws.readyState !== WebSocket.CONNECTING)
436
- ) {
437
- continue;
438
- }
439
- const protocol = protocolState.get(clientId);
440
- logger.warn(
441
- `[downstream] ${clientId} debug injected transport loss reason="${reason}" client=${describeProtocolClient(protocol)} kind=${classifyClientKind(protocol)} session=${protocol && protocol.sessionKey ? protocol.sessionKey : "n/a"}`,
442
- );
443
- if (typeof ws.terminate === "function") {
444
- ws.terminate();
445
- } else {
446
- ws.close(1012, reason);
447
- }
448
- closedClientIds.push(clientId);
449
- }
450
- return {
451
- closedCount: closedClientIds.length,
452
- closedClientIds,
453
- reason,
454
- };
455
- }
456
-
457
- function interactionStageBucket(stage) {
458
- switch (stage) {
459
- case "listening":
460
- case "voice_handoff":
461
- case "thinking":
462
- return "active_non_stream";
463
- case "streaming":
464
- case "post_turn_drain":
465
- return "active_stream";
466
- default:
467
- return "idle";
468
- }
469
- }
470
-
471
- function formatProtocolHelloAck(payload) {
472
- return JSON.stringify({
473
- type: "protocolHelloAck",
474
- protocolVersion: payload.protocolVersion,
475
- supportedProtocolVersions: payload.supportedProtocolVersions || ["v2"],
476
- reason: payload.reason || null,
477
- deprecatedV1: false,
478
- });
479
- }
480
-
481
- function ensureProtocolState(clientId) {
482
- let state = protocolState.get(clientId);
483
- if (!state) {
484
- state = {
485
- selectedVersion: null,
486
- supportedProtocolVersions: ["v2"],
487
- clientName: null,
488
- clientVersion: null,
489
- sessionKey: null,
490
- reason: null,
491
- };
492
- protocolState.set(clientId, state);
493
- }
494
- return state;
495
- }
496
-
497
- function createClientNudgeState() {
498
- return {
499
- visibilityState: null,
500
- streamChars: null,
501
- lastHeartbeatAtMs: null,
502
- lastRelayStreamingActivityAtMs: null,
503
- interactionStage: "idle",
504
- cadenceBucket: "idle",
505
- nudgeActive: false,
506
- nudgeIntervalMs: null,
507
- nudgeStartedAtMs: null,
508
- lastNudgeAtMs: null,
509
- stalledHeartbeatCount: 0,
510
- nudgeTimer: null,
511
- idleDeactivateTimer: null,
512
- staleHeartbeatTimer: null,
513
- hardTimeoutTimer: null,
514
- };
515
- }
516
-
517
- function ensureClientNudgeState(clientId) {
518
- let state = clientNudgeState.get(clientId);
519
- if (!state) {
520
- state = createClientNudgeState();
521
- clientNudgeState.set(clientId, state);
522
- }
523
- return state;
524
- }
525
-
526
- function cloneClientNudgeState(state) {
527
- if (!state) return null;
528
- return {
529
- visibilityState: state.visibilityState || null,
530
- streamChars: Number.isFinite(state.streamChars) ? state.streamChars : null,
531
- lastHeartbeatAtMs: Number.isFinite(state.lastHeartbeatAtMs)
532
- ? state.lastHeartbeatAtMs
533
- : null,
534
- lastRelayStreamingActivityAtMs: Number.isFinite(
535
- state.lastRelayStreamingActivityAtMs,
536
- )
537
- ? state.lastRelayStreamingActivityAtMs
538
- : null,
539
- interactionStage: state.interactionStage || "idle",
540
- cadenceBucket: state.cadenceBucket || "idle",
541
- nudgeActive: !!state.nudgeActive,
542
- nudgeIntervalMs: Number.isFinite(state.nudgeIntervalMs)
543
- ? state.nudgeIntervalMs
544
- : null,
545
- nudgeStartedAtMs: Number.isFinite(state.nudgeStartedAtMs)
546
- ? state.nudgeStartedAtMs
547
- : null,
548
- lastNudgeAtMs: Number.isFinite(state.lastNudgeAtMs)
549
- ? state.lastNudgeAtMs
550
- : null,
551
- stalledHeartbeatCount: Number.isFinite(state.stalledHeartbeatCount)
552
- ? state.stalledHeartbeatCount
553
- : 0,
554
- };
555
- }
556
-
557
- function cloneReadinessBundleIdentity(value) {
558
- if (!value) return null;
559
- return {
560
- kind: value.kind || null,
561
- mode: value.mode || null,
562
- host: value.host || null,
563
- port: Number.isFinite(value.port) ? value.port : null,
564
- servedDistPath: value.servedDistPath || null,
565
- };
566
- }
567
-
568
- function cloneReadinessSnapshot(value) {
569
- if (!value) return null;
570
- return {
571
- clientDebugEnabled:
572
- value.clientDebugEnabled === true || value.clientDebugEnabled === false
573
- ? value.clientDebugEnabled
574
- : null,
575
- runtimeDiagnosticsVisible:
576
- value.runtimeDiagnosticsVisible === true ||
577
- value.runtimeDiagnosticsVisible === false
578
- ? value.runtimeDiagnosticsVisible
579
- : null,
580
- perfNoisyDebugMuted:
581
- value.perfNoisyDebugMuted === true || value.perfNoisyDebugMuted === false
582
- ? value.perfNoisyDebugMuted
583
- : null,
584
- perfPayloadLiteMode:
585
- value.perfPayloadLiteMode === true || value.perfPayloadLiteMode === false
586
- ? value.perfPayloadLiteMode
587
- : null,
588
- activeSessionKey: value.activeSessionKey || null,
589
- bundleIdentity: cloneReadinessBundleIdentity(value.bundleIdentity),
590
- emittedAtMs: Number.isFinite(value.emittedAtMs) ? value.emittedAtMs : null,
591
- updatedAtMs: Number.isFinite(value.updatedAtMs) ? value.updatedAtMs : null,
592
- };
593
- }
594
-
595
- function updateClientReadinessSnapshot(clientId, snapshot) {
596
- if (!clientId || !snapshot) return null;
597
- const next = cloneReadinessSnapshot({
598
- ...snapshot,
599
- updatedAtMs: Date.now(),
600
- });
601
- clientReadinessSnapshotState.set(clientId, next);
602
- return next;
603
- }
604
-
605
- function clearPendingReadinessProbesForClient(clientId) {
606
- if (!clientId) return;
607
- for (const [requestId, pending] of pendingReadinessProbeRequests) {
608
- if (!pending) continue;
609
- if (
610
- pending.requesterClientId === clientId ||
611
- pending.targetClientId === clientId
612
- ) {
613
- pendingReadinessProbeRequests.delete(requestId);
614
- }
615
- }
616
- }
617
-
618
- function buildReadinessClientEntry(clientId) {
619
- const protocol = protocolState.get(clientId);
620
- return {
621
- clientId,
622
- clientKind: classifyClientKind(protocol),
623
- clientName: protocol && protocol.clientName ? protocol.clientName : null,
624
- clientVersion: protocol && protocol.clientVersion ? protocol.clientVersion : null,
625
- protocolSessionKey: protocol && protocol.sessionKey ? protocol.sessionKey : null,
626
- readinessSnapshot: cloneReadinessSnapshot(
627
- clientReadinessSnapshotState.get(clientId) || null,
628
- ),
629
- };
630
- }
631
-
632
- function clearClientNudgeTimer(state, key, clearFn = clearTimeout) {
633
- if (!state || !state[key]) return;
634
- clearFn(state[key]);
635
- state[key] = null;
636
- }
637
-
638
- function clearClientNudgeTimers(clientId) {
639
- const state = clientNudgeState.get(clientId);
640
- if (!state) return;
641
- clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
642
- clearClientNudgeTimer(state, "idleDeactivateTimer");
643
- clearClientNudgeTimer(state, "staleHeartbeatTimer");
644
- clearClientNudgeTimer(state, "hardTimeoutTimer");
645
- }
646
-
647
- function isStreamingBroadcastType(messageType) {
648
- return (
649
- messageType === "streaming" ||
650
- messageType === "ocuclaw.message.stream.delta"
651
- );
652
- }
653
-
654
- function isPagesBroadcastType(messageType) {
655
- return messageType === "pages" || messageType === APP_PROTOCOL.pages;
656
- }
657
-
658
- function isActivityBroadcastType(messageType) {
659
- return messageType === "activity" || messageType === "ocuclaw.activity.update";
660
- }
661
-
662
- function isListenCommittedBroadcastType(messageType) {
663
- return messageType === "listen-committed";
664
- }
665
-
666
- function isListenEndedBroadcastType(messageType) {
667
- return messageType === "listen-ended";
668
- }
669
-
670
- function resetClientStallTracking(state) {
671
- if (!state) return;
672
- state.stalledHeartbeatCount = 0;
673
- }
674
-
675
- function hasStaleHeartbeat(state, now = Date.now()) {
676
- if (!state || !Number.isFinite(state.lastHeartbeatAtMs)) {
677
- return false;
678
- }
679
- return now - state.lastHeartbeatAtMs >= nudgeStaleHeartbeatThresholdMs;
680
- }
681
-
682
- function isVisibilityDegraded(state) {
683
- return (
684
- !!state &&
685
- (state.visibilityState === "hidden" || state.visibilityState === "blurred")
686
- );
687
- }
688
-
689
- function sendRenderNudge(clientId) {
690
- const ws = clients.get(clientId);
691
- if (!ws || ws.readyState !== WebSocket.OPEN) {
692
- stopClientNudges(clientId, "socket_unavailable");
693
- return;
694
- }
695
- sendMessageToClient(clientId, ws, RENDER_NUDGE_FRAME);
696
- ensureClientNudgeState(clientId).lastNudgeAtMs = Date.now();
697
- }
698
-
699
- function scheduleClientHardTimeout(clientId) {
700
- const state = clientNudgeState.get(clientId);
701
- if (!state) return;
702
- clearClientNudgeTimer(state, "hardTimeoutTimer");
703
- if (!state.nudgeActive) return;
704
- state.hardTimeoutTimer = setTimeout(() => {
705
- state.hardTimeoutTimer = null;
706
- setClientInteractionStage(clientId, "idle", {
707
- reason: "nudge_hard_timeout",
708
- deactivateImmediately: true,
709
- });
710
- }, nudgeHardTimeoutMs);
711
- }
712
-
713
- function startClientNudges(
714
- clientId,
715
- intervalMs,
716
- reason = "nudge_start",
717
- sendImmediately = false,
718
- ) {
719
- if (!isAppClient(clientId)) return;
720
- const ws = clients.get(clientId);
721
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
722
- const state = ensureClientNudgeState(clientId);
723
- const nextIntervalMs = Math.max(1, Math.floor(intervalMs));
724
- const wasActive = !!state.nudgeActive;
725
- const intervalChanged = state.nudgeIntervalMs !== nextIntervalMs;
726
- state.cadenceBucket = interactionStageBucket(state.interactionStage);
727
- if (!wasActive) {
728
- state.nudgeActive = true;
729
- state.nudgeStartedAtMs = Date.now();
730
- }
731
- if (wasActive && !intervalChanged) {
732
- return;
733
- }
734
- clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
735
- state.nudgeActive = true;
736
- state.nudgeIntervalMs = nextIntervalMs;
737
- state.nudgeTimer = setInterval(() => {
738
- sendRenderNudge(clientId);
739
- }, nextIntervalMs);
740
- if (!wasActive) {
741
- scheduleClientHardTimeout(clientId);
742
- if (sendImmediately) {
743
- sendRenderNudge(clientId);
744
- }
745
- }
746
- }
747
-
748
- function stopClientNudges(clientId, _reason = "nudge_stop") {
749
- const state = clientNudgeState.get(clientId);
750
- if (!state) return;
751
- clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
752
- clearClientNudgeTimer(state, "idleDeactivateTimer");
753
- clearClientNudgeTimer(state, "hardTimeoutTimer");
754
- state.nudgeActive = false;
755
- state.nudgeIntervalMs = null;
756
- state.nudgeStartedAtMs = null;
757
- resetClientStallTracking(state);
758
- scheduleClientStaleHeartbeatCheck(clientId);
759
- }
760
-
761
- function scheduleClientIdleDeactivation(clientId) {
762
- const state = clientNudgeState.get(clientId);
763
- if (!state) return;
764
- clearClientNudgeTimer(state, "idleDeactivateTimer");
765
- if (!state.nudgeActive || state.interactionStage !== "idle") {
766
- return;
767
- }
768
- state.idleDeactivateTimer = setTimeout(() => {
769
- state.idleDeactivateTimer = null;
770
- const currentState = clientNudgeState.get(clientId);
771
- if (!currentState || currentState.interactionStage !== "idle") {
772
- return;
773
- }
774
- stopClientNudges(clientId, "idle_grace_elapsed");
775
- }, nudgeIdleDeactivateMs);
776
- }
777
-
778
- function scheduleClientStaleHeartbeatCheck(clientId) {
779
- const state = clientNudgeState.get(clientId);
780
- if (!state) return;
781
- clearClientNudgeTimer(state, "staleHeartbeatTimer");
782
- if (
783
- state.nudgeActive ||
784
- interactionStageBucket(state.interactionStage) === "idle" ||
785
- !Number.isFinite(state.lastHeartbeatAtMs)
786
- ) {
787
- return;
788
- }
789
- const delayMs = Math.max(
790
- 0,
791
- (state.lastHeartbeatAtMs + nudgeStaleHeartbeatThresholdMs) - Date.now(),
792
- );
793
- state.staleHeartbeatTimer = setTimeout(() => {
794
- state.staleHeartbeatTimer = null;
795
- const currentState = clientNudgeState.get(clientId);
796
- if (
797
- !currentState ||
798
- currentState.nudgeActive ||
799
- interactionStageBucket(currentState.interactionStage) === "idle"
800
- ) {
801
- return;
802
- }
803
- if (!hasStaleHeartbeat(currentState)) {
804
- scheduleClientStaleHeartbeatCheck(clientId);
805
- return;
806
- }
807
- startClientNudges(
808
- clientId,
809
- nudgeActiveIntervalMs,
810
- "stale_heartbeat_fallback",
811
- true,
812
- );
813
- }, delayMs);
814
- }
815
-
816
- function maybeActivateClientNudges(clientId, reason = "nudge_eval") {
817
- const state = clientNudgeState.get(clientId);
818
- if (!state || !isAppClient(clientId)) return;
819
- state.cadenceBucket = interactionStageBucket(state.interactionStage);
820
- if (state.cadenceBucket === "idle") {
821
- clearClientNudgeTimer(state, "staleHeartbeatTimer");
822
- return;
823
- }
824
- if (state.nudgeActive) {
825
- return;
826
- }
827
- if (isVisibilityDegraded(state) || hasStaleHeartbeat(state)) {
828
- startClientNudges(clientId, nudgeActiveIntervalMs, reason, true);
829
- return;
830
- }
831
- scheduleClientStaleHeartbeatCheck(clientId);
832
- }
833
-
834
- function setClientInteractionStage(clientId, nextStage, options = {}) {
835
- if (!isAppClient(clientId)) return;
836
- const state = ensureClientNudgeState(clientId);
837
- const reason = options.reason || "interaction_stage";
838
- const deactivateImmediately = options.deactivateImmediately === true;
839
- state.interactionStage = nextStage;
840
- state.cadenceBucket = interactionStageBucket(nextStage);
841
- if (state.cadenceBucket !== "active_stream") {
842
- resetClientStallTracking(state);
843
- }
844
- if (state.cadenceBucket === "idle") {
845
- clearClientNudgeTimer(state, "staleHeartbeatTimer");
846
- if (deactivateImmediately) {
847
- stopClientNudges(clientId, reason);
848
- } else {
849
- scheduleClientIdleDeactivation(clientId);
850
- }
851
- return;
852
- }
853
- clearClientNudgeTimer(state, "idleDeactivateTimer");
854
- if (
855
- state.cadenceBucket === "active_non_stream" &&
856
- state.nudgeActive &&
857
- state.nudgeIntervalMs !== nudgeActiveIntervalMs
858
- ) {
859
- startClientNudges(clientId, nudgeActiveIntervalMs, reason, false);
860
- }
861
- maybeActivateClientNudges(clientId, reason);
862
- }
863
-
864
- function observeRelayStreamingActivity(clientId, atMs) {
865
- if (!isAppClient(clientId)) return;
866
- const state = ensureClientNudgeState(clientId);
867
- state.lastRelayStreamingActivityAtMs = atMs;
868
- resetClientStallTracking(state);
869
- if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
870
- startClientNudges(clientId, nudgeActiveIntervalMs, "relay_stream_progress");
871
- return;
872
- }
873
- maybeActivateClientNudges(clientId, "relay_stream_activity");
874
- }
875
-
876
- function updateClientNudgeHeartbeat(clientId, ping) {
877
- const state = ensureClientNudgeState(clientId);
878
- const previousHeartbeatAtMs = Number.isFinite(state.lastHeartbeatAtMs)
879
- ? state.lastHeartbeatAtMs
880
- : null;
881
- const previousStreamChars = Number.isFinite(state.streamChars)
882
- ? state.streamChars
883
- : null;
884
- const nextStreamChars = Number.isFinite(ping.streamChars) ? ping.streamChars : null;
885
- const streamAdvanced =
886
- nextStreamChars !== null &&
887
- (previousStreamChars === null || nextStreamChars > previousStreamChars);
888
- const relayStreamAdvanced =
889
- previousHeartbeatAtMs !== null &&
890
- Number.isFinite(state.lastRelayStreamingActivityAtMs) &&
891
- state.lastRelayStreamingActivityAtMs > previousHeartbeatAtMs;
892
- state.streamChars = nextStreamChars;
893
- if (ping.visibilityState) {
894
- state.visibilityState = ping.visibilityState;
895
- }
896
- state.lastHeartbeatAtMs = Date.now();
897
- state.cadenceBucket = interactionStageBucket(state.interactionStage);
898
- if (state.visibilityState === "visible") {
899
- stopClientNudges(clientId, "heartbeat_visible");
900
- }
901
- if (state.cadenceBucket === "active_stream") {
902
- if (streamAdvanced || relayStreamAdvanced) {
903
- resetClientStallTracking(state);
904
- if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
905
- startClientNudges(
906
- clientId,
907
- nudgeActiveIntervalMs,
908
- streamAdvanced
909
- ? "heartbeat_stream_progress"
910
- : "relay_stream_progress",
911
- false,
912
- );
913
- }
914
- } else if (state.nudgeActive) {
915
- state.stalledHeartbeatCount += 1;
916
- if (
917
- state.stalledHeartbeatCount >= 3 &&
918
- state.nudgeIntervalMs !== nudgeSlowIntervalMs
919
- ) {
920
- startClientNudges(
921
- clientId,
922
- nudgeSlowIntervalMs,
923
- "stream_stalled_decelerated",
924
- false,
925
- );
926
- }
927
- }
928
- } else {
929
- resetClientStallTracking(state);
930
- if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
931
- startClientNudges(clientId, nudgeActiveIntervalMs, "non_stream_fast", false);
932
- }
933
- }
934
- maybeActivateClientNudges(clientId, "heartbeat_update");
935
- }
936
-
937
- function updateClientVisibilityState(clientId, visibilityState) {
938
- const state = ensureClientNudgeState(clientId);
939
- state.visibilityState = visibilityState;
940
- if (visibilityState === "visible") {
941
- stopClientNudges(clientId, "visibility_visible");
942
- return;
943
- }
944
- maybeActivateClientNudges(clientId, "visibility_hidden");
945
- }
946
-
947
- function applyBroadcastInteractionStage(messageType, parsed, relayStreamingActivityAtMs) {
948
- for (const [clientId] of clients) {
949
- if (!isAppClient(clientId)) {
950
- continue;
951
- }
952
- if (relayStreamingActivityAtMs !== null) {
953
- setClientInteractionStage(clientId, "streaming", {
954
- reason: "relay_streaming",
955
- });
956
- observeRelayStreamingActivity(clientId, relayStreamingActivityAtMs);
957
- continue;
958
- }
959
- if (isListenCommittedBroadcastType(messageType)) {
960
- setClientInteractionStage(clientId, "voice_handoff", {
961
- reason: "listen_committed",
962
- });
963
- continue;
964
- }
965
- if (isListenEndedBroadcastType(messageType)) {
966
- setClientInteractionStage(clientId, "idle", {
967
- reason: "listen_ended",
968
- deactivateImmediately: true,
969
- });
970
- continue;
971
- }
972
- if (isActivityBroadcastType(messageType)) {
973
- const activityState = parseOptionalTrimmedString(parsed && parsed.state);
974
- const normalizedActivity = activityState ? activityState.toLowerCase() : null;
975
- const currentStage = ensureClientNudgeState(clientId).interactionStage;
976
- if (normalizedActivity === "thinking") {
977
- setClientInteractionStage(clientId, "thinking", {
978
- reason: "activity_thinking",
979
- });
980
- } else if (normalizedActivity === "idle") {
981
- if (currentStage === "streaming" || currentStage === "post_turn_drain") {
982
- setClientInteractionStage(clientId, "post_turn_drain", {
983
- reason: "activity_idle_stream_drain",
984
- });
985
- } else {
986
- setClientInteractionStage(clientId, "idle", {
987
- reason: "activity_idle",
988
- });
989
- }
990
- }
991
- continue;
992
- }
993
- if (isPagesBroadcastType(messageType)) {
994
- const currentState = ensureClientNudgeState(clientId);
995
- if (interactionStageBucket(currentState.interactionStage) !== "idle") {
996
- setClientInteractionStage(clientId, "post_turn_drain", {
997
- reason: "pages_snapshot",
998
- });
999
- }
1000
- }
1001
- }
1002
- }
1003
-
1004
- function updateProtocolSessionKey(clientId, value) {
1005
- const sessionKey = parseOptionalTrimmedString(value);
1006
- if (!sessionKey) return;
1007
- ensureProtocolState(clientId).sessionKey = sessionKey;
1008
- }
1009
-
1010
- function updateProtocolState(clientId, overrides = {}) {
1011
- const state = ensureProtocolState(clientId);
1012
- if (overrides.supportedProtocolVersions) {
1013
- state.supportedProtocolVersions = parseSupportedProtocolVersions(
1014
- overrides.supportedProtocolVersions,
1015
- );
1016
- }
1017
- if (overrides.clientName) {
1018
- state.clientName = parseOptionalTrimmedString(overrides.clientName);
1019
- }
1020
- if (overrides.clientVersion) {
1021
- state.clientVersion = parseOptionalTrimmedString(overrides.clientVersion);
1022
- }
1023
- if (overrides.sessionKey) {
1024
- state.sessionKey = parseOptionalTrimmedString(overrides.sessionKey);
1025
- }
1026
- if (overrides.selectedVersion !== undefined) {
1027
- state.selectedVersion = overrides.selectedVersion;
1028
- }
1029
- if (overrides.reason !== undefined) {
1030
- state.reason = overrides.reason || null;
1031
- }
1032
- return state;
1033
- }
1034
-
1035
- function clearSyncTimer(clientId) {
1036
- const sync = syncState.get(clientId);
1037
- if (sync && sync.timer) {
1038
- clearTimeout(sync.timer);
1039
- sync.timer = null;
1040
- }
1041
- }
1042
-
1043
- function closeForProtocolViolation(clientId, ws, reason, overrides = {}) {
1044
- updateProtocolState(clientId, { ...overrides, reason });
1045
- clearSyncTimer(clientId);
1046
- if (
1047
- ws &&
1048
- (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)
1049
- ) {
1050
- ws.close(1008, reason);
1051
- }
1052
- }
1053
-
1054
- function armResumeHandshakeTimeout(clientId, ws) {
1055
- const sync = syncState.get(clientId);
1056
- if (!sync) return;
1057
- clearSyncTimer(clientId);
1058
- sync.timer = setTimeout(() => {
1059
- sync.timer = null;
1060
- syncSnapshotForClient(clientId, ws, "connect_timeout", null);
1061
- }, resumeHandshakeTimeoutMs);
1062
- }
1063
-
1064
- function sendMessageToClient(clientId, ws, message) {
1065
- if (!ws || ws.readyState !== WebSocket.OPEN) {
1066
- return;
1067
- }
1068
- ws.send(message);
1069
- }
1070
-
1071
- function forwardReadinessProbeAck(clientId, ack) {
1072
- if (!ack || !ack.requestId) {
1073
- return;
1074
- }
1075
- const pending = pendingReadinessProbeRequests.get(ack.requestId);
1076
- if (!pending || pending.targetClientId !== clientId) {
1077
- return;
1078
- }
1079
- pendingReadinessProbeRequests.delete(ack.requestId);
1080
- const requesterWs = clients.get(pending.requesterClientId);
1081
- if (!requesterWs || requesterWs.readyState !== WebSocket.OPEN) {
1082
- return;
1083
- }
1084
- const protocol = protocolState.get(clientId);
1085
- const message =
1086
- handler && typeof handler.formatReadinessProbeAck === "function"
1087
- ? handler.formatReadinessProbeAck({
1088
- ok: ack.ok !== false,
1089
- requestId: ack.requestId,
1090
- reasonCode: ack.reasonCode || null,
1091
- message: ack.message || null,
1092
- activeSessionKey: ack.activeSessionKey || null,
1093
- emittedAtMs: ack.emittedAtMs,
1094
- clientId,
1095
- clientName: protocol && protocol.clientName ? protocol.clientName : null,
1096
- clientVersion:
1097
- protocol && protocol.clientVersion ? protocol.clientVersion : null,
1098
- })
1099
- : JSON.stringify({
1100
- type: APP_PROTOCOL.readinessProbeAck,
1101
- ok: ack.ok !== false,
1102
- requestId: ack.requestId,
1103
- reasonCode: ack.reasonCode || null,
1104
- message: ack.message || null,
1105
- activeSessionKey: ack.activeSessionKey || null,
1106
- emittedAtMs: ack.emittedAtMs,
1107
- clientId,
1108
- clientName: protocol && protocol.clientName ? protocol.clientName : null,
1109
- clientVersion:
1110
- protocol && protocol.clientVersion ? protocol.clientVersion : null,
1111
- });
1112
- sendMessageToClient(pending.requesterClientId, requesterWs, message);
1113
- }
1114
-
1115
- function getServerResumeState() {
1116
- if (!getCurrentResumeState) {
1117
- return { pagesRevision: null, statusRevision: null };
1118
- }
1119
- const state = getCurrentResumeState() || {};
1120
- return {
1121
- pagesRevision: parseRevision(state.pagesRevision),
1122
- statusRevision: parseRevision(state.statusRevision),
1123
- };
1124
- }
1125
-
1126
- function formatResumeAck(payload) {
1127
- return JSON.stringify({
1128
- type: APP_PROTOCOL.resumeAck,
1129
- reason: payload.reason || null,
1130
- sentPages: !!payload.sentPages,
1131
- sentStatus: !!payload.sentStatus,
1132
- sentApprovals:
1133
- Number.isFinite(payload.sentApprovals) && payload.sentApprovals > 0
1134
- ? Math.floor(payload.sentApprovals)
1135
- : 0,
1136
- pagesRevision:
1137
- payload.pagesRevision === null || payload.pagesRevision === undefined
1138
- ? null
1139
- : payload.pagesRevision,
1140
- statusRevision:
1141
- payload.statusRevision === null || payload.statusRevision === undefined
1142
- ? null
1143
- : payload.statusRevision,
1144
- });
1145
- }
1146
-
1147
- function isSnapshotSynced(sync) {
1148
- return (
1149
- !!sync &&
1150
- sync.pagesSynced &&
1151
- sync.statusSynced &&
1152
- sync.approvalsSynced &&
1153
- sync.debugConfigSynced
1154
- );
1155
- }
1156
-
1157
- function markClientSyncedForType(clientId, messageType) {
1158
- const sync = syncState.get(clientId);
1159
- if (!sync) return;
1160
- if (messageType === "pages" || messageType === APP_PROTOCOL.pages) {
1161
- sync.pagesSynced = true;
1162
- }
1163
- if (messageType === "status" || messageType === APP_PROTOCOL.status) {
1164
- sync.statusSynced = true;
1165
- }
1166
- if (messageType === APP_PROTOCOL.debugConfigSnapshot) {
1167
- sync.debugConfigSynced = true;
1168
- }
1169
- if (isSnapshotSynced(sync) && sync.timer) {
1170
- clearTimeout(sync.timer);
1171
- sync.timer = null;
1172
- }
1173
- }
1174
-
1175
- function syncSnapshotForClient(clientId, ws, reason, resumeRequest) {
1176
- const sync = syncState.get(clientId);
1177
- if (!sync || isSnapshotSynced(sync)) {
1178
- const state = getServerResumeState();
1179
- return {
1180
- reason,
1181
- sentPages: false,
1182
- sentStatus: false,
1183
- sentApprovals: 0,
1184
- pagesRevision: state.pagesRevision,
1185
- statusRevision: state.statusRevision,
1186
- };
1187
- }
1188
-
1189
- if (sync.timer) {
1190
- clearTimeout(sync.timer);
1191
- sync.timer = null;
1192
- }
1193
-
1194
- const pages = getCurrentPages();
1195
- const status = getCurrentStatus();
1196
- const debugConfig = getCurrentDebugConfig ? getCurrentDebugConfig() : null;
1197
- const state = getServerResumeState();
1198
- const clientPagesRevision = parseRevision(
1199
- resumeRequest && resumeRequest.pagesRevision,
1200
- );
1201
- const clientStatusRevision = parseRevision(
1202
- resumeRequest && resumeRequest.statusRevision,
1203
- );
1204
- const clientHasPagesState = parseBool(
1205
- resumeRequest && resumeRequest.hasPagesState,
1206
- );
1207
- const clientHasStatusState = parseBool(
1208
- resumeRequest && resumeRequest.hasStatusState,
1209
- );
1210
- const pagesInSync =
1211
- clientHasPagesState &&
1212
- state.pagesRevision !== null &&
1213
- clientPagesRevision !== null &&
1214
- state.pagesRevision === clientPagesRevision;
1215
- let sentPages = false;
1216
- let sentStatus = false;
1217
- let sentApprovals = 0;
1218
- let sentDebugConfig = false;
1219
- if (!sync.pagesSynced) {
1220
- if (pages === null || pagesInSync) {
1221
- sync.pagesSynced = true;
1222
- } else if (ws.readyState === WebSocket.OPEN) {
1223
- sendMessageToClient(clientId, ws, pages);
1224
- sentPages = true;
1225
- sync.pagesSynced = true;
1226
- }
1227
- }
1228
- if (!sync.statusSynced) {
1229
- // Always replay the current status once per downstream reconnect. The client
1230
- // uses that fresh status snapshot to clear reconnect UI even when revisions
1231
- // appear to match after a relay restart.
1232
- if (status === null) {
1233
- sync.statusSynced = true;
1234
- } else if (ws.readyState === WebSocket.OPEN) {
1235
- sendMessageToClient(clientId, ws, status);
1236
- sentStatus = true;
1237
- sync.statusSynced = true;
1238
- }
1239
- }
1240
- if (!sync.approvalsSynced) {
1241
- if (unresolvedApprovals.size === 0) {
1242
- sync.approvalsSynced = true;
1243
- } else {
1244
- for (const approvalMessage of unresolvedApprovals.values()) {
1245
- if (ws.readyState !== WebSocket.OPEN) {
1246
- break;
1247
- }
1248
- sendMessageToClient(clientId, ws, approvalMessage);
1249
- sentApprovals += 1;
1250
- }
1251
- sync.approvalsSynced = ws.readyState === WebSocket.OPEN;
1252
- }
1253
- }
1254
- if (!sync.debugConfigSynced) {
1255
- if (!isAppClient(clientId) || debugConfig === null) {
1256
- sync.debugConfigSynced = true;
1257
- } else if (ws.readyState === WebSocket.OPEN) {
1258
- sendMessageToClient(clientId, ws, debugConfig);
1259
- sentDebugConfig = true;
1260
- sync.debugConfigSynced = true;
1261
- }
1262
- }
1263
- if (isSnapshotSynced(sync) && sync.timer) {
1264
- clearTimeout(sync.timer);
1265
- sync.timer = null;
1266
- }
1267
-
1268
- return {
1269
- reason,
1270
- sentPages,
1271
- sentStatus,
1272
- sentApprovals,
1273
- sentDebugConfig,
1274
- pagesRevision: state.pagesRevision,
1275
- statusRevision: state.statusRevision,
1276
- };
1277
- }
1278
-
1279
- // When an httpServer is provided, attach to it via noServer mode so HTTP
1280
- // and WebSocket share a single port. Otherwise listen on a standalone port
1281
- // (used when HTTP is disabled and in tests).
1282
- let wss;
1283
- if (opts.httpServer) {
1284
- wss = new WebSocketServer({ noServer: true });
1285
- opts.httpServer.on("upgrade", (req, socket, head) => {
1286
- const url = new URL(req.url, `http://${req.headers.host}`);
1287
- if (url.searchParams.get("token") !== token) {
1288
- socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
1289
- socket.destroy();
1290
- return;
1291
- }
1292
- wss.handleUpgrade(req, socket, head, (ws) => {
1293
- wss.emit("connection", ws, req);
1294
- });
1295
- });
1296
- } else {
1297
- wss = new WebSocketServer({
1298
- host: opts.host,
1299
- port: opts.port,
1300
- verifyClient: ({ req }) => {
1301
- const url = new URL(req.url, `http://${req.headers.host}`);
1302
- return url.searchParams.get("token") === token;
1303
- },
1304
- });
1305
- }
1306
-
1307
- wss.on("connection", (ws, req) => {
1308
- const clientId = `client-${nextClientId++}`;
1309
- clients.set(clientId, ws);
1310
- const connectedAtMs = Date.now();
1311
- const remoteAddress =
1312
- (req && req.socket && req.socket.remoteAddress) || "unknown";
1313
- const userAgent =
1314
- (req && req.headers && req.headers["user-agent"]) || "unknown";
1315
- const clientMeta = {
1316
- role: "unknown",
1317
- firstMessageType: null,
1318
- textMessageCount: 0,
1319
- binaryMessageCount: 0,
1320
- remoteControlCount: 0,
1321
- connectedEventEmitted: false,
1322
- };
1323
- ensureProtocolState(clientId);
1324
- clientNudgeState.set(clientId, createClientNudgeState());
1325
- const currentPages = getCurrentPages();
1326
- const currentStatus = getCurrentStatus();
1327
- syncState.set(clientId, {
1328
- pagesSynced: currentPages === null,
1329
- statusSynced: currentStatus === null,
1330
- approvalsSynced: unresolvedApprovals.size === 0,
1331
- debugConfigSynced: !getCurrentDebugConfig,
1332
- resumeReceived: false,
1333
- timer: null,
1334
- });
1335
- const sync = syncState.get(clientId);
1336
- if (sync) {
1337
- sync.timer = setTimeout(() => {
1338
- sync.timer = null;
1339
- closeForProtocolViolation(clientId, ws, "protocol_hello_timeout");
1340
- }, protocolHelloTimeoutMs);
1341
- }
1342
- logger.info(
1343
- `[downstream] ${clientId} connected (${clients.size} total) ip=${remoteAddress} ua=${truncateForLog(String(userAgent), 100)}`,
1344
- );
1345
-
1346
- ws.on("error", (err) => {
1347
- logger.error(`[downstream] ${clientId} error: ${err.message}`);
1348
- });
1349
-
1350
- ws.on("message", (data, isBinary) => {
1351
- // Legacy downstream binary voice transport was removed. Ignore binary frames.
1352
- if (isBinary) {
1353
- clientMeta.binaryMessageCount += 1;
1354
- return;
1355
- }
1356
-
1357
- clientMeta.textMessageCount += 1;
1358
- const raw = data.toString();
1359
- let parsed = null;
1360
- try {
1361
- parsed = JSON.parse(raw);
1362
- } catch (_) {
1363
- parsed = null;
1364
- }
1365
-
1366
- const protocolHello = parseProtocolHello(parsed);
1367
-
1368
- if (parsed && typeof parsed.type === "string") {
1369
- const clientKind = protocolHello
1370
- ? classifyClientKindFromName(protocolHello.clientName)
1371
- : "unknown";
1372
- updateClientRole(clientMeta, parsed.type, clientKind);
1373
- if (parsed.type === "remote-control") {
1374
- clientMeta.remoteControlCount += 1;
1375
- }
1376
- updateProtocolSessionKey(clientId, parsed.sessionKey);
1377
- }
1378
-
1379
- const enrichedPing = parseEnrichedPing(parsed);
1380
-
1381
- // Intercept application-level ping — respond with pong, skip handler
1382
- if (enrichedPing) {
1383
- updateClientNudgeHeartbeat(clientId, enrichedPing);
1384
- ws.send(JSON.stringify({ type: "pong", ts: parsed.ts }));
1385
- return;
1386
- }
1387
-
1388
- if (protocolHello) {
1389
- if (!protocolHello.supportedProtocolVersions.includes("v2")) {
1390
- closeForProtocolViolation(clientId, ws, "protocol_v2_required", {
1391
- supportedProtocolVersions: protocolHello.supportedProtocolVersions,
1392
- clientName: protocolHello.clientName,
1393
- clientVersion: protocolHello.clientVersion,
1394
- sessionKey: protocolHello.sessionKey,
1395
- });
1396
- return;
1397
- }
1398
- const protocolClientKind = classifyClientKindFromName(
1399
- protocolHello.clientName,
1400
- );
1401
- if (!externalDebugToolsEnabled && protocolClientKind === "debug") {
1402
- closeForProtocolViolation(clientId, ws, "external_debug_tools_disabled", {
1403
- supportedProtocolVersions: protocolHello.supportedProtocolVersions,
1404
- clientName: protocolHello.clientName,
1405
- clientVersion: protocolHello.clientVersion,
1406
- sessionKey: protocolHello.sessionKey,
1407
- });
1408
- return;
1409
- }
1410
- const state = updateProtocolState(clientId, {
1411
- selectedVersion: "v2",
1412
- reason: "negotiated_v2",
1413
- supportedProtocolVersions: protocolHello.supportedProtocolVersions,
1414
- clientName: protocolHello.clientName,
1415
- clientVersion: protocolHello.clientVersion,
1416
- sessionKey: protocolHello.sessionKey,
1417
- });
1418
- if (protocolHello.readinessSnapshot) {
1419
- updateClientReadinessSnapshot(clientId, protocolHello.readinessSnapshot);
1420
- }
1421
- logger.info(
1422
- `[downstream] ${clientId} identified protocol=${state.selectedVersion || "n/a"} client=${describeProtocolClient(state)} kind=${classifyClientKind(state)} session=${state.sessionKey || "n/a"}`,
1423
- );
1424
- if (!clientMeta.connectedEventEmitted) {
1425
- clientMeta.connectedEventEmitted = true;
1426
- if (onClientConnected) {
1427
- onClientConnected({
1428
- clientId,
1429
- connectedCount: countConnectedAppClients(),
1430
- connectedAtMs,
1431
- remoteAddress,
1432
- userAgent: truncateForLog(String(userAgent), 180),
1433
- role: clientMeta.role,
1434
- protocolVersion: state.selectedVersion || null,
1435
- protocolReason: state.reason || null,
1436
- clientKind: classifyClientKind(state),
1437
- clientName: state.clientName || null,
1438
- clientVersion: state.clientVersion || null,
1439
- sessionKey: state.sessionKey || null,
1440
- });
1441
- }
1442
- }
1443
- armResumeHandshakeTimeout(clientId, ws);
1444
- if (ws.readyState === WebSocket.OPEN) {
1445
- ws.send(
1446
- formatProtocolHelloAck({
1447
- protocolVersion: state.selectedVersion || "v2",
1448
- supportedProtocolVersions: ["v2"],
1449
- reason: state.reason,
1450
- }),
1451
- );
1452
- }
1453
- return;
1454
- }
1455
-
1456
- const state = ensureProtocolState(clientId);
1457
- if (!state.selectedVersion && parsed && typeof parsed.type === "string") {
1458
- closeForProtocolViolation(clientId, ws, "protocol_hello_required", {
1459
- clientName: parsed.clientName,
1460
- clientVersion: parsed.clientVersion,
1461
- sessionKey: parsed.sessionKey,
1462
- });
1463
- return;
1464
- }
1465
-
1466
- if (
1467
- state.selectedVersion === "v2" &&
1468
- parsed &&
1469
- REMOVED_V1_APP_TYPES.has(parsed.type)
1470
- ) {
1471
- return;
1472
- }
1473
-
1474
- if (parsed && parsed.type === APP_PROTOCOL.readinessSnapshot) {
1475
- if (classifyClientKind(state) === "app") {
1476
- updateClientReadinessSnapshot(clientId, parsed);
1477
- }
1478
- return;
1479
- }
1480
-
1481
- const readinessProbeAck = parseReadinessProbeAck(parsed);
1482
- if (readinessProbeAck) {
1483
- if (classifyClientKind(state) === "app") {
1484
- forwardReadinessProbeAck(clientId, readinessProbeAck);
1485
- }
1486
- return;
1487
- }
1488
-
1489
- if (parsed && parsed.type === APP_PROTOCOL.resume) {
1490
- const sync = syncState.get(clientId);
1491
- if (sync) sync.resumeReceived = true;
1492
- const ack = syncSnapshotForClient(clientId, ws, "resume", parsed);
1493
- if (ws.readyState === WebSocket.OPEN) {
1494
- sendMessageToClient(clientId, ws, formatResumeAck(ack));
1495
- }
1496
- return;
1497
- }
1498
-
1499
- const visibilityControl = parseVisibilityControl(parsed);
1500
- const drainCompleteControl = parseDrainCompleteControl(parsed);
1501
- const transportControl = visibilityControl || drainCompleteControl;
1502
- if (transportControl) {
1503
- const protocol = protocolState.get(clientId);
1504
- if (transportControl.type === "visibility") {
1505
- updateClientVisibilityState(clientId, transportControl.state);
1506
- } else {
1507
- setClientInteractionStage(clientId, "idle", {
1508
- reason: "drain_complete",
1509
- deactivateImmediately: true,
1510
- });
1511
- }
1512
- const meta = {
1513
- clientId,
1514
- controlType: transportControl.type,
1515
- state: transportControl.type === "visibility" ? transportControl.state : null,
1516
- connectedCount: countConnectedAppClients(),
1517
- role: clientMeta.role,
1518
- clientKind: classifyClientKind(protocol),
1519
- clientName: protocol && protocol.clientName ? protocol.clientName : null,
1520
- clientVersion: protocol && protocol.clientVersion ? protocol.clientVersion : null,
1521
- protocolVersion:
1522
- protocol && protocol.selectedVersion ? protocol.selectedVersion : null,
1523
- sessionKey: protocol && protocol.sessionKey ? protocol.sessionKey : null,
1524
- };
1525
- if (transportControl.type === "visibility") {
1526
- logger.info(
1527
- `[downstream] ${clientId} transport visibility state=${transportControl.state} client=${describeProtocolClient(protocol)} kind=${classifyClientKind(protocol)} session=${protocol && protocol.sessionKey ? protocol.sessionKey : "n/a"}`,
1528
- );
1529
- } else {
1530
- logger.info(
1531
- `[downstream] ${clientId} transport control=${transportControl.type} client=${describeProtocolClient(protocol)} kind=${classifyClientKind(protocol)} session=${protocol && protocol.sessionKey ? protocol.sessionKey : "n/a"}`,
1532
- );
1533
- }
1534
- if (onTransportControl) {
1535
- onTransportControl(meta);
1536
- }
1537
- return;
1538
- }
1539
-
1540
- const result = handler.handleMessage(clientId, raw);
1541
- processResult(clientId, result);
1542
- });
1543
-
1544
- ws.on("close", (code, reasonBuffer) => {
1545
- const sync = syncState.get(clientId);
1546
- if (sync && sync.timer) {
1547
- clearTimeout(sync.timer);
1548
- }
1549
- const protocol = protocolState.get(clientId);
1550
- syncState.delete(clientId);
1551
- protocolState.delete(clientId);
1552
- clientReadinessSnapshotState.delete(clientId);
1553
- clearPendingReadinessProbesForClient(clientId);
1554
- clearClientNudgeTimers(clientId);
1555
- clientNudgeState.delete(clientId);
1556
- handler.removeClient(clientId);
1557
- clients.delete(clientId);
1558
- const lifetimeMs = Date.now() - connectedAtMs;
1559
- const reason =
1560
- reasonBuffer && reasonBuffer.length > 0
1561
- ? truncateForLog(reasonBuffer.toString("utf8").replace(/\s+/g, " "), 120)
1562
- : "";
1563
- logger.info(
1564
- `[downstream] ${clientId} disconnected (${clients.size} total) code=${code} reason="${reason}" lifetimeMs=${lifetimeMs} role=${clientMeta.role} client=${describeProtocolClient(protocol)} kind=${classifyClientKind(protocol)} protocol=${protocol && protocol.selectedVersion ? protocol.selectedVersion : "n/a"} session=${protocol && protocol.sessionKey ? protocol.sessionKey : "n/a"} firstType=${clientMeta.firstMessageType || "n/a"} textMsgs=${clientMeta.textMessageCount} binMsgs=${clientMeta.binaryMessageCount} remoteCtrlMsgs=${clientMeta.remoteControlCount}`,
1565
- );
1566
- if (onClientDisconnected && clientMeta.connectedEventEmitted) {
1567
- onClientDisconnected({
1568
- clientId,
1569
- connectedCount: countConnectedAppClients(),
1570
- connectedAtMs,
1571
- lifetimeMs,
1572
- closeCode: code,
1573
- closeReason: reason,
1574
- role: clientMeta.role,
1575
- protocolVersion:
1576
- protocol && protocol.selectedVersion ? protocol.selectedVersion : null,
1577
- protocolReason: protocol && protocol.reason ? protocol.reason : null,
1578
- clientKind: classifyClientKind(protocol),
1579
- clientName: protocol && protocol.clientName ? protocol.clientName : null,
1580
- clientVersion:
1581
- protocol && protocol.clientVersion ? protocol.clientVersion : null,
1582
- sessionKey: protocol && protocol.sessionKey ? protocol.sessionKey : null,
1583
- firstMessageType: clientMeta.firstMessageType || null,
1584
- textMessageCount: clientMeta.textMessageCount,
1585
- binaryMessageCount: clientMeta.binaryMessageCount,
1586
- remoteControlCount: clientMeta.remoteControlCount,
1587
- });
1588
- }
1589
- });
1590
- });
1591
-
1592
- /**
1593
- * Process the result from handler.handleMessage.
1594
- *
1595
- * Handles both synchronous values and promises. Dispatches unicast
1596
- * or broadcast actions as appropriate.
1597
- *
1598
- * @param {string} senderId - Client ID that sent the original message
1599
- * @param {object|null|Promise} result - Handler return value
1600
- */
1601
- function processResult(senderId, result) {
1602
- if (result === null || result === undefined) {
1603
- return;
1604
- }
1605
-
1606
- if (typeof result.then === "function") {
1607
- result.then(
1608
- (resolved) => processResult(senderId, resolved),
1609
- (err) => {
1610
- logger.error(
1611
- `[downstream] Error processing message from ${senderId}:`,
1612
- err,
1613
- );
1614
- },
1615
- );
1616
- return;
1617
- }
1618
-
1619
- if (result.unicast) {
1620
- const ws = clients.get(senderId);
1621
- if (ws && ws.readyState === WebSocket.OPEN) {
1622
- sendMessageToClient(senderId, ws, result.unicast);
1623
- }
1624
- }
1625
-
1626
- if (result.readinessProbe) {
1627
- const requestId = parseOptionalTrimmedString(
1628
- result.readinessProbe.requestId,
1629
- );
1630
- const targetClientId = parseOptionalTrimmedString(
1631
- result.readinessProbe.targetClientId,
1632
- );
1633
- const message =
1634
- typeof result.readinessProbe.message === "string"
1635
- ? result.readinessProbe.message
1636
- : null;
1637
- const targetWs = targetClientId ? clients.get(targetClientId) : null;
1638
- if (
1639
- !requestId ||
1640
- !targetClientId ||
1641
- !message ||
1642
- !targetWs ||
1643
- targetWs.readyState !== WebSocket.OPEN ||
1644
- !isAppClient(targetClientId)
1645
- ) {
1646
- const requesterWs = clients.get(senderId);
1647
- if (
1648
- requesterWs &&
1649
- requesterWs.readyState === WebSocket.OPEN &&
1650
- handler &&
1651
- typeof handler.formatReadinessProbeAck === "function"
1652
- ) {
1653
- sendMessageToClient(
1654
- senderId,
1655
- requesterWs,
1656
- handler.formatReadinessProbeAck({
1657
- ok: false,
1658
- requestId,
1659
- reasonCode: "no_downstream_client",
1660
- message: "No downstream app client connected",
1661
- }),
1662
- );
1663
- }
1664
- } else {
1665
- pendingReadinessProbeRequests.set(requestId, {
1666
- requesterClientId: senderId,
1667
- targetClientId,
1668
- createdAtMs: Date.now(),
1669
- });
1670
- sendMessageToClient(targetClientId, targetWs, message);
1671
- }
1672
- }
1673
-
1674
- if (result.broadcast) {
1675
- if (Array.isArray(result.broadcast)) {
1676
- for (const msg of result.broadcast) broadcast(msg);
1677
- } else {
1678
- broadcast(result.broadcast);
1679
- }
1680
- }
1681
-
1682
- if (result.broadcastApp) {
1683
- if (Array.isArray(result.broadcastApp)) {
1684
- for (const msg of result.broadcastApp) broadcastApp(msg);
1685
- } else {
1686
- broadcastApp(result.broadcastApp);
1687
- }
1688
- }
1689
- }
1690
-
1691
- /**
1692
- * Send a message to all connected clients.
1693
- *
1694
- * @param {string} message - JSON string to send
1695
- */
1696
- function broadcast(message) {
1697
- let messageType = null;
1698
- let parsed = null;
1699
- try {
1700
- parsed = JSON.parse(message);
1701
- if (parsed && typeof parsed.type === "string") {
1702
- messageType = parsed.type;
1703
- }
1704
- } catch (_) {
1705
- messageType = null;
1706
- }
1707
-
1708
- if (
1709
- (messageType === "approval" || messageType === APP_PROTOCOL.approvalRequest) &&
1710
- parsed &&
1711
- typeof parsed.id === "string"
1712
- ) {
1713
- const approvalId = parsed.id.trim();
1714
- if (approvalId) {
1715
- unresolvedApprovals.set(approvalId, message);
1716
- }
1717
- } else if (
1718
- (messageType === "approvalResolved" ||
1719
- messageType === APP_PROTOCOL.approvalResolved) &&
1720
- parsed &&
1721
- typeof parsed.id === "string"
1722
- ) {
1723
- const approvalId = parsed.id.trim();
1724
- if (approvalId) {
1725
- unresolvedApprovals.delete(approvalId);
1726
- }
1727
- }
1728
-
1729
- const relayStreamingActivityAtMs = isStreamingBroadcastType(messageType)
1730
- ? Date.now()
1731
- : null;
1732
- for (const [clientId, ws] of clients) {
1733
- if (ws.readyState === WebSocket.OPEN) {
1734
- sendMessageToClient(clientId, ws, message);
1735
- if (relayStreamingActivityAtMs !== null) {
1736
- ensureClientNudgeState(clientId).lastRelayStreamingActivityAtMs =
1737
- relayStreamingActivityAtMs;
1738
- }
1739
- if (
1740
- messageType === "pages" ||
1741
- messageType === "status" ||
1742
- messageType === APP_PROTOCOL.pages ||
1743
- messageType === APP_PROTOCOL.status
1744
- ) {
1745
- markClientSyncedForType(clientId, messageType);
1746
- }
1747
- }
1748
- }
1749
- applyBroadcastInteractionStage(messageType, parsed, relayStreamingActivityAtMs);
1750
- }
1751
-
1752
- /**
1753
- * Send a message to all connected app/WebUI clients.
1754
- *
1755
- * @param {string} message - JSON string to send
1756
- */
1757
- function broadcastApp(message) {
1758
- let messageType = null;
1759
- try {
1760
- const parsed = JSON.parse(message);
1761
- if (parsed && typeof parsed.type === "string") {
1762
- messageType = parsed.type;
1763
- }
1764
- } catch (_) {
1765
- messageType = null;
1766
- }
1767
-
1768
- for (const [clientId, ws] of clients) {
1769
- if (!isAppClient(clientId)) {
1770
- continue;
1771
- }
1772
- if (ws.readyState !== WebSocket.OPEN) {
1773
- continue;
1774
- }
1775
- sendMessageToClient(clientId, ws, message);
1776
- if (
1777
- messageType === "pages" ||
1778
- messageType === "status" ||
1779
- messageType === APP_PROTOCOL.pages ||
1780
- messageType === APP_PROTOCOL.status ||
1781
- messageType === APP_PROTOCOL.debugConfigSnapshot
1782
- ) {
1783
- markClientSyncedForType(clientId, messageType);
1784
- }
1785
- }
1786
- }
1787
-
1788
- /**
1789
- * Send a message to a specific client.
1790
- *
1791
- * @param {string} clientId - Target client ID
1792
- * @param {string} message - JSON string to send
1793
- */
1794
- function unicast(clientId, message) {
1795
- const ws = clients.get(clientId);
1796
- if (ws && ws.readyState === WebSocket.OPEN) {
1797
- sendMessageToClient(clientId, ws, message);
1798
- }
1799
- }
1800
-
1801
- /**
1802
- * Get all connected client IDs.
1803
- *
1804
- * @returns {string[]}
1805
- */
1806
- function getClientIds() {
1807
- return Array.from(clients.keys());
1808
- }
1809
-
1810
- function getConnectedAppCount(excludeClientId = null) {
1811
- return countConnectedAppClients(excludeClientId);
1812
- }
1813
-
1814
- function getReadinessSnapshot() {
1815
- const clientsOut = [];
1816
- let latestUpdatedAtMs = null;
1817
- for (const [clientId, ws] of clients) {
1818
- if (!ws || ws.readyState !== WebSocket.OPEN) {
1819
- continue;
1820
- }
1821
- if (!isAppClient(clientId)) {
1822
- continue;
1823
- }
1824
- const entry = buildReadinessClientEntry(clientId);
1825
- clientsOut.push(entry);
1826
- const updatedAtMs =
1827
- entry &&
1828
- entry.readinessSnapshot &&
1829
- Number.isFinite(entry.readinessSnapshot.updatedAtMs)
1830
- ? entry.readinessSnapshot.updatedAtMs
1831
- : null;
1832
- if (updatedAtMs !== null && (latestUpdatedAtMs === null || updatedAtMs > latestUpdatedAtMs)) {
1833
- latestUpdatedAtMs = updatedAtMs;
1834
- }
1835
- }
1836
- clientsOut.sort((left, right) => String(left.clientId).localeCompare(String(right.clientId)));
1837
- return {
1838
- connectedClientCount: clientsOut.length,
1839
- fanoutRecipientCount: clientsOut.length,
1840
- updatedAtMs: latestUpdatedAtMs,
1841
- clients: clientsOut,
1842
- };
1843
- }
1844
-
1845
- /**
1846
- * Shut down the WebSocket server and disconnect all clients.
1847
- *
1848
- * @returns {Promise<void>}
1849
- */
1850
- function close() {
1851
- return new Promise((resolve) => {
1852
- for (const [, sync] of syncState) {
1853
- if (sync.timer) clearTimeout(sync.timer);
1854
- }
1855
- syncState.clear();
1856
- for (const [clientId] of clientNudgeState) {
1857
- clearClientNudgeTimers(clientId);
1858
- }
1859
- clientNudgeState.clear();
1860
- clientReadinessSnapshotState.clear();
1861
- pendingReadinessProbeRequests.clear();
1862
- for (const [, ws] of clients) {
1863
- ws.close();
1864
- }
1865
- clients.clear();
1866
- wss.close(() => resolve());
1867
- });
1868
- }
1869
-
1870
- return {
1871
- broadcast,
1872
- unicast,
1873
- getClientIds,
1874
- getConnectedAppCount,
1875
- getReadinessSnapshot,
1876
- closeConnectedAppClients,
1877
- getClientNudgeState(clientId) {
1878
- return cloneClientNudgeState(clientNudgeState.get(clientId) || null);
1879
- },
1880
- close,
1881
- get httpServer() {
1882
- return opts.httpServer || null;
1883
- },
1884
- /** Underlying WebSocket.Server (for reading address, etc.) */
1885
- get wss() {
1886
- return wss;
1887
- },
1888
- };
1889
- }
1890
-
1891
- export { createDownstreamServer };