ocuclaw 1.3.0 → 1.3.2

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