ocuclaw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/config/runtime-config.js +165 -0
- package/dist/domain/activity-status-adapter.js +1041 -0
- package/dist/domain/conversation-state.js +516 -0
- package/dist/domain/debug-store.js +700 -0
- package/dist/domain/message-emoji-filter.js +249 -0
- package/dist/domain/readability-system-prompt.js +17 -0
- package/dist/even-ai/even-ai-endpoint.js +938 -0
- package/dist/even-ai/even-ai-model-hook.js +80 -0
- package/dist/even-ai/even-ai-router.js +98 -0
- package/dist/even-ai/even-ai-run-waiter.js +265 -0
- package/dist/even-ai/even-ai-settings-store.js +365 -0
- package/dist/gateway/gateway-bridge.js +175 -0
- package/dist/gateway/openclaw-client.js +1570 -0
- package/dist/index.js +38 -0
- package/dist/runtime/downstream-handler.js +2747 -0
- package/dist/runtime/downstream-server.js +1565 -0
- package/dist/runtime/ocuclaw-settings-store.js +237 -0
- package/dist/runtime/protocol-adapter.js +378 -0
- package/dist/runtime/relay-core.js +1977 -0
- package/dist/runtime/relay-service.js +146 -0
- package/dist/runtime/session-service.js +1026 -0
- package/dist/runtime/upstream-runtime.js +931 -0
- package/openclaw.plugin.json +95 -0
- package/package.json +36 -0
|
@@ -0,0 +1,1565 @@
|
|
|
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, string>} */
|
|
101
|
+
const unresolvedApprovals = new Map();
|
|
102
|
+
let nextClientId = 1;
|
|
103
|
+
const APP_PROTOCOL = {
|
|
104
|
+
approvalRequest: "ocuclaw.approval.request",
|
|
105
|
+
approvalResolved: "ocuclaw.approval.resolved",
|
|
106
|
+
debugConfigSnapshot: "ocuclaw.debug.config.snapshot",
|
|
107
|
+
pages: "ocuclaw.view.pages.snapshot",
|
|
108
|
+
resume: "ocuclaw.sync.resume",
|
|
109
|
+
resumeAck: "ocuclaw.sync.resume.ack",
|
|
110
|
+
status: "ocuclaw.runtime.status",
|
|
111
|
+
};
|
|
112
|
+
const REMOVED_V1_APP_TYPES = new Set([
|
|
113
|
+
"approvalResponse",
|
|
114
|
+
"eventDebug",
|
|
115
|
+
"getEvenAiSettings",
|
|
116
|
+
"getOcuClawSettings",
|
|
117
|
+
"getModelsCatalog",
|
|
118
|
+
"getSkills",
|
|
119
|
+
"getSessionModelConfig",
|
|
120
|
+
"getSessions",
|
|
121
|
+
"newChat",
|
|
122
|
+
"newSession",
|
|
123
|
+
"resume",
|
|
124
|
+
"send",
|
|
125
|
+
"setEvenAiSettings",
|
|
126
|
+
"setOcuClawSettings",
|
|
127
|
+
"setSessionModelConfig",
|
|
128
|
+
"slashCommand",
|
|
129
|
+
"subscribeProtocol",
|
|
130
|
+
"switchSession",
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
function truncateForLog(value, maxChars = 120) {
|
|
134
|
+
if (typeof value !== "string") return "";
|
|
135
|
+
if (value.length <= maxChars) return value;
|
|
136
|
+
return `${value.slice(0, maxChars)}...`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function updateClientRole(meta, messageType, clientKind = "unknown") {
|
|
140
|
+
if (!meta || typeof messageType !== "string") return;
|
|
141
|
+
if (!meta.firstMessageType) meta.firstMessageType = messageType;
|
|
142
|
+
|
|
143
|
+
if (clientKind === "debug") {
|
|
144
|
+
meta.role = "debug";
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (meta.role === "debug") {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const nextRole =
|
|
152
|
+
messageType === "remote-control" ? "control" : "app";
|
|
153
|
+
if (meta.role === "unknown") {
|
|
154
|
+
meta.role = nextRole;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (meta.role !== nextRole) {
|
|
158
|
+
meta.role = "mixed";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseRevision(value) {
|
|
163
|
+
if (!Number.isFinite(Number(value))) return null;
|
|
164
|
+
const num = Math.floor(Number(value));
|
|
165
|
+
if (num < 0) return null;
|
|
166
|
+
return num;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseBool(value) {
|
|
170
|
+
if (value === true || value === false) return value;
|
|
171
|
+
if (typeof value === "number") return value !== 0;
|
|
172
|
+
if (typeof value !== "string") return false;
|
|
173
|
+
const normalized = value.trim().toLowerCase();
|
|
174
|
+
return normalized === "true" || normalized === "1" || normalized === "yes";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseOptionalTrimmedString(value) {
|
|
178
|
+
if (typeof value !== "string") return null;
|
|
179
|
+
const trimmed = value.trim();
|
|
180
|
+
return trimmed ? trimmed : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseVisibilityState(value) {
|
|
184
|
+
const normalized = parseOptionalTrimmedString(value);
|
|
185
|
+
if (!normalized) return null;
|
|
186
|
+
const lowered = normalized.toLowerCase();
|
|
187
|
+
return lowered === "hidden" || lowered === "visible" || lowered === "blurred"
|
|
188
|
+
? lowered
|
|
189
|
+
: null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parseVisibilityControl(value) {
|
|
193
|
+
if (!value || value.type !== "visibility") {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const state = parseVisibilityState(value.state);
|
|
197
|
+
if (!state) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
return { type: "visibility", state };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseOptionalNonNegativeInt(value) {
|
|
204
|
+
if (value === null || value === undefined) return null;
|
|
205
|
+
if (!Number.isFinite(Number(value))) return null;
|
|
206
|
+
const num = Math.floor(Number(value));
|
|
207
|
+
return num >= 0 ? num : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function parseDrainCompleteControl(value) {
|
|
211
|
+
if (!value || value.type !== "drain_complete") {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
return { type: "drain_complete" };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseEnrichedPing(value) {
|
|
218
|
+
if (!value || value.type !== "ping") {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
type: "ping",
|
|
223
|
+
ts: Number.isFinite(Number(value.ts)) ? Number(value.ts) : null,
|
|
224
|
+
streamChars: parseOptionalNonNegativeInt(value.streamChars),
|
|
225
|
+
visibilityState: parseVisibilityState(value.visibilityState),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function normalizeProtocolVersion(value) {
|
|
230
|
+
const normalized = parseOptionalTrimmedString(value);
|
|
231
|
+
if (!normalized) return null;
|
|
232
|
+
const lowered = normalized.toLowerCase();
|
|
233
|
+
return lowered === "v1" || lowered === "v2" ? lowered : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parseSupportedProtocolVersions(value) {
|
|
237
|
+
if (!Array.isArray(value)) {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
const versions = [];
|
|
241
|
+
for (const entry of value) {
|
|
242
|
+
const version = normalizeProtocolVersion(entry);
|
|
243
|
+
if (version && !versions.includes(version)) {
|
|
244
|
+
versions.push(version);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return versions;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function parseProtocolHello(value) {
|
|
251
|
+
if (!value || value.type !== "protocolHello") {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
supportedProtocolVersions: parseSupportedProtocolVersions(
|
|
256
|
+
value.supportedProtocolVersions || value.supportedVersions,
|
|
257
|
+
),
|
|
258
|
+
preferredProtocolVersion:
|
|
259
|
+
normalizeProtocolVersion(value.preferredProtocolVersion) || "v2",
|
|
260
|
+
clientName:
|
|
261
|
+
parseOptionalTrimmedString(value.clientName) ||
|
|
262
|
+
parseOptionalTrimmedString(value.clientId),
|
|
263
|
+
clientVersion: parseOptionalTrimmedString(value.clientVersion),
|
|
264
|
+
sessionKey: parseOptionalTrimmedString(value.sessionKey),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function describeProtocolClient(state) {
|
|
269
|
+
if (!state) return "unknown";
|
|
270
|
+
const clientName = parseOptionalTrimmedString(state.clientName);
|
|
271
|
+
const clientVersion = parseOptionalTrimmedString(state.clientVersion);
|
|
272
|
+
if (clientName && clientVersion) {
|
|
273
|
+
return `${clientName}/${clientVersion}`;
|
|
274
|
+
}
|
|
275
|
+
return clientName || "unknown";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function classifyClientKindFromName(clientName) {
|
|
279
|
+
const normalizedName = parseOptionalTrimmedString(clientName);
|
|
280
|
+
if (!normalizedName) return "unknown";
|
|
281
|
+
const normalized = normalizedName.toLowerCase();
|
|
282
|
+
if (normalized === "debugctl") {
|
|
283
|
+
return "debug";
|
|
284
|
+
}
|
|
285
|
+
return "app";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function classifyClientKind(state) {
|
|
289
|
+
if (!state) return "unknown";
|
|
290
|
+
const clientName = parseOptionalTrimmedString(state.clientName);
|
|
291
|
+
if (!clientName) return "unknown";
|
|
292
|
+
return classifyClientKindFromName(clientName);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function countConnectedAppClients(excludeClientId = null) {
|
|
296
|
+
let count = 0;
|
|
297
|
+
for (const [clientId, ws] of clients) {
|
|
298
|
+
if (excludeClientId && clientId === excludeClientId) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (classifyClientKind(protocolState.get(clientId)) !== "app") {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
count += 1;
|
|
308
|
+
}
|
|
309
|
+
return count;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isAppClient(clientId) {
|
|
313
|
+
return classifyClientKind(protocolState.get(clientId)) === "app";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function closeConnectedAppClients(opts = {}) {
|
|
317
|
+
const excludeClientId = parseOptionalTrimmedString(opts.excludeClientId);
|
|
318
|
+
const requestedReason = parseOptionalTrimmedString(opts.reason);
|
|
319
|
+
const reason = requestedReason || "debug_injected_transport_loss";
|
|
320
|
+
const closedClientIds = [];
|
|
321
|
+
for (const [clientId, ws] of clients) {
|
|
322
|
+
if (excludeClientId && clientId === excludeClientId) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (!isAppClient(clientId)) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (
|
|
329
|
+
!ws ||
|
|
330
|
+
(ws.readyState !== WebSocket.OPEN && ws.readyState !== WebSocket.CONNECTING)
|
|
331
|
+
) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const protocol = protocolState.get(clientId);
|
|
335
|
+
logger.warn(
|
|
336
|
+
`[downstream] ${clientId} debug injected transport loss reason="${reason}" client=${describeProtocolClient(protocol)} kind=${classifyClientKind(protocol)} session=${protocol && protocol.sessionKey ? protocol.sessionKey : "n/a"}`,
|
|
337
|
+
);
|
|
338
|
+
if (typeof ws.terminate === "function") {
|
|
339
|
+
ws.terminate();
|
|
340
|
+
} else {
|
|
341
|
+
ws.close(1012, reason);
|
|
342
|
+
}
|
|
343
|
+
closedClientIds.push(clientId);
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
closedCount: closedClientIds.length,
|
|
347
|
+
closedClientIds,
|
|
348
|
+
reason,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function interactionStageBucket(stage) {
|
|
353
|
+
switch (stage) {
|
|
354
|
+
case "listening":
|
|
355
|
+
case "voice_handoff":
|
|
356
|
+
case "thinking":
|
|
357
|
+
return "active_non_stream";
|
|
358
|
+
case "streaming":
|
|
359
|
+
case "post_turn_drain":
|
|
360
|
+
return "active_stream";
|
|
361
|
+
default:
|
|
362
|
+
return "idle";
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function formatProtocolHelloAck(payload) {
|
|
367
|
+
return JSON.stringify({
|
|
368
|
+
type: "protocolHelloAck",
|
|
369
|
+
protocolVersion: payload.protocolVersion,
|
|
370
|
+
supportedProtocolVersions: payload.supportedProtocolVersions || ["v2"],
|
|
371
|
+
reason: payload.reason || null,
|
|
372
|
+
deprecatedV1: false,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function ensureProtocolState(clientId) {
|
|
377
|
+
let state = protocolState.get(clientId);
|
|
378
|
+
if (!state) {
|
|
379
|
+
state = {
|
|
380
|
+
selectedVersion: null,
|
|
381
|
+
supportedProtocolVersions: ["v2"],
|
|
382
|
+
clientName: null,
|
|
383
|
+
clientVersion: null,
|
|
384
|
+
sessionKey: null,
|
|
385
|
+
reason: null,
|
|
386
|
+
};
|
|
387
|
+
protocolState.set(clientId, state);
|
|
388
|
+
}
|
|
389
|
+
return state;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function createClientNudgeState() {
|
|
393
|
+
return {
|
|
394
|
+
visibilityState: null,
|
|
395
|
+
streamChars: null,
|
|
396
|
+
lastHeartbeatAtMs: null,
|
|
397
|
+
lastRelayStreamingActivityAtMs: null,
|
|
398
|
+
interactionStage: "idle",
|
|
399
|
+
cadenceBucket: "idle",
|
|
400
|
+
nudgeActive: false,
|
|
401
|
+
nudgeIntervalMs: null,
|
|
402
|
+
nudgeStartedAtMs: null,
|
|
403
|
+
lastNudgeAtMs: null,
|
|
404
|
+
stalledHeartbeatCount: 0,
|
|
405
|
+
nudgeTimer: null,
|
|
406
|
+
idleDeactivateTimer: null,
|
|
407
|
+
staleHeartbeatTimer: null,
|
|
408
|
+
hardTimeoutTimer: null,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function ensureClientNudgeState(clientId) {
|
|
413
|
+
let state = clientNudgeState.get(clientId);
|
|
414
|
+
if (!state) {
|
|
415
|
+
state = createClientNudgeState();
|
|
416
|
+
clientNudgeState.set(clientId, state);
|
|
417
|
+
}
|
|
418
|
+
return state;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function cloneClientNudgeState(state) {
|
|
422
|
+
if (!state) return null;
|
|
423
|
+
return {
|
|
424
|
+
visibilityState: state.visibilityState || null,
|
|
425
|
+
streamChars: Number.isFinite(state.streamChars) ? state.streamChars : null,
|
|
426
|
+
lastHeartbeatAtMs: Number.isFinite(state.lastHeartbeatAtMs)
|
|
427
|
+
? state.lastHeartbeatAtMs
|
|
428
|
+
: null,
|
|
429
|
+
lastRelayStreamingActivityAtMs: Number.isFinite(
|
|
430
|
+
state.lastRelayStreamingActivityAtMs,
|
|
431
|
+
)
|
|
432
|
+
? state.lastRelayStreamingActivityAtMs
|
|
433
|
+
: null,
|
|
434
|
+
interactionStage: state.interactionStage || "idle",
|
|
435
|
+
cadenceBucket: state.cadenceBucket || "idle",
|
|
436
|
+
nudgeActive: !!state.nudgeActive,
|
|
437
|
+
nudgeIntervalMs: Number.isFinite(state.nudgeIntervalMs)
|
|
438
|
+
? state.nudgeIntervalMs
|
|
439
|
+
: null,
|
|
440
|
+
nudgeStartedAtMs: Number.isFinite(state.nudgeStartedAtMs)
|
|
441
|
+
? state.nudgeStartedAtMs
|
|
442
|
+
: null,
|
|
443
|
+
lastNudgeAtMs: Number.isFinite(state.lastNudgeAtMs)
|
|
444
|
+
? state.lastNudgeAtMs
|
|
445
|
+
: null,
|
|
446
|
+
stalledHeartbeatCount: Number.isFinite(state.stalledHeartbeatCount)
|
|
447
|
+
? state.stalledHeartbeatCount
|
|
448
|
+
: 0,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function clearClientNudgeTimer(state, key, clearFn = clearTimeout) {
|
|
453
|
+
if (!state || !state[key]) return;
|
|
454
|
+
clearFn(state[key]);
|
|
455
|
+
state[key] = null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function clearClientNudgeTimers(clientId) {
|
|
459
|
+
const state = clientNudgeState.get(clientId);
|
|
460
|
+
if (!state) return;
|
|
461
|
+
clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
|
|
462
|
+
clearClientNudgeTimer(state, "idleDeactivateTimer");
|
|
463
|
+
clearClientNudgeTimer(state, "staleHeartbeatTimer");
|
|
464
|
+
clearClientNudgeTimer(state, "hardTimeoutTimer");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function isStreamingBroadcastType(messageType) {
|
|
468
|
+
return (
|
|
469
|
+
messageType === "streaming" ||
|
|
470
|
+
messageType === "ocuclaw.message.stream.delta"
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function isPagesBroadcastType(messageType) {
|
|
475
|
+
return messageType === "pages" || messageType === APP_PROTOCOL.pages;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function isActivityBroadcastType(messageType) {
|
|
479
|
+
return messageType === "activity" || messageType === "ocuclaw.activity.update";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function isListenCommittedBroadcastType(messageType) {
|
|
483
|
+
return messageType === "listen-committed";
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function isListenEndedBroadcastType(messageType) {
|
|
487
|
+
return messageType === "listen-ended";
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function resetClientStallTracking(state) {
|
|
491
|
+
if (!state) return;
|
|
492
|
+
state.stalledHeartbeatCount = 0;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function hasStaleHeartbeat(state, now = Date.now()) {
|
|
496
|
+
if (!state || !Number.isFinite(state.lastHeartbeatAtMs)) {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
return now - state.lastHeartbeatAtMs >= nudgeStaleHeartbeatThresholdMs;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function isVisibilityDegraded(state) {
|
|
503
|
+
return (
|
|
504
|
+
!!state &&
|
|
505
|
+
(state.visibilityState === "hidden" || state.visibilityState === "blurred")
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function sendRenderNudge(clientId) {
|
|
510
|
+
const ws = clients.get(clientId);
|
|
511
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
512
|
+
stopClientNudges(clientId, "socket_unavailable");
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
sendMessageToClient(clientId, ws, RENDER_NUDGE_FRAME);
|
|
516
|
+
ensureClientNudgeState(clientId).lastNudgeAtMs = Date.now();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function scheduleClientHardTimeout(clientId) {
|
|
520
|
+
const state = clientNudgeState.get(clientId);
|
|
521
|
+
if (!state) return;
|
|
522
|
+
clearClientNudgeTimer(state, "hardTimeoutTimer");
|
|
523
|
+
if (!state.nudgeActive) return;
|
|
524
|
+
state.hardTimeoutTimer = setTimeout(() => {
|
|
525
|
+
state.hardTimeoutTimer = null;
|
|
526
|
+
setClientInteractionStage(clientId, "idle", {
|
|
527
|
+
reason: "nudge_hard_timeout",
|
|
528
|
+
deactivateImmediately: true,
|
|
529
|
+
});
|
|
530
|
+
}, nudgeHardTimeoutMs);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function startClientNudges(
|
|
534
|
+
clientId,
|
|
535
|
+
intervalMs,
|
|
536
|
+
reason = "nudge_start",
|
|
537
|
+
sendImmediately = false,
|
|
538
|
+
) {
|
|
539
|
+
if (!isAppClient(clientId)) return;
|
|
540
|
+
const ws = clients.get(clientId);
|
|
541
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
542
|
+
const state = ensureClientNudgeState(clientId);
|
|
543
|
+
const nextIntervalMs = Math.max(1, Math.floor(intervalMs));
|
|
544
|
+
const wasActive = !!state.nudgeActive;
|
|
545
|
+
const intervalChanged = state.nudgeIntervalMs !== nextIntervalMs;
|
|
546
|
+
state.cadenceBucket = interactionStageBucket(state.interactionStage);
|
|
547
|
+
if (!wasActive) {
|
|
548
|
+
state.nudgeActive = true;
|
|
549
|
+
state.nudgeStartedAtMs = Date.now();
|
|
550
|
+
}
|
|
551
|
+
if (wasActive && !intervalChanged) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
|
|
555
|
+
state.nudgeActive = true;
|
|
556
|
+
state.nudgeIntervalMs = nextIntervalMs;
|
|
557
|
+
state.nudgeTimer = setInterval(() => {
|
|
558
|
+
sendRenderNudge(clientId);
|
|
559
|
+
}, nextIntervalMs);
|
|
560
|
+
if (!wasActive) {
|
|
561
|
+
scheduleClientHardTimeout(clientId);
|
|
562
|
+
if (sendImmediately) {
|
|
563
|
+
sendRenderNudge(clientId);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function stopClientNudges(clientId, _reason = "nudge_stop") {
|
|
569
|
+
const state = clientNudgeState.get(clientId);
|
|
570
|
+
if (!state) return;
|
|
571
|
+
clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
|
|
572
|
+
clearClientNudgeTimer(state, "idleDeactivateTimer");
|
|
573
|
+
clearClientNudgeTimer(state, "hardTimeoutTimer");
|
|
574
|
+
state.nudgeActive = false;
|
|
575
|
+
state.nudgeIntervalMs = null;
|
|
576
|
+
state.nudgeStartedAtMs = null;
|
|
577
|
+
resetClientStallTracking(state);
|
|
578
|
+
scheduleClientStaleHeartbeatCheck(clientId);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function scheduleClientIdleDeactivation(clientId) {
|
|
582
|
+
const state = clientNudgeState.get(clientId);
|
|
583
|
+
if (!state) return;
|
|
584
|
+
clearClientNudgeTimer(state, "idleDeactivateTimer");
|
|
585
|
+
if (!state.nudgeActive || state.interactionStage !== "idle") {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
state.idleDeactivateTimer = setTimeout(() => {
|
|
589
|
+
state.idleDeactivateTimer = null;
|
|
590
|
+
const currentState = clientNudgeState.get(clientId);
|
|
591
|
+
if (!currentState || currentState.interactionStage !== "idle") {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
stopClientNudges(clientId, "idle_grace_elapsed");
|
|
595
|
+
}, nudgeIdleDeactivateMs);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function scheduleClientStaleHeartbeatCheck(clientId) {
|
|
599
|
+
const state = clientNudgeState.get(clientId);
|
|
600
|
+
if (!state) return;
|
|
601
|
+
clearClientNudgeTimer(state, "staleHeartbeatTimer");
|
|
602
|
+
if (
|
|
603
|
+
state.nudgeActive ||
|
|
604
|
+
interactionStageBucket(state.interactionStage) === "idle" ||
|
|
605
|
+
!Number.isFinite(state.lastHeartbeatAtMs)
|
|
606
|
+
) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const delayMs = Math.max(
|
|
610
|
+
0,
|
|
611
|
+
(state.lastHeartbeatAtMs + nudgeStaleHeartbeatThresholdMs) - Date.now(),
|
|
612
|
+
);
|
|
613
|
+
state.staleHeartbeatTimer = setTimeout(() => {
|
|
614
|
+
state.staleHeartbeatTimer = null;
|
|
615
|
+
const currentState = clientNudgeState.get(clientId);
|
|
616
|
+
if (
|
|
617
|
+
!currentState ||
|
|
618
|
+
currentState.nudgeActive ||
|
|
619
|
+
interactionStageBucket(currentState.interactionStage) === "idle"
|
|
620
|
+
) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (!hasStaleHeartbeat(currentState)) {
|
|
624
|
+
scheduleClientStaleHeartbeatCheck(clientId);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
startClientNudges(
|
|
628
|
+
clientId,
|
|
629
|
+
nudgeActiveIntervalMs,
|
|
630
|
+
"stale_heartbeat_fallback",
|
|
631
|
+
true,
|
|
632
|
+
);
|
|
633
|
+
}, delayMs);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function maybeActivateClientNudges(clientId, reason = "nudge_eval") {
|
|
637
|
+
const state = clientNudgeState.get(clientId);
|
|
638
|
+
if (!state || !isAppClient(clientId)) return;
|
|
639
|
+
state.cadenceBucket = interactionStageBucket(state.interactionStage);
|
|
640
|
+
if (state.cadenceBucket === "idle") {
|
|
641
|
+
clearClientNudgeTimer(state, "staleHeartbeatTimer");
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (state.nudgeActive) {
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (isVisibilityDegraded(state) || hasStaleHeartbeat(state)) {
|
|
648
|
+
startClientNudges(clientId, nudgeActiveIntervalMs, reason, true);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
scheduleClientStaleHeartbeatCheck(clientId);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function setClientInteractionStage(clientId, nextStage, options = {}) {
|
|
655
|
+
if (!isAppClient(clientId)) return;
|
|
656
|
+
const state = ensureClientNudgeState(clientId);
|
|
657
|
+
const reason = options.reason || "interaction_stage";
|
|
658
|
+
const deactivateImmediately = options.deactivateImmediately === true;
|
|
659
|
+
state.interactionStage = nextStage;
|
|
660
|
+
state.cadenceBucket = interactionStageBucket(nextStage);
|
|
661
|
+
if (state.cadenceBucket !== "active_stream") {
|
|
662
|
+
resetClientStallTracking(state);
|
|
663
|
+
}
|
|
664
|
+
if (state.cadenceBucket === "idle") {
|
|
665
|
+
clearClientNudgeTimer(state, "staleHeartbeatTimer");
|
|
666
|
+
if (deactivateImmediately) {
|
|
667
|
+
stopClientNudges(clientId, reason);
|
|
668
|
+
} else {
|
|
669
|
+
scheduleClientIdleDeactivation(clientId);
|
|
670
|
+
}
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
clearClientNudgeTimer(state, "idleDeactivateTimer");
|
|
674
|
+
if (
|
|
675
|
+
state.cadenceBucket === "active_non_stream" &&
|
|
676
|
+
state.nudgeActive &&
|
|
677
|
+
state.nudgeIntervalMs !== nudgeActiveIntervalMs
|
|
678
|
+
) {
|
|
679
|
+
startClientNudges(clientId, nudgeActiveIntervalMs, reason, false);
|
|
680
|
+
}
|
|
681
|
+
maybeActivateClientNudges(clientId, reason);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function observeRelayStreamingActivity(clientId, atMs) {
|
|
685
|
+
if (!isAppClient(clientId)) return;
|
|
686
|
+
const state = ensureClientNudgeState(clientId);
|
|
687
|
+
state.lastRelayStreamingActivityAtMs = atMs;
|
|
688
|
+
resetClientStallTracking(state);
|
|
689
|
+
if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
|
|
690
|
+
startClientNudges(clientId, nudgeActiveIntervalMs, "relay_stream_progress");
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
maybeActivateClientNudges(clientId, "relay_stream_activity");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function updateClientNudgeHeartbeat(clientId, ping) {
|
|
697
|
+
const state = ensureClientNudgeState(clientId);
|
|
698
|
+
const previousHeartbeatAtMs = Number.isFinite(state.lastHeartbeatAtMs)
|
|
699
|
+
? state.lastHeartbeatAtMs
|
|
700
|
+
: null;
|
|
701
|
+
const previousStreamChars = Number.isFinite(state.streamChars)
|
|
702
|
+
? state.streamChars
|
|
703
|
+
: null;
|
|
704
|
+
const nextStreamChars = Number.isFinite(ping.streamChars) ? ping.streamChars : null;
|
|
705
|
+
const streamAdvanced =
|
|
706
|
+
nextStreamChars !== null &&
|
|
707
|
+
(previousStreamChars === null || nextStreamChars > previousStreamChars);
|
|
708
|
+
const relayStreamAdvanced =
|
|
709
|
+
previousHeartbeatAtMs !== null &&
|
|
710
|
+
Number.isFinite(state.lastRelayStreamingActivityAtMs) &&
|
|
711
|
+
state.lastRelayStreamingActivityAtMs > previousHeartbeatAtMs;
|
|
712
|
+
state.streamChars = nextStreamChars;
|
|
713
|
+
if (ping.visibilityState) {
|
|
714
|
+
state.visibilityState = ping.visibilityState;
|
|
715
|
+
}
|
|
716
|
+
state.lastHeartbeatAtMs = Date.now();
|
|
717
|
+
state.cadenceBucket = interactionStageBucket(state.interactionStage);
|
|
718
|
+
if (state.visibilityState === "visible") {
|
|
719
|
+
stopClientNudges(clientId, "heartbeat_visible");
|
|
720
|
+
}
|
|
721
|
+
if (state.cadenceBucket === "active_stream") {
|
|
722
|
+
if (streamAdvanced || relayStreamAdvanced) {
|
|
723
|
+
resetClientStallTracking(state);
|
|
724
|
+
if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
|
|
725
|
+
startClientNudges(
|
|
726
|
+
clientId,
|
|
727
|
+
nudgeActiveIntervalMs,
|
|
728
|
+
streamAdvanced
|
|
729
|
+
? "heartbeat_stream_progress"
|
|
730
|
+
: "relay_stream_progress",
|
|
731
|
+
false,
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
} else if (state.nudgeActive) {
|
|
735
|
+
state.stalledHeartbeatCount += 1;
|
|
736
|
+
if (
|
|
737
|
+
state.stalledHeartbeatCount >= 3 &&
|
|
738
|
+
state.nudgeIntervalMs !== nudgeSlowIntervalMs
|
|
739
|
+
) {
|
|
740
|
+
startClientNudges(
|
|
741
|
+
clientId,
|
|
742
|
+
nudgeSlowIntervalMs,
|
|
743
|
+
"stream_stalled_decelerated",
|
|
744
|
+
false,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
} else {
|
|
749
|
+
resetClientStallTracking(state);
|
|
750
|
+
if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
|
|
751
|
+
startClientNudges(clientId, nudgeActiveIntervalMs, "non_stream_fast", false);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
maybeActivateClientNudges(clientId, "heartbeat_update");
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function updateClientVisibilityState(clientId, visibilityState) {
|
|
758
|
+
const state = ensureClientNudgeState(clientId);
|
|
759
|
+
state.visibilityState = visibilityState;
|
|
760
|
+
if (visibilityState === "visible") {
|
|
761
|
+
stopClientNudges(clientId, "visibility_visible");
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
maybeActivateClientNudges(clientId, "visibility_hidden");
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function applyBroadcastInteractionStage(messageType, parsed, relayStreamingActivityAtMs) {
|
|
768
|
+
for (const [clientId] of clients) {
|
|
769
|
+
if (!isAppClient(clientId)) {
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (relayStreamingActivityAtMs !== null) {
|
|
773
|
+
setClientInteractionStage(clientId, "streaming", {
|
|
774
|
+
reason: "relay_streaming",
|
|
775
|
+
});
|
|
776
|
+
observeRelayStreamingActivity(clientId, relayStreamingActivityAtMs);
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
if (isListenCommittedBroadcastType(messageType)) {
|
|
780
|
+
setClientInteractionStage(clientId, "voice_handoff", {
|
|
781
|
+
reason: "listen_committed",
|
|
782
|
+
});
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
if (isListenEndedBroadcastType(messageType)) {
|
|
786
|
+
setClientInteractionStage(clientId, "idle", {
|
|
787
|
+
reason: "listen_ended",
|
|
788
|
+
deactivateImmediately: true,
|
|
789
|
+
});
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (isActivityBroadcastType(messageType)) {
|
|
793
|
+
const activityState = parseOptionalTrimmedString(parsed && parsed.state);
|
|
794
|
+
const normalizedActivity = activityState ? activityState.toLowerCase() : null;
|
|
795
|
+
const currentStage = ensureClientNudgeState(clientId).interactionStage;
|
|
796
|
+
if (normalizedActivity === "thinking") {
|
|
797
|
+
setClientInteractionStage(clientId, "thinking", {
|
|
798
|
+
reason: "activity_thinking",
|
|
799
|
+
});
|
|
800
|
+
} else if (normalizedActivity === "idle") {
|
|
801
|
+
if (currentStage === "streaming" || currentStage === "post_turn_drain") {
|
|
802
|
+
setClientInteractionStage(clientId, "post_turn_drain", {
|
|
803
|
+
reason: "activity_idle_stream_drain",
|
|
804
|
+
});
|
|
805
|
+
} else {
|
|
806
|
+
setClientInteractionStage(clientId, "idle", {
|
|
807
|
+
reason: "activity_idle",
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
if (isPagesBroadcastType(messageType)) {
|
|
814
|
+
const currentState = ensureClientNudgeState(clientId);
|
|
815
|
+
if (interactionStageBucket(currentState.interactionStage) !== "idle") {
|
|
816
|
+
setClientInteractionStage(clientId, "post_turn_drain", {
|
|
817
|
+
reason: "pages_snapshot",
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function updateProtocolSessionKey(clientId, value) {
|
|
825
|
+
const sessionKey = parseOptionalTrimmedString(value);
|
|
826
|
+
if (!sessionKey) return;
|
|
827
|
+
ensureProtocolState(clientId).sessionKey = sessionKey;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function updateProtocolState(clientId, overrides = {}) {
|
|
831
|
+
const state = ensureProtocolState(clientId);
|
|
832
|
+
if (overrides.supportedProtocolVersions) {
|
|
833
|
+
state.supportedProtocolVersions = parseSupportedProtocolVersions(
|
|
834
|
+
overrides.supportedProtocolVersions,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
if (overrides.clientName) {
|
|
838
|
+
state.clientName = parseOptionalTrimmedString(overrides.clientName);
|
|
839
|
+
}
|
|
840
|
+
if (overrides.clientVersion) {
|
|
841
|
+
state.clientVersion = parseOptionalTrimmedString(overrides.clientVersion);
|
|
842
|
+
}
|
|
843
|
+
if (overrides.sessionKey) {
|
|
844
|
+
state.sessionKey = parseOptionalTrimmedString(overrides.sessionKey);
|
|
845
|
+
}
|
|
846
|
+
if (overrides.selectedVersion !== undefined) {
|
|
847
|
+
state.selectedVersion = overrides.selectedVersion;
|
|
848
|
+
}
|
|
849
|
+
if (overrides.reason !== undefined) {
|
|
850
|
+
state.reason = overrides.reason || null;
|
|
851
|
+
}
|
|
852
|
+
return state;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function clearSyncTimer(clientId) {
|
|
856
|
+
const sync = syncState.get(clientId);
|
|
857
|
+
if (sync && sync.timer) {
|
|
858
|
+
clearTimeout(sync.timer);
|
|
859
|
+
sync.timer = null;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function closeForProtocolViolation(clientId, ws, reason, overrides = {}) {
|
|
864
|
+
updateProtocolState(clientId, { ...overrides, reason });
|
|
865
|
+
clearSyncTimer(clientId);
|
|
866
|
+
if (
|
|
867
|
+
ws &&
|
|
868
|
+
(ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)
|
|
869
|
+
) {
|
|
870
|
+
ws.close(1008, reason);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function armResumeHandshakeTimeout(clientId, ws) {
|
|
875
|
+
const sync = syncState.get(clientId);
|
|
876
|
+
if (!sync) return;
|
|
877
|
+
clearSyncTimer(clientId);
|
|
878
|
+
sync.timer = setTimeout(() => {
|
|
879
|
+
sync.timer = null;
|
|
880
|
+
syncSnapshotForClient(clientId, ws, "connect_timeout", null);
|
|
881
|
+
}, resumeHandshakeTimeoutMs);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function sendMessageToClient(clientId, ws, message) {
|
|
885
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
ws.send(message);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function getServerResumeState() {
|
|
892
|
+
if (!getCurrentResumeState) {
|
|
893
|
+
return { pagesRevision: null, statusRevision: null };
|
|
894
|
+
}
|
|
895
|
+
const state = getCurrentResumeState() || {};
|
|
896
|
+
return {
|
|
897
|
+
pagesRevision: parseRevision(state.pagesRevision),
|
|
898
|
+
statusRevision: parseRevision(state.statusRevision),
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function formatResumeAck(payload) {
|
|
903
|
+
return JSON.stringify({
|
|
904
|
+
type: APP_PROTOCOL.resumeAck,
|
|
905
|
+
reason: payload.reason || null,
|
|
906
|
+
sentPages: !!payload.sentPages,
|
|
907
|
+
sentStatus: !!payload.sentStatus,
|
|
908
|
+
sentApprovals:
|
|
909
|
+
Number.isFinite(payload.sentApprovals) && payload.sentApprovals > 0
|
|
910
|
+
? Math.floor(payload.sentApprovals)
|
|
911
|
+
: 0,
|
|
912
|
+
pagesRevision:
|
|
913
|
+
payload.pagesRevision === null || payload.pagesRevision === undefined
|
|
914
|
+
? null
|
|
915
|
+
: payload.pagesRevision,
|
|
916
|
+
statusRevision:
|
|
917
|
+
payload.statusRevision === null || payload.statusRevision === undefined
|
|
918
|
+
? null
|
|
919
|
+
: payload.statusRevision,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function isSnapshotSynced(sync) {
|
|
924
|
+
return (
|
|
925
|
+
!!sync &&
|
|
926
|
+
sync.pagesSynced &&
|
|
927
|
+
sync.statusSynced &&
|
|
928
|
+
sync.approvalsSynced &&
|
|
929
|
+
sync.debugConfigSynced
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function markClientSyncedForType(clientId, messageType) {
|
|
934
|
+
const sync = syncState.get(clientId);
|
|
935
|
+
if (!sync) return;
|
|
936
|
+
if (messageType === "pages" || messageType === APP_PROTOCOL.pages) {
|
|
937
|
+
sync.pagesSynced = true;
|
|
938
|
+
}
|
|
939
|
+
if (messageType === "status" || messageType === APP_PROTOCOL.status) {
|
|
940
|
+
sync.statusSynced = true;
|
|
941
|
+
}
|
|
942
|
+
if (messageType === APP_PROTOCOL.debugConfigSnapshot) {
|
|
943
|
+
sync.debugConfigSynced = true;
|
|
944
|
+
}
|
|
945
|
+
if (isSnapshotSynced(sync) && sync.timer) {
|
|
946
|
+
clearTimeout(sync.timer);
|
|
947
|
+
sync.timer = null;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function syncSnapshotForClient(clientId, ws, reason, resumeRequest) {
|
|
952
|
+
const sync = syncState.get(clientId);
|
|
953
|
+
if (!sync || isSnapshotSynced(sync)) {
|
|
954
|
+
const state = getServerResumeState();
|
|
955
|
+
return {
|
|
956
|
+
reason,
|
|
957
|
+
sentPages: false,
|
|
958
|
+
sentStatus: false,
|
|
959
|
+
sentApprovals: 0,
|
|
960
|
+
pagesRevision: state.pagesRevision,
|
|
961
|
+
statusRevision: state.statusRevision,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (sync.timer) {
|
|
966
|
+
clearTimeout(sync.timer);
|
|
967
|
+
sync.timer = null;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const pages = getCurrentPages();
|
|
971
|
+
const status = getCurrentStatus();
|
|
972
|
+
const debugConfig = getCurrentDebugConfig ? getCurrentDebugConfig() : null;
|
|
973
|
+
const state = getServerResumeState();
|
|
974
|
+
const clientPagesRevision = parseRevision(
|
|
975
|
+
resumeRequest && resumeRequest.pagesRevision,
|
|
976
|
+
);
|
|
977
|
+
const clientStatusRevision = parseRevision(
|
|
978
|
+
resumeRequest && resumeRequest.statusRevision,
|
|
979
|
+
);
|
|
980
|
+
const clientHasPagesState = parseBool(
|
|
981
|
+
resumeRequest && resumeRequest.hasPagesState,
|
|
982
|
+
);
|
|
983
|
+
const clientHasStatusState = parseBool(
|
|
984
|
+
resumeRequest && resumeRequest.hasStatusState,
|
|
985
|
+
);
|
|
986
|
+
const pagesInSync =
|
|
987
|
+
clientHasPagesState &&
|
|
988
|
+
state.pagesRevision !== null &&
|
|
989
|
+
clientPagesRevision !== null &&
|
|
990
|
+
state.pagesRevision === clientPagesRevision;
|
|
991
|
+
let sentPages = false;
|
|
992
|
+
let sentStatus = false;
|
|
993
|
+
let sentApprovals = 0;
|
|
994
|
+
let sentDebugConfig = false;
|
|
995
|
+
if (!sync.pagesSynced) {
|
|
996
|
+
if (pages === null || pagesInSync) {
|
|
997
|
+
sync.pagesSynced = true;
|
|
998
|
+
} else if (ws.readyState === WebSocket.OPEN) {
|
|
999
|
+
sendMessageToClient(clientId, ws, pages);
|
|
1000
|
+
sentPages = true;
|
|
1001
|
+
sync.pagesSynced = true;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
if (!sync.statusSynced) {
|
|
1005
|
+
// Always replay the current status once per downstream reconnect. The client
|
|
1006
|
+
// uses that fresh status snapshot to clear reconnect UI even when revisions
|
|
1007
|
+
// appear to match after a relay restart.
|
|
1008
|
+
if (status === null) {
|
|
1009
|
+
sync.statusSynced = true;
|
|
1010
|
+
} else if (ws.readyState === WebSocket.OPEN) {
|
|
1011
|
+
sendMessageToClient(clientId, ws, status);
|
|
1012
|
+
sentStatus = true;
|
|
1013
|
+
sync.statusSynced = true;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
if (!sync.approvalsSynced) {
|
|
1017
|
+
if (unresolvedApprovals.size === 0) {
|
|
1018
|
+
sync.approvalsSynced = true;
|
|
1019
|
+
} else {
|
|
1020
|
+
for (const approvalMessage of unresolvedApprovals.values()) {
|
|
1021
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
1022
|
+
break;
|
|
1023
|
+
}
|
|
1024
|
+
sendMessageToClient(clientId, ws, approvalMessage);
|
|
1025
|
+
sentApprovals += 1;
|
|
1026
|
+
}
|
|
1027
|
+
sync.approvalsSynced = ws.readyState === WebSocket.OPEN;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
if (!sync.debugConfigSynced) {
|
|
1031
|
+
if (!isAppClient(clientId) || debugConfig === null) {
|
|
1032
|
+
sync.debugConfigSynced = true;
|
|
1033
|
+
} else if (ws.readyState === WebSocket.OPEN) {
|
|
1034
|
+
sendMessageToClient(clientId, ws, debugConfig);
|
|
1035
|
+
sentDebugConfig = true;
|
|
1036
|
+
sync.debugConfigSynced = true;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
if (isSnapshotSynced(sync) && sync.timer) {
|
|
1040
|
+
clearTimeout(sync.timer);
|
|
1041
|
+
sync.timer = null;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
return {
|
|
1045
|
+
reason,
|
|
1046
|
+
sentPages,
|
|
1047
|
+
sentStatus,
|
|
1048
|
+
sentApprovals,
|
|
1049
|
+
sentDebugConfig,
|
|
1050
|
+
pagesRevision: state.pagesRevision,
|
|
1051
|
+
statusRevision: state.statusRevision,
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// When an httpServer is provided, attach to it via noServer mode so HTTP
|
|
1056
|
+
// and WebSocket share a single port. Otherwise listen on a standalone port
|
|
1057
|
+
// (used when HTTP is disabled and in tests).
|
|
1058
|
+
let wss;
|
|
1059
|
+
if (opts.httpServer) {
|
|
1060
|
+
wss = new WebSocketServer({ noServer: true });
|
|
1061
|
+
opts.httpServer.on("upgrade", (req, socket, head) => {
|
|
1062
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1063
|
+
if (url.searchParams.get("token") !== token) {
|
|
1064
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
1065
|
+
socket.destroy();
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1069
|
+
wss.emit("connection", ws, req);
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
} else {
|
|
1073
|
+
wss = new WebSocketServer({
|
|
1074
|
+
host: opts.host,
|
|
1075
|
+
port: opts.port,
|
|
1076
|
+
verifyClient: ({ req }) => {
|
|
1077
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1078
|
+
return url.searchParams.get("token") === token;
|
|
1079
|
+
},
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
wss.on("connection", (ws, req) => {
|
|
1084
|
+
const clientId = `client-${nextClientId++}`;
|
|
1085
|
+
clients.set(clientId, ws);
|
|
1086
|
+
const connectedAtMs = Date.now();
|
|
1087
|
+
const remoteAddress =
|
|
1088
|
+
(req && req.socket && req.socket.remoteAddress) || "unknown";
|
|
1089
|
+
const userAgent =
|
|
1090
|
+
(req && req.headers && req.headers["user-agent"]) || "unknown";
|
|
1091
|
+
const clientMeta = {
|
|
1092
|
+
role: "unknown",
|
|
1093
|
+
firstMessageType: null,
|
|
1094
|
+
textMessageCount: 0,
|
|
1095
|
+
binaryMessageCount: 0,
|
|
1096
|
+
remoteControlCount: 0,
|
|
1097
|
+
connectedEventEmitted: false,
|
|
1098
|
+
};
|
|
1099
|
+
ensureProtocolState(clientId);
|
|
1100
|
+
clientNudgeState.set(clientId, createClientNudgeState());
|
|
1101
|
+
const currentPages = getCurrentPages();
|
|
1102
|
+
const currentStatus = getCurrentStatus();
|
|
1103
|
+
syncState.set(clientId, {
|
|
1104
|
+
pagesSynced: currentPages === null,
|
|
1105
|
+
statusSynced: currentStatus === null,
|
|
1106
|
+
approvalsSynced: unresolvedApprovals.size === 0,
|
|
1107
|
+
debugConfigSynced: !getCurrentDebugConfig,
|
|
1108
|
+
resumeReceived: false,
|
|
1109
|
+
timer: null,
|
|
1110
|
+
});
|
|
1111
|
+
const sync = syncState.get(clientId);
|
|
1112
|
+
if (sync) {
|
|
1113
|
+
sync.timer = setTimeout(() => {
|
|
1114
|
+
sync.timer = null;
|
|
1115
|
+
closeForProtocolViolation(clientId, ws, "protocol_hello_timeout");
|
|
1116
|
+
}, protocolHelloTimeoutMs);
|
|
1117
|
+
}
|
|
1118
|
+
logger.info(
|
|
1119
|
+
`[downstream] ${clientId} connected (${clients.size} total) ip=${remoteAddress} ua=${truncateForLog(String(userAgent), 100)}`,
|
|
1120
|
+
);
|
|
1121
|
+
|
|
1122
|
+
ws.on("error", (err) => {
|
|
1123
|
+
logger.error(`[downstream] ${clientId} error: ${err.message}`);
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
ws.on("message", (data, isBinary) => {
|
|
1127
|
+
// Legacy downstream binary voice transport was removed. Ignore binary frames.
|
|
1128
|
+
if (isBinary) {
|
|
1129
|
+
clientMeta.binaryMessageCount += 1;
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
clientMeta.textMessageCount += 1;
|
|
1134
|
+
const raw = data.toString();
|
|
1135
|
+
let parsed = null;
|
|
1136
|
+
try {
|
|
1137
|
+
parsed = JSON.parse(raw);
|
|
1138
|
+
} catch (_) {
|
|
1139
|
+
parsed = null;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const protocolHello = parseProtocolHello(parsed);
|
|
1143
|
+
|
|
1144
|
+
if (parsed && typeof parsed.type === "string") {
|
|
1145
|
+
const clientKind = protocolHello
|
|
1146
|
+
? classifyClientKindFromName(protocolHello.clientName)
|
|
1147
|
+
: "unknown";
|
|
1148
|
+
updateClientRole(clientMeta, parsed.type, clientKind);
|
|
1149
|
+
if (parsed.type === "remote-control") {
|
|
1150
|
+
clientMeta.remoteControlCount += 1;
|
|
1151
|
+
}
|
|
1152
|
+
updateProtocolSessionKey(clientId, parsed.sessionKey);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const enrichedPing = parseEnrichedPing(parsed);
|
|
1156
|
+
|
|
1157
|
+
// Intercept application-level ping — respond with pong, skip handler
|
|
1158
|
+
if (enrichedPing) {
|
|
1159
|
+
updateClientNudgeHeartbeat(clientId, enrichedPing);
|
|
1160
|
+
ws.send(JSON.stringify({ type: "pong", ts: parsed.ts }));
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (protocolHello) {
|
|
1165
|
+
if (!protocolHello.supportedProtocolVersions.includes("v2")) {
|
|
1166
|
+
closeForProtocolViolation(clientId, ws, "protocol_v2_required", {
|
|
1167
|
+
supportedProtocolVersions: protocolHello.supportedProtocolVersions,
|
|
1168
|
+
clientName: protocolHello.clientName,
|
|
1169
|
+
clientVersion: protocolHello.clientVersion,
|
|
1170
|
+
sessionKey: protocolHello.sessionKey,
|
|
1171
|
+
});
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
const protocolClientKind = classifyClientKindFromName(
|
|
1175
|
+
protocolHello.clientName,
|
|
1176
|
+
);
|
|
1177
|
+
if (!externalDebugToolsEnabled && protocolClientKind === "debug") {
|
|
1178
|
+
closeForProtocolViolation(clientId, ws, "external_debug_tools_disabled", {
|
|
1179
|
+
supportedProtocolVersions: protocolHello.supportedProtocolVersions,
|
|
1180
|
+
clientName: protocolHello.clientName,
|
|
1181
|
+
clientVersion: protocolHello.clientVersion,
|
|
1182
|
+
sessionKey: protocolHello.sessionKey,
|
|
1183
|
+
});
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
const state = updateProtocolState(clientId, {
|
|
1187
|
+
selectedVersion: "v2",
|
|
1188
|
+
reason: "negotiated_v2",
|
|
1189
|
+
supportedProtocolVersions: protocolHello.supportedProtocolVersions,
|
|
1190
|
+
clientName: protocolHello.clientName,
|
|
1191
|
+
clientVersion: protocolHello.clientVersion,
|
|
1192
|
+
sessionKey: protocolHello.sessionKey,
|
|
1193
|
+
});
|
|
1194
|
+
logger.info(
|
|
1195
|
+
`[downstream] ${clientId} identified protocol=${state.selectedVersion || "n/a"} client=${describeProtocolClient(state)} kind=${classifyClientKind(state)} session=${state.sessionKey || "n/a"}`,
|
|
1196
|
+
);
|
|
1197
|
+
if (!clientMeta.connectedEventEmitted) {
|
|
1198
|
+
clientMeta.connectedEventEmitted = true;
|
|
1199
|
+
if (onClientConnected) {
|
|
1200
|
+
onClientConnected({
|
|
1201
|
+
clientId,
|
|
1202
|
+
connectedCount: countConnectedAppClients(),
|
|
1203
|
+
connectedAtMs,
|
|
1204
|
+
remoteAddress,
|
|
1205
|
+
userAgent: truncateForLog(String(userAgent), 180),
|
|
1206
|
+
role: clientMeta.role,
|
|
1207
|
+
protocolVersion: state.selectedVersion || null,
|
|
1208
|
+
protocolReason: state.reason || null,
|
|
1209
|
+
clientKind: classifyClientKind(state),
|
|
1210
|
+
clientName: state.clientName || null,
|
|
1211
|
+
clientVersion: state.clientVersion || null,
|
|
1212
|
+
sessionKey: state.sessionKey || null,
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
armResumeHandshakeTimeout(clientId, ws);
|
|
1217
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1218
|
+
ws.send(
|
|
1219
|
+
formatProtocolHelloAck({
|
|
1220
|
+
protocolVersion: state.selectedVersion || "v2",
|
|
1221
|
+
supportedProtocolVersions: ["v2"],
|
|
1222
|
+
reason: state.reason,
|
|
1223
|
+
}),
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const state = ensureProtocolState(clientId);
|
|
1230
|
+
if (!state.selectedVersion && parsed && typeof parsed.type === "string") {
|
|
1231
|
+
closeForProtocolViolation(clientId, ws, "protocol_hello_required", {
|
|
1232
|
+
clientName: parsed.clientName,
|
|
1233
|
+
clientVersion: parsed.clientVersion,
|
|
1234
|
+
sessionKey: parsed.sessionKey,
|
|
1235
|
+
});
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
if (
|
|
1240
|
+
state.selectedVersion === "v2" &&
|
|
1241
|
+
parsed &&
|
|
1242
|
+
REMOVED_V1_APP_TYPES.has(parsed.type)
|
|
1243
|
+
) {
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (parsed && parsed.type === APP_PROTOCOL.resume) {
|
|
1248
|
+
const sync = syncState.get(clientId);
|
|
1249
|
+
if (sync) sync.resumeReceived = true;
|
|
1250
|
+
const ack = syncSnapshotForClient(clientId, ws, "resume", parsed);
|
|
1251
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1252
|
+
sendMessageToClient(clientId, ws, formatResumeAck(ack));
|
|
1253
|
+
}
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const visibilityControl = parseVisibilityControl(parsed);
|
|
1258
|
+
const drainCompleteControl = parseDrainCompleteControl(parsed);
|
|
1259
|
+
const transportControl = visibilityControl || drainCompleteControl;
|
|
1260
|
+
if (transportControl) {
|
|
1261
|
+
const protocol = protocolState.get(clientId);
|
|
1262
|
+
if (transportControl.type === "visibility") {
|
|
1263
|
+
updateClientVisibilityState(clientId, transportControl.state);
|
|
1264
|
+
} else {
|
|
1265
|
+
setClientInteractionStage(clientId, "idle", {
|
|
1266
|
+
reason: "drain_complete",
|
|
1267
|
+
deactivateImmediately: true,
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
const meta = {
|
|
1271
|
+
clientId,
|
|
1272
|
+
controlType: transportControl.type,
|
|
1273
|
+
state: transportControl.type === "visibility" ? transportControl.state : null,
|
|
1274
|
+
connectedCount: countConnectedAppClients(),
|
|
1275
|
+
role: clientMeta.role,
|
|
1276
|
+
clientKind: classifyClientKind(protocol),
|
|
1277
|
+
clientName: protocol && protocol.clientName ? protocol.clientName : null,
|
|
1278
|
+
clientVersion: protocol && protocol.clientVersion ? protocol.clientVersion : null,
|
|
1279
|
+
protocolVersion:
|
|
1280
|
+
protocol && protocol.selectedVersion ? protocol.selectedVersion : null,
|
|
1281
|
+
sessionKey: protocol && protocol.sessionKey ? protocol.sessionKey : null,
|
|
1282
|
+
};
|
|
1283
|
+
if (transportControl.type === "visibility") {
|
|
1284
|
+
logger.info(
|
|
1285
|
+
`[downstream] ${clientId} transport visibility state=${transportControl.state} client=${describeProtocolClient(protocol)} kind=${classifyClientKind(protocol)} session=${protocol && protocol.sessionKey ? protocol.sessionKey : "n/a"}`,
|
|
1286
|
+
);
|
|
1287
|
+
} else {
|
|
1288
|
+
logger.info(
|
|
1289
|
+
`[downstream] ${clientId} transport control=${transportControl.type} client=${describeProtocolClient(protocol)} kind=${classifyClientKind(protocol)} session=${protocol && protocol.sessionKey ? protocol.sessionKey : "n/a"}`,
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
if (onTransportControl) {
|
|
1293
|
+
onTransportControl(meta);
|
|
1294
|
+
}
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const result = handler.handleMessage(clientId, raw);
|
|
1299
|
+
processResult(clientId, result);
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
ws.on("close", (code, reasonBuffer) => {
|
|
1303
|
+
const sync = syncState.get(clientId);
|
|
1304
|
+
if (sync && sync.timer) {
|
|
1305
|
+
clearTimeout(sync.timer);
|
|
1306
|
+
}
|
|
1307
|
+
const protocol = protocolState.get(clientId);
|
|
1308
|
+
syncState.delete(clientId);
|
|
1309
|
+
protocolState.delete(clientId);
|
|
1310
|
+
clearClientNudgeTimers(clientId);
|
|
1311
|
+
clientNudgeState.delete(clientId);
|
|
1312
|
+
handler.removeClient(clientId);
|
|
1313
|
+
clients.delete(clientId);
|
|
1314
|
+
const lifetimeMs = Date.now() - connectedAtMs;
|
|
1315
|
+
const reason =
|
|
1316
|
+
reasonBuffer && reasonBuffer.length > 0
|
|
1317
|
+
? truncateForLog(reasonBuffer.toString("utf8").replace(/\s+/g, " "), 120)
|
|
1318
|
+
: "";
|
|
1319
|
+
logger.info(
|
|
1320
|
+
`[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}`,
|
|
1321
|
+
);
|
|
1322
|
+
if (onClientDisconnected && clientMeta.connectedEventEmitted) {
|
|
1323
|
+
onClientDisconnected({
|
|
1324
|
+
clientId,
|
|
1325
|
+
connectedCount: countConnectedAppClients(),
|
|
1326
|
+
connectedAtMs,
|
|
1327
|
+
lifetimeMs,
|
|
1328
|
+
closeCode: code,
|
|
1329
|
+
closeReason: reason,
|
|
1330
|
+
role: clientMeta.role,
|
|
1331
|
+
protocolVersion:
|
|
1332
|
+
protocol && protocol.selectedVersion ? protocol.selectedVersion : null,
|
|
1333
|
+
protocolReason: protocol && protocol.reason ? protocol.reason : null,
|
|
1334
|
+
clientKind: classifyClientKind(protocol),
|
|
1335
|
+
clientName: protocol && protocol.clientName ? protocol.clientName : null,
|
|
1336
|
+
clientVersion:
|
|
1337
|
+
protocol && protocol.clientVersion ? protocol.clientVersion : null,
|
|
1338
|
+
sessionKey: protocol && protocol.sessionKey ? protocol.sessionKey : null,
|
|
1339
|
+
firstMessageType: clientMeta.firstMessageType || null,
|
|
1340
|
+
textMessageCount: clientMeta.textMessageCount,
|
|
1341
|
+
binaryMessageCount: clientMeta.binaryMessageCount,
|
|
1342
|
+
remoteControlCount: clientMeta.remoteControlCount,
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
});
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Process the result from handler.handleMessage.
|
|
1350
|
+
*
|
|
1351
|
+
* Handles both synchronous values and promises. Dispatches unicast
|
|
1352
|
+
* or broadcast actions as appropriate.
|
|
1353
|
+
*
|
|
1354
|
+
* @param {string} senderId - Client ID that sent the original message
|
|
1355
|
+
* @param {object|null|Promise} result - Handler return value
|
|
1356
|
+
*/
|
|
1357
|
+
function processResult(senderId, result) {
|
|
1358
|
+
if (result === null || result === undefined) {
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
if (typeof result.then === "function") {
|
|
1363
|
+
result.then(
|
|
1364
|
+
(resolved) => processResult(senderId, resolved),
|
|
1365
|
+
(err) => {
|
|
1366
|
+
logger.error(
|
|
1367
|
+
`[downstream] Error processing message from ${senderId}:`,
|
|
1368
|
+
err,
|
|
1369
|
+
);
|
|
1370
|
+
},
|
|
1371
|
+
);
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
if (result.unicast) {
|
|
1376
|
+
const ws = clients.get(senderId);
|
|
1377
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1378
|
+
sendMessageToClient(senderId, ws, result.unicast);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
if (result.broadcast) {
|
|
1383
|
+
if (Array.isArray(result.broadcast)) {
|
|
1384
|
+
for (const msg of result.broadcast) broadcast(msg);
|
|
1385
|
+
} else {
|
|
1386
|
+
broadcast(result.broadcast);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
if (result.broadcastApp) {
|
|
1391
|
+
if (Array.isArray(result.broadcastApp)) {
|
|
1392
|
+
for (const msg of result.broadcastApp) broadcastApp(msg);
|
|
1393
|
+
} else {
|
|
1394
|
+
broadcastApp(result.broadcastApp);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/**
|
|
1400
|
+
* Send a message to all connected clients.
|
|
1401
|
+
*
|
|
1402
|
+
* @param {string} message - JSON string to send
|
|
1403
|
+
*/
|
|
1404
|
+
function broadcast(message) {
|
|
1405
|
+
let messageType = null;
|
|
1406
|
+
let parsed = null;
|
|
1407
|
+
try {
|
|
1408
|
+
parsed = JSON.parse(message);
|
|
1409
|
+
if (parsed && typeof parsed.type === "string") {
|
|
1410
|
+
messageType = parsed.type;
|
|
1411
|
+
}
|
|
1412
|
+
} catch (_) {
|
|
1413
|
+
messageType = null;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
if (
|
|
1417
|
+
(messageType === "approval" || messageType === APP_PROTOCOL.approvalRequest) &&
|
|
1418
|
+
parsed &&
|
|
1419
|
+
typeof parsed.id === "string"
|
|
1420
|
+
) {
|
|
1421
|
+
const approvalId = parsed.id.trim();
|
|
1422
|
+
if (approvalId) {
|
|
1423
|
+
unresolvedApprovals.set(approvalId, message);
|
|
1424
|
+
}
|
|
1425
|
+
} else if (
|
|
1426
|
+
(messageType === "approvalResolved" ||
|
|
1427
|
+
messageType === APP_PROTOCOL.approvalResolved) &&
|
|
1428
|
+
parsed &&
|
|
1429
|
+
typeof parsed.id === "string"
|
|
1430
|
+
) {
|
|
1431
|
+
const approvalId = parsed.id.trim();
|
|
1432
|
+
if (approvalId) {
|
|
1433
|
+
unresolvedApprovals.delete(approvalId);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const relayStreamingActivityAtMs = isStreamingBroadcastType(messageType)
|
|
1438
|
+
? Date.now()
|
|
1439
|
+
: null;
|
|
1440
|
+
for (const [clientId, ws] of clients) {
|
|
1441
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1442
|
+
sendMessageToClient(clientId, ws, message);
|
|
1443
|
+
if (relayStreamingActivityAtMs !== null) {
|
|
1444
|
+
ensureClientNudgeState(clientId).lastRelayStreamingActivityAtMs =
|
|
1445
|
+
relayStreamingActivityAtMs;
|
|
1446
|
+
}
|
|
1447
|
+
if (
|
|
1448
|
+
messageType === "pages" ||
|
|
1449
|
+
messageType === "status" ||
|
|
1450
|
+
messageType === APP_PROTOCOL.pages ||
|
|
1451
|
+
messageType === APP_PROTOCOL.status
|
|
1452
|
+
) {
|
|
1453
|
+
markClientSyncedForType(clientId, messageType);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
applyBroadcastInteractionStage(messageType, parsed, relayStreamingActivityAtMs);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* Send a message to all connected app/WebUI clients.
|
|
1462
|
+
*
|
|
1463
|
+
* @param {string} message - JSON string to send
|
|
1464
|
+
*/
|
|
1465
|
+
function broadcastApp(message) {
|
|
1466
|
+
let messageType = null;
|
|
1467
|
+
try {
|
|
1468
|
+
const parsed = JSON.parse(message);
|
|
1469
|
+
if (parsed && typeof parsed.type === "string") {
|
|
1470
|
+
messageType = parsed.type;
|
|
1471
|
+
}
|
|
1472
|
+
} catch (_) {
|
|
1473
|
+
messageType = null;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
for (const [clientId, ws] of clients) {
|
|
1477
|
+
if (!isAppClient(clientId)) {
|
|
1478
|
+
continue;
|
|
1479
|
+
}
|
|
1480
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
1483
|
+
sendMessageToClient(clientId, ws, message);
|
|
1484
|
+
if (
|
|
1485
|
+
messageType === "pages" ||
|
|
1486
|
+
messageType === "status" ||
|
|
1487
|
+
messageType === APP_PROTOCOL.pages ||
|
|
1488
|
+
messageType === APP_PROTOCOL.status ||
|
|
1489
|
+
messageType === APP_PROTOCOL.debugConfigSnapshot
|
|
1490
|
+
) {
|
|
1491
|
+
markClientSyncedForType(clientId, messageType);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* Send a message to a specific client.
|
|
1498
|
+
*
|
|
1499
|
+
* @param {string} clientId - Target client ID
|
|
1500
|
+
* @param {string} message - JSON string to send
|
|
1501
|
+
*/
|
|
1502
|
+
function unicast(clientId, message) {
|
|
1503
|
+
const ws = clients.get(clientId);
|
|
1504
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1505
|
+
sendMessageToClient(clientId, ws, message);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* Get all connected client IDs.
|
|
1511
|
+
*
|
|
1512
|
+
* @returns {string[]}
|
|
1513
|
+
*/
|
|
1514
|
+
function getClientIds() {
|
|
1515
|
+
return Array.from(clients.keys());
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function getConnectedAppCount(excludeClientId = null) {
|
|
1519
|
+
return countConnectedAppClients(excludeClientId);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Shut down the WebSocket server and disconnect all clients.
|
|
1524
|
+
*
|
|
1525
|
+
* @returns {Promise<void>}
|
|
1526
|
+
*/
|
|
1527
|
+
function close() {
|
|
1528
|
+
return new Promise((resolve) => {
|
|
1529
|
+
for (const [, sync] of syncState) {
|
|
1530
|
+
if (sync.timer) clearTimeout(sync.timer);
|
|
1531
|
+
}
|
|
1532
|
+
syncState.clear();
|
|
1533
|
+
for (const [clientId] of clientNudgeState) {
|
|
1534
|
+
clearClientNudgeTimers(clientId);
|
|
1535
|
+
}
|
|
1536
|
+
clientNudgeState.clear();
|
|
1537
|
+
for (const [, ws] of clients) {
|
|
1538
|
+
ws.close();
|
|
1539
|
+
}
|
|
1540
|
+
clients.clear();
|
|
1541
|
+
wss.close(() => resolve());
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
return {
|
|
1546
|
+
broadcast,
|
|
1547
|
+
unicast,
|
|
1548
|
+
getClientIds,
|
|
1549
|
+
getConnectedAppCount,
|
|
1550
|
+
closeConnectedAppClients,
|
|
1551
|
+
getClientNudgeState(clientId) {
|
|
1552
|
+
return cloneClientNudgeState(clientNudgeState.get(clientId) || null);
|
|
1553
|
+
},
|
|
1554
|
+
close,
|
|
1555
|
+
get httpServer() {
|
|
1556
|
+
return opts.httpServer || null;
|
|
1557
|
+
},
|
|
1558
|
+
/** Underlying WebSocket.Server (for reading address, etc.) */
|
|
1559
|
+
get wss() {
|
|
1560
|
+
return wss;
|
|
1561
|
+
},
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
export { createDownstreamServer };
|