@devicerail/client 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/LICENSE +201 -0
- package/README.md +79 -0
- package/dist/errors.d.ts +76 -0
- package/dist/errors.js +132 -0
- package/dist/event-stream.d.ts +66 -0
- package/dist/event-stream.js +1153 -0
- package/dist/generated/response-schemas.d.ts +6 -0
- package/dist/generated/response-schemas.js +7621 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/ndjson.d.ts +13 -0
- package/dist/ndjson.js +134 -0
- package/dist/rpc-client.d.ts +69 -0
- package/dist/rpc-client.js +928 -0
- package/dist/rpc-response-validator.d.ts +5 -0
- package/dist/rpc-response-validator.js +190 -0
- package/dist/stderr-tail.d.ts +10 -0
- package/dist/stderr-tail.js +42 -0
- package/dist/write-queue.d.ts +26 -0
- package/dist/write-queue.js +273 -0
- package/package.json +56 -0
|
@@ -0,0 +1,1153 @@
|
|
|
1
|
+
import { EventStreamAbortedError, EventStreamClosedError, EventStreamCursorError, EventStreamQueueOverflowError, EventStreamRemoteTerminationError, ProtocolViolationError, RequestAbortedError, RpcRemoteError, } from "./errors.js";
|
|
2
|
+
import { assertPureJsonValue, validateRpcResponse } from "./rpc-response-validator.js";
|
|
3
|
+
const EVENTS_STREAM_FEATURE = "events.stream.v1";
|
|
4
|
+
const MEDIA_PAYLOAD_TYPES = new Set([
|
|
5
|
+
"mediaStreamStarted",
|
|
6
|
+
"mediaFrameCaptured",
|
|
7
|
+
"mediaStreamEnded",
|
|
8
|
+
]);
|
|
9
|
+
const UI_SNAPSHOT_MEDIA_TYPE = "application/vnd.devicerail.ui-tree+json;version=1";
|
|
10
|
+
const HELLO_REQUEST_ID = "devicerail:event-stream:hello";
|
|
11
|
+
const SUBSCRIBE_REQUEST_ID = "devicerail:event-stream:subscribe";
|
|
12
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
13
|
+
export const DEFAULT_EVENT_STREAM_MAX_MESSAGE_BYTES = 1024 * 1024;
|
|
14
|
+
export const DEFAULT_EVENT_STREAM_MAX_QUEUED_BYTES = 4 * 1024 * 1024;
|
|
15
|
+
export const DEFAULT_EVENT_STREAM_MAX_QUEUED_EVENTS = 64;
|
|
16
|
+
function isObject(value) {
|
|
17
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
function assertExactKeys(value, required, optional, location) {
|
|
20
|
+
const allowed = new Set([...required, ...optional]);
|
|
21
|
+
for (const key of required) {
|
|
22
|
+
if (!Object.hasOwn(value, key)) {
|
|
23
|
+
throw new ProtocolViolationError(`${location} is missing ${key}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const key of Object.keys(value)) {
|
|
27
|
+
if (!allowed.has(key)) {
|
|
28
|
+
throw new ProtocolViolationError(`${location} contains unknown field ${key}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function assertRequiredKeys(value, required, location) {
|
|
33
|
+
for (const key of required) {
|
|
34
|
+
if (!Object.hasOwn(value, key)) {
|
|
35
|
+
throw new ProtocolViolationError(`${location} is missing ${key}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function positiveSafeInteger(value, name) {
|
|
40
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
41
|
+
throw new RangeError(`${name} must be a positive safe integer`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function safeUnsignedInteger(value, location, oneBased = false) {
|
|
46
|
+
if (typeof value !== "number" ||
|
|
47
|
+
!Number.isSafeInteger(value) ||
|
|
48
|
+
value < (oneBased ? 1 : 0)) {
|
|
49
|
+
throw new ProtocolViolationError(`${location} must be a ${oneBased ? "positive" : "non-negative"} safe integer`);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
function uuid(value, location) {
|
|
54
|
+
if (typeof value !== "string" || !UUID_PATTERN.test(value)) {
|
|
55
|
+
throw new ProtocolViolationError(`${location} must be a UUID`);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
function stringValue(value, location) {
|
|
60
|
+
if (typeof value !== "string") {
|
|
61
|
+
throw new ProtocolViolationError(`${location} must be a string`);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
function boundedString(value, location, maxLength) {
|
|
66
|
+
const result = stringValue(value, location);
|
|
67
|
+
if (result.length === 0 || [...result].length > maxLength) {
|
|
68
|
+
throw new ProtocolViolationError(`${location} must contain 1..${String(maxLength)} Unicode code points`);
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
function validateCursor(value, location) {
|
|
73
|
+
if (!isObject(value)) {
|
|
74
|
+
throw new ProtocolViolationError(`${location} must be an object`);
|
|
75
|
+
}
|
|
76
|
+
assertExactKeys(value, ["sequence", "sessionId", "streamEpoch"], [], location);
|
|
77
|
+
return {
|
|
78
|
+
sequence: safeUnsignedInteger(value.sequence, `${location}.sequence`, true),
|
|
79
|
+
sessionId: uuid(value.sessionId, `${location}.sessionId`),
|
|
80
|
+
streamEpoch: uuid(value.streamEpoch, `${location}.streamEpoch`),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function sameCursor(left, right) {
|
|
84
|
+
return (left.sequence === right.sequence &&
|
|
85
|
+
left.sessionId === right.sessionId &&
|
|
86
|
+
left.streamEpoch === right.streamEpoch);
|
|
87
|
+
}
|
|
88
|
+
function cloneCursor(cursor) {
|
|
89
|
+
return { ...cursor };
|
|
90
|
+
}
|
|
91
|
+
function validateErrorInfo(value, location) {
|
|
92
|
+
if (!isObject(value)) {
|
|
93
|
+
throw new ProtocolViolationError(`${location} must be an object`);
|
|
94
|
+
}
|
|
95
|
+
assertExactKeys(value, ["code", "message", "retryable"], ["details"], location);
|
|
96
|
+
if (typeof value.code !== "string" ||
|
|
97
|
+
typeof value.message !== "string" ||
|
|
98
|
+
typeof value.retryable !== "boolean") {
|
|
99
|
+
throw new ProtocolViolationError(`${location} has invalid code/message/retryable fields`);
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
function validateRpcError(value, location) {
|
|
104
|
+
if (!isObject(value)) {
|
|
105
|
+
throw new ProtocolViolationError(`${location} must be an object`);
|
|
106
|
+
}
|
|
107
|
+
assertExactKeys(value, ["code", "data", "message"], [], location);
|
|
108
|
+
if (typeof value.code !== "number" ||
|
|
109
|
+
!Number.isInteger(value.code) ||
|
|
110
|
+
value.code < -2_147_483_648 ||
|
|
111
|
+
value.code > 2_147_483_647 ||
|
|
112
|
+
typeof value.message !== "string") {
|
|
113
|
+
throw new ProtocolViolationError(`${location} has an invalid JSON-RPC code or message`);
|
|
114
|
+
}
|
|
115
|
+
validateErrorInfo(value.data, `${location}.data`);
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
function parseResponseResult(value, expectedId, location) {
|
|
119
|
+
if (!isObject(value) || value.jsonrpc !== "2.0") {
|
|
120
|
+
throw new ProtocolViolationError(`${location} must be a JSON-RPC 2.0 response`);
|
|
121
|
+
}
|
|
122
|
+
if (value.id !== expectedId) {
|
|
123
|
+
throw new ProtocolViolationError(`${location} has an unexpected response id`);
|
|
124
|
+
}
|
|
125
|
+
const hasResult = Object.hasOwn(value, "result");
|
|
126
|
+
const hasError = Object.hasOwn(value, "error");
|
|
127
|
+
if (hasResult === hasError) {
|
|
128
|
+
throw new ProtocolViolationError(`${location} must contain exactly one of result or error`);
|
|
129
|
+
}
|
|
130
|
+
if (hasError) {
|
|
131
|
+
assertExactKeys(value, ["error", "id", "jsonrpc"], [], location);
|
|
132
|
+
throw new RpcRemoteError(expectedId, validateRpcError(value.error, `${location}.error`));
|
|
133
|
+
}
|
|
134
|
+
assertExactKeys(value, ["id", "jsonrpc", "result"], [], location);
|
|
135
|
+
return value.result;
|
|
136
|
+
}
|
|
137
|
+
function validateOpenResult(value) {
|
|
138
|
+
if (!isObject(value)) {
|
|
139
|
+
throw new ProtocolViolationError("events.stream.open result must be an object");
|
|
140
|
+
}
|
|
141
|
+
assertExactKeys(value, ["endpoint", "expiresAtMs", "streamEpoch"], [], "events.stream.open result");
|
|
142
|
+
if (typeof value.endpoint !== "string" || value.endpoint.length === 0 || value.endpoint.length > 2_048) {
|
|
143
|
+
throw new ProtocolViolationError("events.stream.open result.endpoint must contain 1..2048 characters");
|
|
144
|
+
}
|
|
145
|
+
let endpoint;
|
|
146
|
+
try {
|
|
147
|
+
endpoint = new URL(value.endpoint);
|
|
148
|
+
}
|
|
149
|
+
catch (cause) {
|
|
150
|
+
throw new ProtocolViolationError("events.stream.open result.endpoint must be an absolute URL", {
|
|
151
|
+
cause,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (endpoint.protocol !== "ws:" ||
|
|
155
|
+
endpoint.hostname !== "127.0.0.1" ||
|
|
156
|
+
endpoint.port === "" ||
|
|
157
|
+
endpoint.username !== "" ||
|
|
158
|
+
endpoint.password !== "" ||
|
|
159
|
+
endpoint.hash !== "" ||
|
|
160
|
+
endpoint.search !== "" ||
|
|
161
|
+
endpoint.pathname === "/") {
|
|
162
|
+
throw new ProtocolViolationError("events.stream.open result.endpoint must be an uncredentialed loopback ws:// URL with a capability path");
|
|
163
|
+
}
|
|
164
|
+
const port = Number(endpoint.port);
|
|
165
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
166
|
+
throw new ProtocolViolationError("events.stream.open result.endpoint has an invalid port");
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
endpoint: value.endpoint,
|
|
170
|
+
expiresAtMs: safeUnsignedInteger(value.expiresAtMs, "events.stream.open result.expiresAtMs"),
|
|
171
|
+
streamEpoch: uuid(value.streamEpoch, "events.stream.open result.streamEpoch"),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function validateHelloResult(value, expectedProtocol) {
|
|
175
|
+
if (!isObject(value)) {
|
|
176
|
+
throw new ProtocolViolationError("WebSocket system.hello result must be an object");
|
|
177
|
+
}
|
|
178
|
+
assertExactKeys(value, ["connectionId", "features", "protocol", "server", "transport"], [], "WebSocket system.hello result");
|
|
179
|
+
uuid(value.connectionId, "WebSocket system.hello result.connectionId");
|
|
180
|
+
if (!isObject(value.features)) {
|
|
181
|
+
throw new ProtocolViolationError("WebSocket system.hello result.features must be an object");
|
|
182
|
+
}
|
|
183
|
+
assertExactKeys(value.features, ["enabled"], [], "WebSocket system.hello result.features");
|
|
184
|
+
if (!Array.isArray(value.features.enabled) ||
|
|
185
|
+
value.features.enabled.length !== 1 ||
|
|
186
|
+
value.features.enabled[0] !== EVENTS_STREAM_FEATURE) {
|
|
187
|
+
throw new ProtocolViolationError(`WebSocket system.hello must enable only ${EVENTS_STREAM_FEATURE}`);
|
|
188
|
+
}
|
|
189
|
+
if (!isObject(value.protocol) || !isObject(value.protocol.selected)) {
|
|
190
|
+
throw new ProtocolViolationError("WebSocket system.hello result.protocol is invalid");
|
|
191
|
+
}
|
|
192
|
+
assertExactKeys(value.protocol, ["selected"], [], "WebSocket system.hello result.protocol");
|
|
193
|
+
assertExactKeys(value.protocol.selected, ["major", "minor"], [], "WebSocket system.hello result.protocol.selected");
|
|
194
|
+
if (value.protocol.selected.major !== expectedProtocol.major
|
|
195
|
+
|| value.protocol.selected.minor !== expectedProtocol.minor) {
|
|
196
|
+
throw new ProtocolViolationError("WebSocket system.hello selected a protocol different from the control connection");
|
|
197
|
+
}
|
|
198
|
+
if (!isObject(value.server)) {
|
|
199
|
+
throw new ProtocolViolationError("WebSocket system.hello result.server must be an object");
|
|
200
|
+
}
|
|
201
|
+
assertExactKeys(value.server, ["name", "version"], [], "WebSocket system.hello result.server");
|
|
202
|
+
if (typeof value.server.name !== "string" || typeof value.server.version !== "string") {
|
|
203
|
+
throw new ProtocolViolationError("WebSocket system.hello server name/version must be strings");
|
|
204
|
+
}
|
|
205
|
+
if (!isObject(value.transport)) {
|
|
206
|
+
throw new ProtocolViolationError("WebSocket system.hello result.transport must be an object");
|
|
207
|
+
}
|
|
208
|
+
assertExactKeys(value.transport, ["framing", "kind"], [], "WebSocket system.hello result.transport");
|
|
209
|
+
if (value.transport.kind !== "webSocket" || value.transport.framing !== "jsonMessage") {
|
|
210
|
+
throw new ProtocolViolationError("WebSocket system.hello must negotiate webSocket/jsonMessage transport");
|
|
211
|
+
}
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
function validateSubscribeResult(value, sessionId, streamEpoch, afterCursor) {
|
|
215
|
+
if (!isObject(value)) {
|
|
216
|
+
throw new ProtocolViolationError("events.subscribe result must be an object");
|
|
217
|
+
}
|
|
218
|
+
assertExactKeys(value, ["replayThrough", "sessionId", "sessionState", "subscriptionId"], [], "events.subscribe result");
|
|
219
|
+
const resultSessionId = uuid(value.sessionId, "events.subscribe result.sessionId");
|
|
220
|
+
const subscriptionId = uuid(value.subscriptionId, "events.subscribe result.subscriptionId");
|
|
221
|
+
const replayThrough = validateCursor(value.replayThrough, "events.subscribe result.replayThrough");
|
|
222
|
+
if (value.sessionState !== "active" && value.sessionState !== "ended") {
|
|
223
|
+
throw new ProtocolViolationError("events.subscribe result.sessionState is invalid");
|
|
224
|
+
}
|
|
225
|
+
if (resultSessionId !== sessionId ||
|
|
226
|
+
replayThrough.sessionId !== sessionId ||
|
|
227
|
+
replayThrough.streamEpoch !== streamEpoch) {
|
|
228
|
+
throw new EventStreamCursorError("events.subscribe returned a cursor for another stream");
|
|
229
|
+
}
|
|
230
|
+
if (afterCursor && replayThrough.sequence < afterCursor.sequence) {
|
|
231
|
+
throw new EventStreamCursorError("events.subscribe replay boundary precedes afterCursor");
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
replayThrough,
|
|
235
|
+
sessionId: resultSessionId,
|
|
236
|
+
sessionState: value.sessionState,
|
|
237
|
+
subscriptionId,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function validateAsset(value, location) {
|
|
241
|
+
if (!isObject(value)) {
|
|
242
|
+
throw new ProtocolViolationError(`${location} must be an object`);
|
|
243
|
+
}
|
|
244
|
+
assertRequiredKeys(value, ["id", "mediaType", "uri"], location);
|
|
245
|
+
for (const key of ["id", "mediaType", "uri"]) {
|
|
246
|
+
stringValue(value[key], `${location}.${key}`);
|
|
247
|
+
}
|
|
248
|
+
if (value.sha256 !== undefined && value.sha256 !== null && typeof value.sha256 !== "string") {
|
|
249
|
+
throw new ProtocolViolationError(`${location}.sha256 must be a string or null`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function validateMediaViewport(value, location) {
|
|
253
|
+
if (!isObject(value)) {
|
|
254
|
+
throw new ProtocolViolationError(`${location} must be an object`);
|
|
255
|
+
}
|
|
256
|
+
assertExactKeys(value, ["height", "scaleFactor", "width"], [], location);
|
|
257
|
+
for (const key of ["width", "height"]) {
|
|
258
|
+
const dimension = safeUnsignedInteger(value[key], `${location}.${key}`, true);
|
|
259
|
+
if (dimension > 4_294_967_295) {
|
|
260
|
+
throw new ProtocolViolationError(`${location}.${key} exceeds u32`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (typeof value.scaleFactor !== "number" || !Number.isFinite(value.scaleFactor) || value.scaleFactor <= 0) {
|
|
264
|
+
throw new ProtocolViolationError(`${location}.scaleFactor must be positive and finite`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function validateUiContext(value, location) {
|
|
268
|
+
if (!isObject(value)) {
|
|
269
|
+
throw new ProtocolViolationError(`${location} must be an object`);
|
|
270
|
+
}
|
|
271
|
+
assertExactKeys(value, ["contextKind", "contextId", "documentEpoch"], [], location);
|
|
272
|
+
if (value.contextKind !== "native" && value.contextKind !== "web") {
|
|
273
|
+
throw new ProtocolViolationError(`${location}.contextKind is invalid`);
|
|
274
|
+
}
|
|
275
|
+
boundedString(value.contextId, `${location}.contextId`, 4_096);
|
|
276
|
+
boundedString(value.documentEpoch, `${location}.documentEpoch`, 4_096);
|
|
277
|
+
return value.contextKind;
|
|
278
|
+
}
|
|
279
|
+
function validateUiSnapshotRef(value, location) {
|
|
280
|
+
if (!isObject(value)) {
|
|
281
|
+
throw new ProtocolViolationError(`${location} must be an object`);
|
|
282
|
+
}
|
|
283
|
+
assertExactKeys(value, ["formatVersion", "context", "nodeCount", "byteLength", "evidence"], [], location);
|
|
284
|
+
if (value.formatVersion !== 1) {
|
|
285
|
+
throw new ProtocolViolationError(`${location}.formatVersion must be 1`);
|
|
286
|
+
}
|
|
287
|
+
validateUiContext(value.context, `${location}.context`);
|
|
288
|
+
const nodeCount = safeUnsignedInteger(value.nodeCount, `${location}.nodeCount`, true);
|
|
289
|
+
if (nodeCount > 10_000) {
|
|
290
|
+
throw new ProtocolViolationError(`${location}.nodeCount exceeds 10000`);
|
|
291
|
+
}
|
|
292
|
+
const byteLength = safeUnsignedInteger(value.byteLength, `${location}.byteLength`, true);
|
|
293
|
+
if (byteLength > 786_432) {
|
|
294
|
+
throw new ProtocolViolationError(`${location}.byteLength exceeds 786432`);
|
|
295
|
+
}
|
|
296
|
+
validateAsset(value.evidence, `${location}.evidence`);
|
|
297
|
+
if (value.evidence.mediaType !== UI_SNAPSHOT_MEDIA_TYPE) {
|
|
298
|
+
throw new ProtocolViolationError(`${location}.evidence.mediaType is not a v1 UI Tree`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function validateActionExecution(value, location) {
|
|
302
|
+
if (!isObject(value) || typeof value.mode !== "string") {
|
|
303
|
+
throw new ProtocolViolationError(`${location} must be a tagged object`);
|
|
304
|
+
}
|
|
305
|
+
switch (value.mode) {
|
|
306
|
+
case "nativeSemantic":
|
|
307
|
+
case "webSemantic": {
|
|
308
|
+
assertExactKeys(value, ["mode", "context"], [], location);
|
|
309
|
+
const kind = validateUiContext(value.context, `${location}.context`);
|
|
310
|
+
const expected = value.mode === "nativeSemantic" ? "native" : "web";
|
|
311
|
+
if (kind !== expected) {
|
|
312
|
+
throw new ProtocolViolationError(`${location}.context.contextKind does not match mode`);
|
|
313
|
+
}
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
case "coordinateFallback":
|
|
317
|
+
assertExactKeys(value, ["mode", "context", "fallbackReason"], [], location);
|
|
318
|
+
validateUiContext(value.context, `${location}.context`);
|
|
319
|
+
if (value.fallbackReason !== "semanticInteractionUnavailable" &&
|
|
320
|
+
value.fallbackReason !== "platformLimitation") {
|
|
321
|
+
throw new ProtocolViolationError(`${location}.fallbackReason is invalid`);
|
|
322
|
+
}
|
|
323
|
+
break;
|
|
324
|
+
default:
|
|
325
|
+
throw new ProtocolViolationError(`${location}.mode is invalid`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function validateObservation(value, location) {
|
|
329
|
+
if (!isObject(value) || !isObject(value.viewport)) {
|
|
330
|
+
throw new ProtocolViolationError(`${location} and its viewport must be objects`);
|
|
331
|
+
}
|
|
332
|
+
assertRequiredKeys(value, ["capturedAtMs", "deviceId", "id", "viewport"], location);
|
|
333
|
+
assertRequiredKeys(value.viewport, ["height", "scaleFactor", "width"], `${location}.viewport`);
|
|
334
|
+
uuid(value.id, `${location}.id`);
|
|
335
|
+
stringValue(value.deviceId, `${location}.deviceId`);
|
|
336
|
+
safeUnsignedInteger(value.capturedAtMs, `${location}.capturedAtMs`);
|
|
337
|
+
for (const key of ["width", "height"]) {
|
|
338
|
+
const dimension = safeUnsignedInteger(value.viewport[key], `${location}.viewport.${key}`);
|
|
339
|
+
if (dimension > 4_294_967_295) {
|
|
340
|
+
throw new ProtocolViolationError(`${location}.viewport.${key} exceeds u32`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (typeof value.viewport.scaleFactor !== "number" || !Number.isFinite(value.viewport.scaleFactor)) {
|
|
344
|
+
throw new ProtocolViolationError(`${location}.viewport.scaleFactor must be finite`);
|
|
345
|
+
}
|
|
346
|
+
if (value.screenshot !== undefined && value.screenshot !== null) {
|
|
347
|
+
validateAsset(value.screenshot, `${location}.screenshot`);
|
|
348
|
+
}
|
|
349
|
+
if (value.screenshotOmission !== undefined &&
|
|
350
|
+
value.screenshotOmission !== null &&
|
|
351
|
+
value.screenshotOmission !== "policy" &&
|
|
352
|
+
value.screenshotOmission !== "protectedAction") {
|
|
353
|
+
throw new ProtocolViolationError(`${location}.screenshotOmission is invalid`);
|
|
354
|
+
}
|
|
355
|
+
if (value.screenshot !== undefined &&
|
|
356
|
+
value.screenshot !== null &&
|
|
357
|
+
value.screenshotOmission !== undefined) {
|
|
358
|
+
throw new ProtocolViolationError(`${location} has both screenshot and screenshotOmission`);
|
|
359
|
+
}
|
|
360
|
+
if (value.uiSnapshot !== undefined) {
|
|
361
|
+
validateUiSnapshotRef(value.uiSnapshot, `${location}.uiSnapshot`);
|
|
362
|
+
}
|
|
363
|
+
if (value.uiSnapshotOmission !== undefined &&
|
|
364
|
+
value.uiSnapshotOmission !== "driverUnsupported" &&
|
|
365
|
+
value.uiSnapshotOmission !== "policy" &&
|
|
366
|
+
value.uiSnapshotOmission !== "protectedAction") {
|
|
367
|
+
throw new ProtocolViolationError(`${location}.uiSnapshotOmission is invalid`);
|
|
368
|
+
}
|
|
369
|
+
if (value.uiSnapshot !== undefined && value.uiSnapshotOmission !== undefined) {
|
|
370
|
+
throw new ProtocolViolationError(`${location} has both uiSnapshot and uiSnapshotOmission`);
|
|
371
|
+
}
|
|
372
|
+
if (value.metadata !== undefined && !isObject(value.metadata)) {
|
|
373
|
+
throw new ProtocolViolationError(`${location}.metadata must be an object`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function validateActionResult(value, location) {
|
|
377
|
+
if (!isObject(value)) {
|
|
378
|
+
throw new ProtocolViolationError(`${location} must be an object`);
|
|
379
|
+
}
|
|
380
|
+
assertRequiredKeys(value, ["callId", "finishedAtMs", "output", "startedAtMs"], location);
|
|
381
|
+
uuid(value.callId, `${location}.callId`);
|
|
382
|
+
safeUnsignedInteger(value.startedAtMs, `${location}.startedAtMs`);
|
|
383
|
+
safeUnsignedInteger(value.finishedAtMs, `${location}.finishedAtMs`);
|
|
384
|
+
if (!Object.hasOwn(value, "output")) {
|
|
385
|
+
throw new ProtocolViolationError(`${location} is missing output`);
|
|
386
|
+
}
|
|
387
|
+
for (const key of ["before", "after"]) {
|
|
388
|
+
if (value[key] !== undefined && value[key] !== null) {
|
|
389
|
+
validateObservation(value[key], `${location}.${key}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (value.evidence !== undefined) {
|
|
393
|
+
if (!Array.isArray(value.evidence)) {
|
|
394
|
+
throw new ProtocolViolationError(`${location}.evidence must be an array`);
|
|
395
|
+
}
|
|
396
|
+
value.evidence.forEach((asset, index) => validateAsset(asset, `${location}.evidence[${index}]`));
|
|
397
|
+
}
|
|
398
|
+
if (value.execution !== undefined) {
|
|
399
|
+
validateActionExecution(value.execution, `${location}.execution`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function validateActionOutcome(value, location) {
|
|
403
|
+
if (!isObject(value) || typeof value.outcome !== "string") {
|
|
404
|
+
throw new ProtocolViolationError(`${location} must be a tagged object`);
|
|
405
|
+
}
|
|
406
|
+
switch (value.outcome) {
|
|
407
|
+
case "succeeded":
|
|
408
|
+
assertExactKeys(value, ["outcome", "result"], [], location);
|
|
409
|
+
validateActionResult(value.result, `${location}.result`);
|
|
410
|
+
break;
|
|
411
|
+
case "failed":
|
|
412
|
+
case "cancelled":
|
|
413
|
+
assertExactKeys(value, ["error", "outcome"], [], location);
|
|
414
|
+
validateErrorInfo(value.error, `${location}.error`);
|
|
415
|
+
break;
|
|
416
|
+
case "timedOut":
|
|
417
|
+
assertExactKeys(value, ["error", "outcome", "timeoutMs"], [], location);
|
|
418
|
+
validateErrorInfo(value.error, `${location}.error`);
|
|
419
|
+
safeUnsignedInteger(value.timeoutMs, `${location}.timeoutMs`);
|
|
420
|
+
break;
|
|
421
|
+
default:
|
|
422
|
+
throw new ProtocolViolationError(`${location}.outcome is invalid`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function validatePayload(value, location) {
|
|
426
|
+
if (!isObject(value) || typeof value.type !== "string") {
|
|
427
|
+
throw new ProtocolViolationError(`${location} must be a tagged object`);
|
|
428
|
+
}
|
|
429
|
+
switch (value.type) {
|
|
430
|
+
case "sessionStarted":
|
|
431
|
+
assertExactKeys(value, ["type"], [], location);
|
|
432
|
+
break;
|
|
433
|
+
case "sessionEnded":
|
|
434
|
+
assertExactKeys(value, ["outcome", "type"], ["reason"], location);
|
|
435
|
+
if (!new Set(["completed", "failed", "cancelled", "shutdown"]).has(String(value.outcome))) {
|
|
436
|
+
throw new ProtocolViolationError(`${location}.outcome is invalid`);
|
|
437
|
+
}
|
|
438
|
+
if (value.reason !== undefined && value.reason !== null && typeof value.reason !== "string") {
|
|
439
|
+
throw new ProtocolViolationError(`${location}.reason must be a string or null`);
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
case "observationCaptured":
|
|
443
|
+
assertExactKeys(value, ["observation", "type"], [], location);
|
|
444
|
+
validateObservation(value.observation, `${location}.observation`);
|
|
445
|
+
break;
|
|
446
|
+
case "actionStarted":
|
|
447
|
+
assertExactKeys(value, ["call", "type"], [], location);
|
|
448
|
+
if (!isObject(value.call)) {
|
|
449
|
+
throw new ProtocolViolationError(`${location}.call must be an object`);
|
|
450
|
+
}
|
|
451
|
+
assertRequiredKeys(value.call, ["id", "name"], `${location}.call`);
|
|
452
|
+
uuid(value.call.id, `${location}.call.id`);
|
|
453
|
+
stringValue(value.call.name, `${location}.call.name`);
|
|
454
|
+
if (value.call.argumentsRedacted !== undefined &&
|
|
455
|
+
typeof value.call.argumentsRedacted !== "boolean") {
|
|
456
|
+
throw new ProtocolViolationError(`${location}.call.argumentsRedacted must be boolean`);
|
|
457
|
+
}
|
|
458
|
+
break;
|
|
459
|
+
case "actionCompleted":
|
|
460
|
+
assertExactKeys(value, ["callId", "outcome", "type"], [], location);
|
|
461
|
+
uuid(value.callId, `${location}.callId`);
|
|
462
|
+
validateActionOutcome(value.outcome, `${location}.outcome`);
|
|
463
|
+
break;
|
|
464
|
+
case "mediaStreamStarted": {
|
|
465
|
+
assertExactKeys(value, ["stream", "type"], [], location);
|
|
466
|
+
if (!isObject(value.stream)) {
|
|
467
|
+
throw new ProtocolViolationError(`${location}.stream must be an object`);
|
|
468
|
+
}
|
|
469
|
+
assertExactKeys(value.stream, ["id", "kind", "mediaType"], ["viewport"], `${location}.stream`);
|
|
470
|
+
uuid(value.stream.id, `${location}.stream.id`);
|
|
471
|
+
if (value.stream.kind !== "screenshot" && value.stream.kind !== "video") {
|
|
472
|
+
throw new ProtocolViolationError(`${location}.stream.kind is invalid`);
|
|
473
|
+
}
|
|
474
|
+
stringValue(value.stream.mediaType, `${location}.stream.mediaType`);
|
|
475
|
+
if (value.stream.viewport !== undefined) {
|
|
476
|
+
validateMediaViewport(value.stream.viewport, `${location}.stream.viewport`);
|
|
477
|
+
}
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
case "mediaFrameCaptured": {
|
|
481
|
+
assertExactKeys(value, ["frame", "type"], [], location);
|
|
482
|
+
if (!isObject(value.frame)) {
|
|
483
|
+
throw new ProtocolViolationError(`${location}.frame must be an object`);
|
|
484
|
+
}
|
|
485
|
+
assertExactKeys(value.frame, ["streamId", "frameIndex", "evidence"], ["keyFrame", "durationMs"], `${location}.frame`);
|
|
486
|
+
uuid(value.frame.streamId, `${location}.frame.streamId`);
|
|
487
|
+
safeUnsignedInteger(value.frame.frameIndex, `${location}.frame.frameIndex`, true);
|
|
488
|
+
if (value.frame.keyFrame !== undefined && typeof value.frame.keyFrame !== "boolean") {
|
|
489
|
+
throw new ProtocolViolationError(`${location}.frame.keyFrame must be boolean`);
|
|
490
|
+
}
|
|
491
|
+
if (value.frame.durationMs !== undefined) {
|
|
492
|
+
safeUnsignedInteger(value.frame.durationMs, `${location}.frame.durationMs`);
|
|
493
|
+
}
|
|
494
|
+
validateAsset(value.frame.evidence, `${location}.frame.evidence`);
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
case "mediaStreamEnded":
|
|
498
|
+
assertExactKeys(value, ["frameCount", "streamId", "type"], [], location);
|
|
499
|
+
uuid(value.streamId, `${location}.streamId`);
|
|
500
|
+
safeUnsignedInteger(value.frameCount, `${location}.frameCount`);
|
|
501
|
+
break;
|
|
502
|
+
case "verdictRecorded":
|
|
503
|
+
assertExactKeys(value, ["type", "verdict"], [], location);
|
|
504
|
+
if (!isObject(value.verdict)) {
|
|
505
|
+
throw new ProtocolViolationError(`${location}.verdict must be an object`);
|
|
506
|
+
}
|
|
507
|
+
assertExactKeys(value.verdict, ["status", "summary"], ["evidence"], `${location}.verdict`);
|
|
508
|
+
if (!new Set(["pass", "fail", "unknown"]).has(String(value.verdict.status))) {
|
|
509
|
+
throw new ProtocolViolationError(`${location}.verdict.status is invalid`);
|
|
510
|
+
}
|
|
511
|
+
if (typeof value.verdict.summary !== "string") {
|
|
512
|
+
throw new ProtocolViolationError(`${location}.verdict.summary must be a string`);
|
|
513
|
+
}
|
|
514
|
+
if (value.verdict.evidence !== undefined) {
|
|
515
|
+
if (!Array.isArray(value.verdict.evidence)) {
|
|
516
|
+
throw new ProtocolViolationError(`${location}.verdict.evidence must be an array`);
|
|
517
|
+
}
|
|
518
|
+
value.verdict.evidence.forEach((asset, index) => validateAsset(asset, `${location}.verdict.evidence[${index}]`));
|
|
519
|
+
}
|
|
520
|
+
break;
|
|
521
|
+
case "error":
|
|
522
|
+
assertExactKeys(value, ["error", "type"], [], location);
|
|
523
|
+
validateErrorInfo(value.error, `${location}.error`);
|
|
524
|
+
break;
|
|
525
|
+
default:
|
|
526
|
+
throw new ProtocolViolationError(`${location}.type is unknown`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function observationUsesProtocol15(value) {
|
|
530
|
+
return isObject(value) &&
|
|
531
|
+
(Object.hasOwn(value, "uiSnapshot") || Object.hasOwn(value, "uiSnapshotOmission"));
|
|
532
|
+
}
|
|
533
|
+
function payloadUsesProtocol15(value) {
|
|
534
|
+
if (!isObject(value)) {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
if (value.type === "observationCaptured") {
|
|
538
|
+
return observationUsesProtocol15(value.observation);
|
|
539
|
+
}
|
|
540
|
+
if (value.type !== "actionCompleted" || !isObject(value.outcome)) {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
if (value.outcome.outcome !== "succeeded" || !isObject(value.outcome.result)) {
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
const result = value.outcome.result;
|
|
547
|
+
return Object.hasOwn(result, "execution") ||
|
|
548
|
+
observationUsesProtocol15(result.before) ||
|
|
549
|
+
observationUsesProtocol15(result.after);
|
|
550
|
+
}
|
|
551
|
+
function validateTestEvent(value, location, selectedProtocol) {
|
|
552
|
+
if (!isObject(value)) {
|
|
553
|
+
throw new ProtocolViolationError(`${location} must be an object`);
|
|
554
|
+
}
|
|
555
|
+
assertExactKeys(value, ["atMs", "eventId", "payload", "sequence", "sessionId"], ["deviceId", "requestId"], location);
|
|
556
|
+
uuid(value.eventId, `${location}.eventId`);
|
|
557
|
+
uuid(value.sessionId, `${location}.sessionId`);
|
|
558
|
+
safeUnsignedInteger(value.atMs, `${location}.atMs`);
|
|
559
|
+
safeUnsignedInteger(value.sequence, `${location}.sequence`, true);
|
|
560
|
+
if (value.deviceId !== undefined && value.deviceId !== null && typeof value.deviceId !== "string") {
|
|
561
|
+
throw new ProtocolViolationError(`${location}.deviceId must be a string or null`);
|
|
562
|
+
}
|
|
563
|
+
if (value.requestId !== undefined &&
|
|
564
|
+
value.requestId !== null &&
|
|
565
|
+
typeof value.requestId !== "string" &&
|
|
566
|
+
!(typeof value.requestId === "number" &&
|
|
567
|
+
Number.isSafeInteger(value.requestId) &&
|
|
568
|
+
value.requestId >= 0)) {
|
|
569
|
+
throw new ProtocolViolationError(`${location}.requestId is invalid`);
|
|
570
|
+
}
|
|
571
|
+
validatePayload(value.payload, `${location}.payload`);
|
|
572
|
+
if (selectedProtocol.minor < 4
|
|
573
|
+
&& isObject(value.payload)
|
|
574
|
+
&& MEDIA_PAYLOAD_TYPES.has(String(value.payload.type))) {
|
|
575
|
+
throw new ProtocolViolationError(`${location}.payload requires protocol 1.4 but the stream selected ${selectedProtocol.major}.${selectedProtocol.minor}`);
|
|
576
|
+
}
|
|
577
|
+
if (selectedProtocol.minor < 5 && payloadUsesProtocol15(value.payload)) {
|
|
578
|
+
throw new ProtocolViolationError(`${location}.payload requires protocol 1.5 but the stream selected ${selectedProtocol.major}.${selectedProtocol.minor}`);
|
|
579
|
+
}
|
|
580
|
+
return value;
|
|
581
|
+
}
|
|
582
|
+
function validateEventNotification(value, bytes, sessionId, streamEpoch, subscriptionId, selectedProtocol) {
|
|
583
|
+
if (!isObject(value)) {
|
|
584
|
+
throw new ProtocolViolationError("events.stream.event notification must be an object");
|
|
585
|
+
}
|
|
586
|
+
assertExactKeys(value, ["jsonrpc", "method", "params"], [], "events.stream.event notification");
|
|
587
|
+
if (value.jsonrpc !== "2.0" || value.method !== "events.stream.event" || !isObject(value.params)) {
|
|
588
|
+
throw new ProtocolViolationError("events.stream.event notification envelope is invalid");
|
|
589
|
+
}
|
|
590
|
+
assertExactKeys(value.params, ["cursor", "event", "subscriptionId"], [], "events.stream.event params");
|
|
591
|
+
if (value.params.subscriptionId !== subscriptionId) {
|
|
592
|
+
throw new ProtocolViolationError("events.stream.event has an unexpected subscriptionId");
|
|
593
|
+
}
|
|
594
|
+
const cursor = validateCursor(value.params.cursor, "events.stream.event params.cursor");
|
|
595
|
+
const event = validateTestEvent(value.params.event, "events.stream.event params.event", selectedProtocol);
|
|
596
|
+
if (cursor.sessionId !== sessionId ||
|
|
597
|
+
cursor.streamEpoch !== streamEpoch ||
|
|
598
|
+
event.sessionId !== sessionId ||
|
|
599
|
+
event.sequence !== cursor.sequence) {
|
|
600
|
+
throw new EventStreamCursorError("events.stream.event cursor and event identity do not match");
|
|
601
|
+
}
|
|
602
|
+
return { bytes, cursor, event };
|
|
603
|
+
}
|
|
604
|
+
function validateTermination(value, location) {
|
|
605
|
+
if (!isObject(value) || typeof value.reason !== "string") {
|
|
606
|
+
throw new ProtocolViolationError(`${location} must be a tagged object`);
|
|
607
|
+
}
|
|
608
|
+
if (value.reason === "sessionEnded" || value.reason === "cancelled") {
|
|
609
|
+
assertExactKeys(value, ["reason"], [], location);
|
|
610
|
+
}
|
|
611
|
+
else if (new Set([
|
|
612
|
+
"slowConsumer",
|
|
613
|
+
"sessionDeleted",
|
|
614
|
+
"serverShutdown",
|
|
615
|
+
"sequenceGap",
|
|
616
|
+
"eventTooLarge",
|
|
617
|
+
"internalError",
|
|
618
|
+
]).has(value.reason)) {
|
|
619
|
+
assertExactKeys(value, ["error", "reason"], [], location);
|
|
620
|
+
validateErrorInfo(value.error, `${location}.error`);
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
throw new ProtocolViolationError(`${location}.reason is unknown`);
|
|
624
|
+
}
|
|
625
|
+
return value;
|
|
626
|
+
}
|
|
627
|
+
function validateTerminalNotification(value, sessionId, streamEpoch, subscriptionId) {
|
|
628
|
+
if (!isObject(value)) {
|
|
629
|
+
throw new ProtocolViolationError("events.stream.terminal notification must be an object");
|
|
630
|
+
}
|
|
631
|
+
assertExactKeys(value, ["jsonrpc", "method", "params"], [], "events.stream.terminal notification");
|
|
632
|
+
if (value.jsonrpc !== "2.0" ||
|
|
633
|
+
value.method !== "events.stream.terminal" ||
|
|
634
|
+
!isObject(value.params)) {
|
|
635
|
+
throw new ProtocolViolationError("events.stream.terminal notification envelope is invalid");
|
|
636
|
+
}
|
|
637
|
+
assertExactKeys(value.params, ["sessionId", "subscriptionId", "termination"], ["lastEmittedCursor"], "events.stream.terminal params");
|
|
638
|
+
if (value.params.sessionId !== sessionId || value.params.subscriptionId !== subscriptionId) {
|
|
639
|
+
throw new ProtocolViolationError("events.stream.terminal identifies another subscription");
|
|
640
|
+
}
|
|
641
|
+
const lastEmittedCursor = value.params.lastEmittedCursor === undefined || value.params.lastEmittedCursor === null
|
|
642
|
+
? undefined
|
|
643
|
+
: validateCursor(value.params.lastEmittedCursor, "events.stream.terminal params.lastEmittedCursor");
|
|
644
|
+
if (lastEmittedCursor &&
|
|
645
|
+
(lastEmittedCursor.sessionId !== sessionId || lastEmittedCursor.streamEpoch !== streamEpoch)) {
|
|
646
|
+
throw new EventStreamCursorError("terminal lastEmittedCursor belongs to another stream");
|
|
647
|
+
}
|
|
648
|
+
const result = {
|
|
649
|
+
sessionId,
|
|
650
|
+
subscriptionId,
|
|
651
|
+
termination: validateTermination(value.params.termination, "events.stream.terminal params.termination"),
|
|
652
|
+
...(lastEmittedCursor ? { lastEmittedCursor } : {}),
|
|
653
|
+
};
|
|
654
|
+
return result;
|
|
655
|
+
}
|
|
656
|
+
function parseJsonMessage(data, maxMessageBytes) {
|
|
657
|
+
if (typeof data !== "string") {
|
|
658
|
+
throw new ProtocolViolationError("event stream WebSocket messages must be UTF-8 text");
|
|
659
|
+
}
|
|
660
|
+
const bytes = Buffer.byteLength(data, "utf8");
|
|
661
|
+
if (bytes > maxMessageBytes) {
|
|
662
|
+
throw new ProtocolViolationError(`event stream WebSocket message is ${bytes} bytes; the limit is ${maxMessageBytes}`);
|
|
663
|
+
}
|
|
664
|
+
let value;
|
|
665
|
+
try {
|
|
666
|
+
value = JSON.parse(data);
|
|
667
|
+
}
|
|
668
|
+
catch (cause) {
|
|
669
|
+
throw new ProtocolViolationError("event stream WebSocket message is not valid JSON", { cause });
|
|
670
|
+
}
|
|
671
|
+
assertPureJsonValue(value);
|
|
672
|
+
return { bytes, value };
|
|
673
|
+
}
|
|
674
|
+
function nativeWebSocketFactory(endpoint, subprotocol) {
|
|
675
|
+
return new WebSocket(endpoint, subprotocol);
|
|
676
|
+
}
|
|
677
|
+
function validateOriginPolicy(value) {
|
|
678
|
+
if (value === undefined) {
|
|
679
|
+
return { kind: "absent" };
|
|
680
|
+
}
|
|
681
|
+
if (!isObject(value) || (value.kind !== "absent" && value.kind !== "exact")) {
|
|
682
|
+
throw new ProtocolViolationError("event stream originPolicy is invalid");
|
|
683
|
+
}
|
|
684
|
+
if (value.kind === "absent") {
|
|
685
|
+
assertExactKeys(value, ["kind"], [], "event stream originPolicy");
|
|
686
|
+
return { kind: "absent" };
|
|
687
|
+
}
|
|
688
|
+
assertExactKeys(value, ["kind", "origin"], [], "event stream originPolicy");
|
|
689
|
+
if (typeof value.origin !== "string" || value.origin.length === 0) {
|
|
690
|
+
throw new ProtocolViolationError("event stream exact originPolicy requires an origin");
|
|
691
|
+
}
|
|
692
|
+
const prefix = value.origin.startsWith("http://127.0.0.1:")
|
|
693
|
+
? "http://127.0.0.1:"
|
|
694
|
+
: value.origin.startsWith("https://127.0.0.1:")
|
|
695
|
+
? "https://127.0.0.1:"
|
|
696
|
+
: undefined;
|
|
697
|
+
const port = prefix ? value.origin.slice(prefix.length) : "";
|
|
698
|
+
if (value.origin.length > 256 ||
|
|
699
|
+
!/^[\u0000-\u007f]*$/u.test(value.origin) ||
|
|
700
|
+
port.length === 0 ||
|
|
701
|
+
(port.length > 1 && port.startsWith("0")) ||
|
|
702
|
+
!/^[0-9]+$/u.test(port) ||
|
|
703
|
+
Number(port) < 1 ||
|
|
704
|
+
Number(port) > 65_535 ||
|
|
705
|
+
(prefix === "http://127.0.0.1:" && port === "80") ||
|
|
706
|
+
(prefix === "https://127.0.0.1:" && port === "443")) {
|
|
707
|
+
throw new ProtocolViolationError("event stream exact originPolicy must contain only an http(s) origin");
|
|
708
|
+
}
|
|
709
|
+
return { kind: "exact", origin: value.origin };
|
|
710
|
+
}
|
|
711
|
+
function normalizeSettings(options) {
|
|
712
|
+
return {
|
|
713
|
+
maxMessageBytes: positiveSafeInteger(options.maxMessageBytes ?? DEFAULT_EVENT_STREAM_MAX_MESSAGE_BYTES, "maxMessageBytes"),
|
|
714
|
+
maxQueuedBytes: positiveSafeInteger(options.maxQueuedBytes ?? DEFAULT_EVENT_STREAM_MAX_QUEUED_BYTES, "maxQueuedBytes"),
|
|
715
|
+
maxQueuedEvents: positiveSafeInteger(options.maxQueuedEvents ?? DEFAULT_EVENT_STREAM_MAX_QUEUED_EVENTS, "maxQueuedEvents"),
|
|
716
|
+
originPolicy: validateOriginPolicy(options.originPolicy),
|
|
717
|
+
webSocketFactory: options.webSocketFactory ?? nativeWebSocketFactory,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
function validateEventProtocolVersion(value) {
|
|
721
|
+
if (!isObject(value)
|
|
722
|
+
|| value.major !== 1
|
|
723
|
+
|| (value.minor !== 3 && value.minor !== 4 && value.minor !== 5)
|
|
724
|
+
|| !Object.hasOwn(value, "major")
|
|
725
|
+
|| !Object.hasOwn(value, "minor")
|
|
726
|
+
|| Object.keys(value).length !== 2) {
|
|
727
|
+
throw new ProtocolViolationError("event stream requires selected protocol 1.3, 1.4, or 1.5");
|
|
728
|
+
}
|
|
729
|
+
return { major: value.major, minor: value.minor };
|
|
730
|
+
}
|
|
731
|
+
async function openCapabilityWithAbort(capabilityOpener, sessionId, originPolicy, signal) {
|
|
732
|
+
const pending = capabilityOpener(sessionId, originPolicy, signal);
|
|
733
|
+
if (!signal) {
|
|
734
|
+
return await pending;
|
|
735
|
+
}
|
|
736
|
+
return await new Promise((resolve, reject) => {
|
|
737
|
+
let settled = false;
|
|
738
|
+
const finish = (operation) => {
|
|
739
|
+
if (settled) {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
settled = true;
|
|
743
|
+
signal.removeEventListener("abort", abort);
|
|
744
|
+
operation();
|
|
745
|
+
};
|
|
746
|
+
const abort = () => finish(() => reject(new EventStreamAbortedError()));
|
|
747
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
748
|
+
if (signal.aborted) {
|
|
749
|
+
abort();
|
|
750
|
+
}
|
|
751
|
+
void pending.then((result) => finish(() => resolve(result)), (error) => finish(() => reject(error instanceof Error
|
|
752
|
+
? error
|
|
753
|
+
: new EventStreamClosedError(1006, "capability request failed"))));
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* A single resumable Session stream. Socket receipt, iterator delivery, and
|
|
758
|
+
* application confirmation are deliberately separate cursor boundaries.
|
|
759
|
+
*/
|
|
760
|
+
export class DeviceRailEventStream {
|
|
761
|
+
#afterCursor;
|
|
762
|
+
#capabilityOpener;
|
|
763
|
+
#clientPeer;
|
|
764
|
+
#eventProtocol;
|
|
765
|
+
#ready;
|
|
766
|
+
#sessionId;
|
|
767
|
+
#signal;
|
|
768
|
+
#settings;
|
|
769
|
+
#socket;
|
|
770
|
+
#streamEpoch;
|
|
771
|
+
#abortListener;
|
|
772
|
+
#confirmedCursor;
|
|
773
|
+
#deliveredSequence;
|
|
774
|
+
#failure;
|
|
775
|
+
#iteratorClosed = false;
|
|
776
|
+
#pendingNext;
|
|
777
|
+
#phase = "connecting";
|
|
778
|
+
#queue = [];
|
|
779
|
+
#queuedBytes = 0;
|
|
780
|
+
#receivedCursor;
|
|
781
|
+
#receivedPayloadType;
|
|
782
|
+
#readyReject;
|
|
783
|
+
#readyResolve;
|
|
784
|
+
#subscription;
|
|
785
|
+
#terminal;
|
|
786
|
+
#onOpen = () => {
|
|
787
|
+
if (this.#phase !== "connecting") {
|
|
788
|
+
this.#fail(new ProtocolViolationError("event stream WebSocket opened more than once"));
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (this.#socket.protocol !== "devicerail.events.v1") {
|
|
792
|
+
this.#fail(new ProtocolViolationError("event stream WebSocket did not negotiate devicerail.events.v1"));
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
this.#phase = "awaitingHello";
|
|
796
|
+
this.#send({
|
|
797
|
+
id: HELLO_REQUEST_ID,
|
|
798
|
+
jsonrpc: "2.0",
|
|
799
|
+
method: "system.hello",
|
|
800
|
+
params: {
|
|
801
|
+
client: this.#clientPeer,
|
|
802
|
+
features: { required: [EVENTS_STREAM_FEATURE] },
|
|
803
|
+
protocol: {
|
|
804
|
+
ranges: [{
|
|
805
|
+
major: this.#eventProtocol.major,
|
|
806
|
+
maxMinor: this.#eventProtocol.minor,
|
|
807
|
+
minMinor: this.#eventProtocol.minor,
|
|
808
|
+
}],
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
});
|
|
812
|
+
};
|
|
813
|
+
#onMessage = (event) => {
|
|
814
|
+
try {
|
|
815
|
+
const message = parseJsonMessage(event.data, this.#settings.maxMessageBytes);
|
|
816
|
+
if (this.#phase === "awaitingHello") {
|
|
817
|
+
validateRpcResponse("system.hello", message.value);
|
|
818
|
+
const result = parseResponseResult(message.value, HELLO_REQUEST_ID, "WebSocket system.hello response");
|
|
819
|
+
validateHelloResult(result, this.#eventProtocol);
|
|
820
|
+
this.#phase = "awaitingSubscribe";
|
|
821
|
+
this.#send({
|
|
822
|
+
id: SUBSCRIBE_REQUEST_ID,
|
|
823
|
+
jsonrpc: "2.0",
|
|
824
|
+
method: "events.subscribe",
|
|
825
|
+
params: {
|
|
826
|
+
sessionId: this.#sessionId,
|
|
827
|
+
...(this.#afterCursor ? { afterCursor: this.#afterCursor } : {}),
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (this.#phase === "awaitingSubscribe") {
|
|
833
|
+
validateRpcResponse("events.subscribe", message.value);
|
|
834
|
+
const result = parseResponseResult(message.value, SUBSCRIBE_REQUEST_ID, "events.subscribe response");
|
|
835
|
+
this.#subscription = validateSubscribeResult(result, this.#sessionId, this.#streamEpoch, this.#afterCursor);
|
|
836
|
+
this.#phase = "streaming";
|
|
837
|
+
this.#readyResolve();
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (this.#phase !== "streaming" || !this.#subscription) {
|
|
841
|
+
throw new ProtocolViolationError("event stream received a message outside its active phase");
|
|
842
|
+
}
|
|
843
|
+
if (isObject(message.value) && message.value.method === "events.stream.event") {
|
|
844
|
+
this.#acceptEvent(validateEventNotification(message.value, message.bytes, this.#sessionId, this.#streamEpoch, this.#subscription.subscriptionId, this.#eventProtocol));
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (isObject(message.value) && message.value.method === "events.stream.terminal") {
|
|
848
|
+
this.#acceptTerminal(validateTerminalNotification(message.value, this.#sessionId, this.#streamEpoch, this.#subscription.subscriptionId));
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
throw new ProtocolViolationError("event stream received an unknown server message");
|
|
852
|
+
}
|
|
853
|
+
catch (cause) {
|
|
854
|
+
this.#fail(cause instanceof Error
|
|
855
|
+
? cause
|
|
856
|
+
: new ProtocolViolationError("event stream message processing failed"));
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
#onClose = (event) => {
|
|
860
|
+
if (this.#phase === "finished") {
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
this.#fail(new EventStreamClosedError(event.code ?? 1006, event.reason ?? ""), false);
|
|
864
|
+
};
|
|
865
|
+
#onError = () => {
|
|
866
|
+
if (this.#phase !== "finished") {
|
|
867
|
+
this.#fail(new EventStreamClosedError(1006, "WebSocket transport error"));
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
constructor(capabilityOpener, clientPeer, eventProtocol, sessionId, afterCursor, streamEpoch, socket, settings, signal) {
|
|
871
|
+
this.#capabilityOpener = capabilityOpener;
|
|
872
|
+
this.#clientPeer = { ...clientPeer };
|
|
873
|
+
this.#eventProtocol = validateEventProtocolVersion(eventProtocol);
|
|
874
|
+
this.#sessionId = sessionId;
|
|
875
|
+
this.#signal = signal;
|
|
876
|
+
this.#afterCursor = afterCursor ? cloneCursor(afterCursor) : undefined;
|
|
877
|
+
this.#confirmedCursor = afterCursor ? cloneCursor(afterCursor) : undefined;
|
|
878
|
+
this.#deliveredSequence = afterCursor?.sequence ?? 0;
|
|
879
|
+
this.#streamEpoch = streamEpoch;
|
|
880
|
+
this.#socket = socket;
|
|
881
|
+
this.#settings = settings;
|
|
882
|
+
this.#ready = new Promise((resolve, reject) => {
|
|
883
|
+
this.#readyResolve = resolve;
|
|
884
|
+
this.#readyReject = reject;
|
|
885
|
+
});
|
|
886
|
+
socket.addEventListener("open", this.#onOpen);
|
|
887
|
+
socket.addEventListener("message", this.#onMessage);
|
|
888
|
+
socket.addEventListener("close", this.#onClose);
|
|
889
|
+
socket.addEventListener("error", this.#onError);
|
|
890
|
+
if (signal) {
|
|
891
|
+
this.#abortListener = () => this.#abort();
|
|
892
|
+
signal.addEventListener("abort", this.#abortListener, { once: true });
|
|
893
|
+
if (signal.aborted) {
|
|
894
|
+
this.#abort();
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
get confirmedCursor() {
|
|
899
|
+
return this.#confirmedCursor ? cloneCursor(this.#confirmedCursor) : undefined;
|
|
900
|
+
}
|
|
901
|
+
get receivedCursor() {
|
|
902
|
+
return this.#receivedCursor ? cloneCursor(this.#receivedCursor) : undefined;
|
|
903
|
+
}
|
|
904
|
+
get sessionId() {
|
|
905
|
+
return this.#sessionId;
|
|
906
|
+
}
|
|
907
|
+
get terminal() {
|
|
908
|
+
return this.#terminal ? structuredClone(this.#terminal) : undefined;
|
|
909
|
+
}
|
|
910
|
+
[Symbol.asyncIterator]() {
|
|
911
|
+
return this;
|
|
912
|
+
}
|
|
913
|
+
async next() {
|
|
914
|
+
if (this.#iteratorClosed) {
|
|
915
|
+
return { done: true, value: undefined };
|
|
916
|
+
}
|
|
917
|
+
const queued = this.#queue.shift();
|
|
918
|
+
if (queued) {
|
|
919
|
+
this.#queuedBytes -= queued.bytes;
|
|
920
|
+
this.#deliveredSequence = queued.item.cursor.sequence;
|
|
921
|
+
return { done: false, value: queued.item };
|
|
922
|
+
}
|
|
923
|
+
if (this.#failure) {
|
|
924
|
+
throw this.#failure;
|
|
925
|
+
}
|
|
926
|
+
if (this.#terminal) {
|
|
927
|
+
if (this.#terminal.termination.reason === "sessionEnded") {
|
|
928
|
+
return { done: true, value: undefined };
|
|
929
|
+
}
|
|
930
|
+
throw new EventStreamRemoteTerminationError(this.#terminal.termination);
|
|
931
|
+
}
|
|
932
|
+
if (this.#pendingNext) {
|
|
933
|
+
throw new ProtocolViolationError("only one event stream next() call may be pending");
|
|
934
|
+
}
|
|
935
|
+
return await new Promise((resolve, reject) => {
|
|
936
|
+
this.#pendingNext = { reject, resolve };
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
async return() {
|
|
940
|
+
this.#iteratorClosed = true;
|
|
941
|
+
this.#queue = [];
|
|
942
|
+
this.#queuedBytes = 0;
|
|
943
|
+
const pending = this.#pendingNext;
|
|
944
|
+
this.#pendingNext = undefined;
|
|
945
|
+
pending?.resolve({ done: true, value: undefined });
|
|
946
|
+
this.#abort();
|
|
947
|
+
return { done: true, value: undefined };
|
|
948
|
+
}
|
|
949
|
+
/** Marks exactly one delivered event as durably accepted by the application. */
|
|
950
|
+
confirm(cursor) {
|
|
951
|
+
const validated = validateCursor(cursor, "confirmed cursor");
|
|
952
|
+
if (validated.sessionId !== this.#sessionId ||
|
|
953
|
+
validated.streamEpoch !== this.#streamEpoch) {
|
|
954
|
+
throw new EventStreamCursorError("confirmed cursor belongs to another stream");
|
|
955
|
+
}
|
|
956
|
+
const expected = (this.#confirmedCursor?.sequence ?? 0) + 1;
|
|
957
|
+
if (validated.sequence !== expected) {
|
|
958
|
+
throw new EventStreamCursorError(`confirmed cursor sequence ${validated.sequence} is not the contiguous next sequence ${expected}`);
|
|
959
|
+
}
|
|
960
|
+
if (validated.sequence > this.#deliveredSequence) {
|
|
961
|
+
throw new EventStreamCursorError("an event must be delivered before it can be confirmed");
|
|
962
|
+
}
|
|
963
|
+
this.#confirmedCursor = cloneCursor(validated);
|
|
964
|
+
return cloneCursor(validated);
|
|
965
|
+
}
|
|
966
|
+
/** Aborts this socket without advancing the confirmed application cursor. */
|
|
967
|
+
cancel() {
|
|
968
|
+
this.#abort();
|
|
969
|
+
}
|
|
970
|
+
/** Opens a fresh single-use capability from the last explicitly confirmed cursor. */
|
|
971
|
+
async resume(options = {}) {
|
|
972
|
+
if (this.#phase !== "finished") {
|
|
973
|
+
throw new EventStreamCursorError("an active event stream cannot be resumed");
|
|
974
|
+
}
|
|
975
|
+
if (this.#queue.length > 0 || this.#pendingNext) {
|
|
976
|
+
throw new EventStreamCursorError("the previous event stream prefix must be drained before it can be resumed");
|
|
977
|
+
}
|
|
978
|
+
return await connectEventStream(this.#capabilityOpener, this.#clientPeer, this.#eventProtocol, {
|
|
979
|
+
sessionId: this.#sessionId,
|
|
980
|
+
...(this.#confirmedCursor ? { afterCursor: cloneCursor(this.#confirmedCursor) } : {}),
|
|
981
|
+
}, {
|
|
982
|
+
maxMessageBytes: options.maxMessageBytes ?? this.#settings.maxMessageBytes,
|
|
983
|
+
maxQueuedBytes: options.maxQueuedBytes ?? this.#settings.maxQueuedBytes,
|
|
984
|
+
maxQueuedEvents: options.maxQueuedEvents ?? this.#settings.maxQueuedEvents,
|
|
985
|
+
originPolicy: options.originPolicy ?? this.#settings.originPolicy,
|
|
986
|
+
webSocketFactory: options.webSocketFactory ?? this.#settings.webSocketFactory,
|
|
987
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
async waitUntilReady() {
|
|
991
|
+
await this.#ready;
|
|
992
|
+
}
|
|
993
|
+
#acceptEvent(validated) {
|
|
994
|
+
const previousSequence = this.#receivedCursor?.sequence ?? this.#afterCursor?.sequence ?? 0;
|
|
995
|
+
if (previousSequence >= Number.MAX_SAFE_INTEGER || validated.cursor.sequence !== previousSequence + 1) {
|
|
996
|
+
throw new EventStreamCursorError(`event stream sequence ${validated.cursor.sequence} does not follow ${previousSequence}`);
|
|
997
|
+
}
|
|
998
|
+
this.#receivedCursor = cloneCursor(validated.cursor);
|
|
999
|
+
this.#receivedPayloadType = validated.event.payload.type;
|
|
1000
|
+
const item = Object.freeze({
|
|
1001
|
+
cursor: cloneCursor(validated.cursor),
|
|
1002
|
+
event: validated.event,
|
|
1003
|
+
confirm: () => this.confirm(validated.cursor),
|
|
1004
|
+
});
|
|
1005
|
+
if (this.#pendingNext) {
|
|
1006
|
+
const pending = this.#pendingNext;
|
|
1007
|
+
this.#pendingNext = undefined;
|
|
1008
|
+
this.#deliveredSequence = validated.cursor.sequence;
|
|
1009
|
+
pending.resolve({ done: false, value: item });
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (this.#queue.length >= this.#settings.maxQueuedEvents ||
|
|
1013
|
+
this.#queuedBytes + validated.bytes > this.#settings.maxQueuedBytes) {
|
|
1014
|
+
throw new EventStreamQueueOverflowError(this.#settings.maxQueuedEvents, this.#settings.maxQueuedBytes);
|
|
1015
|
+
}
|
|
1016
|
+
this.#queue.push({ bytes: validated.bytes, item });
|
|
1017
|
+
this.#queuedBytes += validated.bytes;
|
|
1018
|
+
}
|
|
1019
|
+
#acceptTerminal(terminal) {
|
|
1020
|
+
const emitted = terminal.lastEmittedCursor;
|
|
1021
|
+
if ((emitted === undefined) !== (this.#receivedCursor === undefined) ||
|
|
1022
|
+
(emitted && this.#receivedCursor && !sameCursor(emitted, this.#receivedCursor))) {
|
|
1023
|
+
throw new EventStreamCursorError("terminal lastEmittedCursor does not equal the continuous received prefix");
|
|
1024
|
+
}
|
|
1025
|
+
if (terminal.termination.reason === "sessionEnded") {
|
|
1026
|
+
const subscription = this.#subscription;
|
|
1027
|
+
if (!subscription) {
|
|
1028
|
+
throw new ProtocolViolationError("normal event stream termination preceded subscription");
|
|
1029
|
+
}
|
|
1030
|
+
const continuousSequence = this.#receivedCursor?.sequence ?? this.#afterCursor?.sequence ?? 0;
|
|
1031
|
+
if (continuousSequence < subscription.replayThrough.sequence) {
|
|
1032
|
+
throw new EventStreamCursorError(`normal event stream termination stopped at sequence ${continuousSequence} before replay boundary ${subscription.replayThrough.sequence}`);
|
|
1033
|
+
}
|
|
1034
|
+
const replayWasAlreadyConfirmed = subscription.sessionState === "ended" &&
|
|
1035
|
+
this.#receivedCursor === undefined &&
|
|
1036
|
+
this.#afterCursor !== undefined &&
|
|
1037
|
+
sameCursor(this.#afterCursor, subscription.replayThrough);
|
|
1038
|
+
if (!replayWasAlreadyConfirmed &&
|
|
1039
|
+
this.#receivedPayloadType !== "sessionEnded") {
|
|
1040
|
+
throw new ProtocolViolationError("a subscription that receives events through Session end must receive sessionEnded as its final event before normal termination");
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
this.#terminal = terminal;
|
|
1044
|
+
this.#phase = "finished";
|
|
1045
|
+
this.#detachAbort();
|
|
1046
|
+
this.#detachSocket();
|
|
1047
|
+
this.#settlePendingNext();
|
|
1048
|
+
try {
|
|
1049
|
+
this.#socket.close(1000, "terminal received");
|
|
1050
|
+
}
|
|
1051
|
+
catch {
|
|
1052
|
+
// The typed terminal remains authoritative if the close handshake fails.
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
#abort() {
|
|
1056
|
+
if (this.#phase === "finished") {
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
this.#queue = [];
|
|
1060
|
+
this.#queuedBytes = 0;
|
|
1061
|
+
this.#fail(new EventStreamAbortedError(), true, 1000, "client aborted");
|
|
1062
|
+
}
|
|
1063
|
+
#detachAbort() {
|
|
1064
|
+
if (this.#abortListener) {
|
|
1065
|
+
this.#signal?.removeEventListener("abort", this.#abortListener);
|
|
1066
|
+
}
|
|
1067
|
+
this.#abortListener = undefined;
|
|
1068
|
+
}
|
|
1069
|
+
#detachSocket() {
|
|
1070
|
+
this.#socket.removeEventListener("open", this.#onOpen);
|
|
1071
|
+
this.#socket.removeEventListener("message", this.#onMessage);
|
|
1072
|
+
this.#socket.removeEventListener("close", this.#onClose);
|
|
1073
|
+
this.#socket.removeEventListener("error", this.#onError);
|
|
1074
|
+
}
|
|
1075
|
+
#fail(error, closeSocket = true, closeCode = 1002, closeReason = "protocol violation") {
|
|
1076
|
+
if (this.#phase === "finished") {
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
this.#failure = error;
|
|
1080
|
+
this.#phase = "finished";
|
|
1081
|
+
this.#readyReject(error);
|
|
1082
|
+
this.#detachAbort();
|
|
1083
|
+
this.#detachSocket();
|
|
1084
|
+
this.#settlePendingNext();
|
|
1085
|
+
if (closeSocket) {
|
|
1086
|
+
try {
|
|
1087
|
+
this.#socket.close(closeCode, closeReason);
|
|
1088
|
+
}
|
|
1089
|
+
catch {
|
|
1090
|
+
// Preserve the first explicit stream failure.
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
#send(value) {
|
|
1095
|
+
try {
|
|
1096
|
+
this.#socket.send(JSON.stringify(value));
|
|
1097
|
+
}
|
|
1098
|
+
catch (cause) {
|
|
1099
|
+
this.#fail(new EventStreamClosedError(1006, cause instanceof Error ? cause.message : "WebSocket send failed"));
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
#settlePendingNext() {
|
|
1103
|
+
if (!this.#pendingNext || this.#queue.length > 0) {
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
const pending = this.#pendingNext;
|
|
1107
|
+
this.#pendingNext = undefined;
|
|
1108
|
+
if (this.#failure) {
|
|
1109
|
+
pending.reject(this.#failure);
|
|
1110
|
+
}
|
|
1111
|
+
else if (this.#terminal?.termination.reason === "sessionEnded") {
|
|
1112
|
+
pending.resolve({ done: true, value: undefined });
|
|
1113
|
+
}
|
|
1114
|
+
else if (this.#terminal) {
|
|
1115
|
+
pending.reject(new EventStreamRemoteTerminationError(this.#terminal.termination));
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
export async function connectEventStream(capabilityOpener, clientPeer, eventProtocol, params, options) {
|
|
1120
|
+
if (options.signal?.aborted) {
|
|
1121
|
+
throw new RequestAbortedError();
|
|
1122
|
+
}
|
|
1123
|
+
const selectedProtocol = validateEventProtocolVersion(eventProtocol);
|
|
1124
|
+
if (!isObject(params)) {
|
|
1125
|
+
throw new ProtocolViolationError("event stream params must be an object");
|
|
1126
|
+
}
|
|
1127
|
+
assertExactKeys(params, ["sessionId"], ["afterCursor"], "event stream params");
|
|
1128
|
+
const sessionId = uuid(params.sessionId, "event stream params.sessionId");
|
|
1129
|
+
const afterCursor = params.afterCursor === undefined || params.afterCursor === null
|
|
1130
|
+
? undefined
|
|
1131
|
+
: validateCursor(params.afterCursor, "event stream params.afterCursor");
|
|
1132
|
+
if (afterCursor && afterCursor.sessionId !== sessionId) {
|
|
1133
|
+
throw new EventStreamCursorError("afterCursor belongs to another session");
|
|
1134
|
+
}
|
|
1135
|
+
const settings = normalizeSettings(options);
|
|
1136
|
+
const capability = validateOpenResult(await openCapabilityWithAbort(capabilityOpener, sessionId, settings.originPolicy, options.signal));
|
|
1137
|
+
if (afterCursor && afterCursor.streamEpoch !== capability.streamEpoch) {
|
|
1138
|
+
throw new EventStreamCursorError("afterCursor belongs to another daemon stream epoch");
|
|
1139
|
+
}
|
|
1140
|
+
if (options.signal?.aborted) {
|
|
1141
|
+
throw new EventStreamAbortedError();
|
|
1142
|
+
}
|
|
1143
|
+
let socket;
|
|
1144
|
+
try {
|
|
1145
|
+
socket = settings.webSocketFactory(capability.endpoint, "devicerail.events.v1");
|
|
1146
|
+
}
|
|
1147
|
+
catch (cause) {
|
|
1148
|
+
throw new EventStreamClosedError(1006, cause instanceof Error ? cause.message : "WebSocket construction failed");
|
|
1149
|
+
}
|
|
1150
|
+
const stream = new DeviceRailEventStream(capabilityOpener, clientPeer, selectedProtocol, sessionId, afterCursor, capability.streamEpoch, socket, settings, options.signal);
|
|
1151
|
+
await stream.waitUntilReady();
|
|
1152
|
+
return stream;
|
|
1153
|
+
}
|