agl 23.0.0 → 24.0.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/changelog.md +11 -0
- package/dist_serve/bundle.js +811 -808
- package/dist_serve/bundle.js.map +1 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.authstore.js +2 -2
- package/dist_ts/classes.browserresourcehost.d.ts +1 -0
- package/dist_ts/classes.browserresourcehost.js +7 -1
- package/dist_ts/classes.controller.d.ts +35 -5
- package/dist_ts/classes.controller.js +994 -229
- package/dist_ts/classes.validation.d.ts +9 -1
- package/dist_ts/classes.validation.js +62 -7
- package/dist_ts_interfaces/browsertransport.d.ts +72 -0
- package/dist_ts_interfaces/browsertransport.js +47 -0
- package/dist_ts_interfaces/index.d.ts +1 -0
- package/dist_ts_interfaces/index.js +2 -1
- package/dist_ts_interfaces/interfaces.d.ts +10 -6
- package/dist_ts_interfaces/interfaces.js +2 -3
- package/dist_ts_interfaces/requests.d.ts +20 -21
- package/package.json +5 -5
- package/readme.md +7 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.authstore.ts +1 -1
- package/ts/classes.browserresourcehost.ts +7 -0
- package/ts/classes.controller.ts +1367 -279
- package/ts/classes.validation.ts +77 -6
- package/ts_interfaces/browsertransport.ts +126 -0
- package/ts_interfaces/index.ts +1 -0
- package/ts_interfaces/interfaces.ts +11 -11
- package/ts_interfaces/requests.ts +23 -30
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/classes.browserviewtransport.ts +532 -0
- package/ts_web/classes.controllersocketclient.ts +6 -17
- package/ts_web/elements.harnesscontrollerapp.ts +450 -115
- package/ts_web/index.ts +1 -0
- package/ts_web/plugins.ts +2 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import * as interfaces from '../ts_interfaces/index.js';
|
|
3
|
+
|
|
4
|
+
const browserOperationTimeoutMs = 35_000;
|
|
5
|
+
const browserTransportStopTimeoutMs = 1_000;
|
|
6
|
+
const textEncoder = new TextEncoder();
|
|
7
|
+
|
|
8
|
+
interface IPendingBrowserOperation {
|
|
9
|
+
resolve(resultArg: interfaces.TControllerBrowserOperationResult): void;
|
|
10
|
+
reject(reasonArg: unknown): void;
|
|
11
|
+
abortSignal?: AbortSignal;
|
|
12
|
+
abortListener?: () => void;
|
|
13
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IControllerBrowserViewTransportClientOptions {
|
|
17
|
+
streamGeneration: number;
|
|
18
|
+
events: plugins.typedrequestInterfaces.TVirtualStream<'receive'>;
|
|
19
|
+
operations: plugins.typedrequestInterfaces.TVirtualStream<'send'>;
|
|
20
|
+
onEvent(eventArg: interfaces.TControllerBrowserViewEvent): void;
|
|
21
|
+
onFailure(errorArg: unknown, recoverableArg: boolean): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class ControllerBrowserOperationError extends Error {
|
|
25
|
+
constructor(
|
|
26
|
+
public readonly code: string,
|
|
27
|
+
messageArg: string,
|
|
28
|
+
) {
|
|
29
|
+
super(messageArg);
|
|
30
|
+
this.name = 'ControllerBrowserOperationError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type TObject = Record<string, unknown>;
|
|
35
|
+
|
|
36
|
+
const assertObject = (valueArg: unknown, nameArg: string): TObject => {
|
|
37
|
+
if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) {
|
|
38
|
+
throw new interfaces.ControllerBrowserTransportProtocolError(`${nameArg} must be an object.`);
|
|
39
|
+
}
|
|
40
|
+
const prototype = Object.getPrototypeOf(valueArg);
|
|
41
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
42
|
+
throw new interfaces.ControllerBrowserTransportProtocolError(`${nameArg} must be a plain object.`);
|
|
43
|
+
}
|
|
44
|
+
return valueArg as TObject;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const assertKeys = (
|
|
48
|
+
valueArg: TObject,
|
|
49
|
+
requiredArg: readonly string[],
|
|
50
|
+
optionalArg: readonly string[] = [],
|
|
51
|
+
): void => {
|
|
52
|
+
const allowed = new Set([...requiredArg, ...optionalArg]);
|
|
53
|
+
if (
|
|
54
|
+
!requiredArg.every((keyArg) => Object.prototype.hasOwnProperty.call(valueArg, keyArg))
|
|
55
|
+
|| Object.keys(valueArg).some((keyArg) => !allowed.has(keyArg))
|
|
56
|
+
) throw new interfaces.ControllerBrowserTransportProtocolError('Browser packet keys are invalid.');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const assertBoundedString = (valueArg: unknown, nameArg: string, maximumBytesArg = 512): string => {
|
|
60
|
+
if (
|
|
61
|
+
typeof valueArg !== 'string'
|
|
62
|
+
|| valueArg.length === 0
|
|
63
|
+
|| textEncoder.encode(valueArg).byteLength > maximumBytesArg
|
|
64
|
+
) throw new interfaces.ControllerBrowserTransportProtocolError(`${nameArg} is invalid.`);
|
|
65
|
+
return valueArg;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const assertSafeInteger = (valueArg: unknown, nameArg: string, minimumArg = 0): number => {
|
|
69
|
+
if (!Number.isSafeInteger(valueArg) || (valueArg as number) < minimumArg) {
|
|
70
|
+
throw new interfaces.ControllerBrowserTransportProtocolError(`${nameArg} is invalid.`);
|
|
71
|
+
}
|
|
72
|
+
return valueArg as number;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const assertFiniteNumber = (valueArg: unknown, nameArg: string): number => {
|
|
76
|
+
if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) {
|
|
77
|
+
throw new interfaces.ControllerBrowserTransportProtocolError(`${nameArg} is invalid.`);
|
|
78
|
+
}
|
|
79
|
+
return valueArg;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const assertBrowserViewport = (valueArg: unknown): interfaces.IControllerBrowserViewport => {
|
|
83
|
+
const value = assertObject(valueArg, 'frame.viewport');
|
|
84
|
+
assertKeys(value, ['width', 'height', 'deviceScaleFactor']);
|
|
85
|
+
const width = assertSafeInteger(value.width, 'viewport.width', 1);
|
|
86
|
+
const height = assertSafeInteger(value.height, 'viewport.height', 1);
|
|
87
|
+
const deviceScaleFactor = assertFiniteNumber(value.deviceScaleFactor, 'viewport.deviceScaleFactor');
|
|
88
|
+
if (
|
|
89
|
+
width > 4096
|
|
90
|
+
|| height > 4096
|
|
91
|
+
|| deviceScaleFactor < 0.25
|
|
92
|
+
|| deviceScaleFactor > 3
|
|
93
|
+
|| Math.ceil(width * deviceScaleFactor) * Math.ceil(height * deviceScaleFactor) > 8_294_400
|
|
94
|
+
) throw new interfaces.ControllerBrowserTransportProtocolError('frame.viewport is out of bounds.');
|
|
95
|
+
return { width, height, deviceScaleFactor };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const assertBrowserError = (
|
|
99
|
+
valueArg: unknown,
|
|
100
|
+
fatalRequiredArg = true,
|
|
101
|
+
): { code: string; message: string; fatal: boolean; tabId?: string } => {
|
|
102
|
+
const value = assertObject(valueArg, 'browser error');
|
|
103
|
+
assertKeys(value, fatalRequiredArg ? ['code', 'message', 'fatal'] : ['code', 'message'], ['tabId']);
|
|
104
|
+
if (fatalRequiredArg && typeof value.fatal !== 'boolean') {
|
|
105
|
+
throw new interfaces.ControllerBrowserTransportProtocolError('browser error fatal flag is invalid.');
|
|
106
|
+
}
|
|
107
|
+
const tabId = value.tabId === undefined
|
|
108
|
+
? undefined
|
|
109
|
+
: assertBoundedString(value.tabId, 'browser error tabId');
|
|
110
|
+
return {
|
|
111
|
+
code: assertBoundedString(value.code, 'browser error code', 128),
|
|
112
|
+
message: assertBoundedString(value.message, 'browser error message', 512),
|
|
113
|
+
fatal: fatalRequiredArg ? value.fatal as boolean : false,
|
|
114
|
+
...(tabId === undefined ? {} : { tabId }),
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const assertBrowserState = (valueArg: unknown): interfaces.IControllerBrowserViewState => {
|
|
119
|
+
const value = assertObject(valueArg, 'browser state');
|
|
120
|
+
assertKeys(
|
|
121
|
+
value,
|
|
122
|
+
['revision', 'status', 'activeTabId', 'viewportRevision', 'viewport', 'tabs'],
|
|
123
|
+
['lastError'],
|
|
124
|
+
);
|
|
125
|
+
if (!['stopped', 'starting', 'running', 'stopping'].includes(String(value.status))) {
|
|
126
|
+
throw new interfaces.ControllerBrowserTransportProtocolError('browser state status is invalid.');
|
|
127
|
+
}
|
|
128
|
+
if (value.activeTabId !== null && typeof value.activeTabId !== 'string') {
|
|
129
|
+
throw new interfaces.ControllerBrowserTransportProtocolError('browser state activeTabId is invalid.');
|
|
130
|
+
}
|
|
131
|
+
if (!Array.isArray(value.tabs) || value.tabs.length > 64) {
|
|
132
|
+
throw new interfaces.ControllerBrowserTransportProtocolError('browser state tabs are invalid.');
|
|
133
|
+
}
|
|
134
|
+
const tabs = value.tabs.map((tabArg): interfaces.IControllerBrowserTabState => {
|
|
135
|
+
const tab = assertObject(tabArg, 'browser tab');
|
|
136
|
+
assertKeys(tab, [
|
|
137
|
+
'id', 'url', 'title', 'active', 'status', 'generation', 'appliedViewportRevision', 'streaming',
|
|
138
|
+
]);
|
|
139
|
+
if (
|
|
140
|
+
typeof tab.active !== 'boolean'
|
|
141
|
+
|| typeof tab.streaming !== 'boolean'
|
|
142
|
+
|| !['open', 'crashed'].includes(String(tab.status))
|
|
143
|
+
) throw new interfaces.ControllerBrowserTransportProtocolError('browser tab state is invalid.');
|
|
144
|
+
return {
|
|
145
|
+
id: assertBoundedString(tab.id, 'browser tab id'),
|
|
146
|
+
url: typeof tab.url === 'string' && textEncoder.encode(tab.url).byteLength <= 16 * 1024
|
|
147
|
+
? tab.url
|
|
148
|
+
: (() => { throw new interfaces.ControllerBrowserTransportProtocolError('browser tab URL is invalid.'); })(),
|
|
149
|
+
title: typeof tab.title === 'string' && textEncoder.encode(tab.title).byteLength <= 4 * 1024
|
|
150
|
+
? tab.title
|
|
151
|
+
: (() => { throw new interfaces.ControllerBrowserTransportProtocolError('browser tab title is invalid.'); })(),
|
|
152
|
+
active: tab.active,
|
|
153
|
+
status: tab.status as 'open' | 'crashed',
|
|
154
|
+
generation: assertSafeInteger(tab.generation, 'browser tab generation'),
|
|
155
|
+
appliedViewportRevision: assertSafeInteger(
|
|
156
|
+
tab.appliedViewportRevision,
|
|
157
|
+
'browser tab viewport revision',
|
|
158
|
+
),
|
|
159
|
+
streaming: tab.streaming,
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
const lastError = value.lastError === undefined
|
|
163
|
+
? undefined
|
|
164
|
+
: assertBrowserError(value.lastError);
|
|
165
|
+
return {
|
|
166
|
+
revision: assertSafeInteger(value.revision, 'browser state revision', 1),
|
|
167
|
+
status: value.status as interfaces.IControllerBrowserViewState['status'],
|
|
168
|
+
activeTabId: value.activeTabId as string | null,
|
|
169
|
+
viewportRevision: assertSafeInteger(value.viewportRevision, 'browser state viewport revision'),
|
|
170
|
+
viewport: assertBrowserViewport(value.viewport),
|
|
171
|
+
tabs,
|
|
172
|
+
...(lastError === undefined ? {} : { lastError }),
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const assertBrowserFrameMetadata = (
|
|
177
|
+
valueArg: unknown,
|
|
178
|
+
): interfaces.IControllerBrowserFrameMetadata => {
|
|
179
|
+
const value = assertObject(valueArg, 'browser frame');
|
|
180
|
+
assertKeys(value, [
|
|
181
|
+
'tabId', 'sequence', 'generation', 'viewportRevision', 'viewport', 'format', 'mimeType',
|
|
182
|
+
'width', 'height', 'metadata', 'dataByteLength',
|
|
183
|
+
]);
|
|
184
|
+
if (
|
|
185
|
+
(value.format !== 'jpeg' && value.format !== 'png')
|
|
186
|
+
|| (value.mimeType !== 'image/jpeg' && value.mimeType !== 'image/png')
|
|
187
|
+
|| (value.format === 'jpeg' && value.mimeType !== 'image/jpeg')
|
|
188
|
+
|| (value.format === 'png' && value.mimeType !== 'image/png')
|
|
189
|
+
) throw new interfaces.ControllerBrowserTransportProtocolError('browser frame format is invalid.');
|
|
190
|
+
const metadata = assertObject(value.metadata, 'browser frame metadata');
|
|
191
|
+
assertKeys(metadata, [
|
|
192
|
+
'offsetTop', 'pageScaleFactor', 'deviceWidth', 'deviceHeight', 'scrollOffsetX', 'scrollOffsetY',
|
|
193
|
+
], ['timestamp']);
|
|
194
|
+
const dataByteLength = assertSafeInteger(value.dataByteLength, 'frame.dataByteLength', 1);
|
|
195
|
+
if (dataByteLength > interfaces.controllerBrowserTransportMaximumFrameBytes) {
|
|
196
|
+
throw new interfaces.ControllerBrowserTransportProtocolError('browser frame exceeds the byte limit.');
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
tabId: assertBoundedString(value.tabId, 'frame.tabId'),
|
|
200
|
+
sequence: assertSafeInteger(value.sequence, 'frame.sequence', 1),
|
|
201
|
+
generation: assertSafeInteger(value.generation, 'frame.generation'),
|
|
202
|
+
viewportRevision: assertSafeInteger(value.viewportRevision, 'frame.viewportRevision'),
|
|
203
|
+
viewport: assertBrowserViewport(value.viewport),
|
|
204
|
+
format: value.format,
|
|
205
|
+
mimeType: value.mimeType,
|
|
206
|
+
width: assertSafeInteger(value.width, 'frame.width', 1),
|
|
207
|
+
height: assertSafeInteger(value.height, 'frame.height', 1),
|
|
208
|
+
metadata: {
|
|
209
|
+
offsetTop: assertFiniteNumber(metadata.offsetTop, 'frame.metadata.offsetTop'),
|
|
210
|
+
pageScaleFactor: assertFiniteNumber(metadata.pageScaleFactor, 'frame.metadata.pageScaleFactor'),
|
|
211
|
+
deviceWidth: assertFiniteNumber(metadata.deviceWidth, 'frame.metadata.deviceWidth'),
|
|
212
|
+
deviceHeight: assertFiniteNumber(metadata.deviceHeight, 'frame.metadata.deviceHeight'),
|
|
213
|
+
scrollOffsetX: assertFiniteNumber(metadata.scrollOffsetX, 'frame.metadata.scrollOffsetX'),
|
|
214
|
+
scrollOffsetY: assertFiniteNumber(metadata.scrollOffsetY, 'frame.metadata.scrollOffsetY'),
|
|
215
|
+
...(metadata.timestamp === undefined
|
|
216
|
+
? {}
|
|
217
|
+
: { timestamp: assertFiniteNumber(metadata.timestamp, 'frame.metadata.timestamp') }),
|
|
218
|
+
},
|
|
219
|
+
dataByteLength,
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const assertServerPacket = (valueArg: unknown): interfaces.TControllerBrowserServerPacket => {
|
|
224
|
+
const value = assertObject(valueArg, 'browser server packet');
|
|
225
|
+
if (value.protocol !== interfaces.controllerBrowserTransportProtocol) {
|
|
226
|
+
throw new interfaces.ControllerBrowserTransportProtocolError('browser packet protocol is invalid.');
|
|
227
|
+
}
|
|
228
|
+
const streamGeneration = assertSafeInteger(value.streamGeneration, 'streamGeneration', 1);
|
|
229
|
+
if (value.type === 'state') {
|
|
230
|
+
assertKeys(value, ['protocol', 'streamGeneration', 'type', 'state']);
|
|
231
|
+
return {
|
|
232
|
+
protocol: interfaces.controllerBrowserTransportProtocol,
|
|
233
|
+
streamGeneration,
|
|
234
|
+
type: 'state',
|
|
235
|
+
state: assertBrowserState(value.state),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (value.type === 'error') {
|
|
239
|
+
assertKeys(value, ['protocol', 'streamGeneration', 'type', 'error']);
|
|
240
|
+
return {
|
|
241
|
+
protocol: interfaces.controllerBrowserTransportProtocol,
|
|
242
|
+
streamGeneration,
|
|
243
|
+
type: 'error',
|
|
244
|
+
error: assertBrowserError(value.error),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (value.type === 'frame') {
|
|
248
|
+
assertKeys(value, ['protocol', 'streamGeneration', 'type', 'frame']);
|
|
249
|
+
return {
|
|
250
|
+
protocol: interfaces.controllerBrowserTransportProtocol,
|
|
251
|
+
streamGeneration,
|
|
252
|
+
type: 'frame',
|
|
253
|
+
frame: assertBrowserFrameMetadata(value.frame),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (value.type === 'operationResult') {
|
|
257
|
+
assertKeys(value, ['protocol', 'streamGeneration', 'type', 'operationId', 'result']);
|
|
258
|
+
const result = assertObject(value.result, 'browser operation result');
|
|
259
|
+
if (result.status === 'succeeded') {
|
|
260
|
+
assertKeys(result, ['status'], ['accepted', 'state']);
|
|
261
|
+
if (result.accepted !== undefined && typeof result.accepted !== 'boolean') {
|
|
262
|
+
throw new interfaces.ControllerBrowserTransportProtocolError('operation acceptance is invalid.');
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
protocol: interfaces.controllerBrowserTransportProtocol,
|
|
266
|
+
streamGeneration,
|
|
267
|
+
type: 'operationResult',
|
|
268
|
+
operationId: assertSafeInteger(value.operationId, 'operationId', 1),
|
|
269
|
+
result: {
|
|
270
|
+
status: 'succeeded',
|
|
271
|
+
...(result.accepted === undefined ? {} : { accepted: result.accepted }),
|
|
272
|
+
...(result.state === undefined ? {} : { state: assertBrowserState(result.state) }),
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (result.status === 'failed') {
|
|
277
|
+
assertKeys(result, ['status', 'error']);
|
|
278
|
+
const error = assertBrowserError(result.error, false);
|
|
279
|
+
return {
|
|
280
|
+
protocol: interfaces.controllerBrowserTransportProtocol,
|
|
281
|
+
streamGeneration,
|
|
282
|
+
type: 'operationResult',
|
|
283
|
+
operationId: assertSafeInteger(value.operationId, 'operationId', 1),
|
|
284
|
+
result: { status: 'failed', error: { code: error.code, message: error.message } },
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (result.status === 'cancelled' || result.status === 'superseded') {
|
|
288
|
+
assertKeys(result, ['status']);
|
|
289
|
+
return {
|
|
290
|
+
protocol: interfaces.controllerBrowserTransportProtocol,
|
|
291
|
+
streamGeneration,
|
|
292
|
+
type: 'operationResult',
|
|
293
|
+
operationId: assertSafeInteger(value.operationId, 'operationId', 1),
|
|
294
|
+
result: { status: result.status },
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
throw new interfaces.ControllerBrowserTransportProtocolError('browser server packet type is invalid.');
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
export class ControllerBrowserViewTransportClient {
|
|
302
|
+
private lifecycle: 'new' | 'opening' | 'active' | 'failed' | 'stopped' = 'new';
|
|
303
|
+
private nextOperationId = 0;
|
|
304
|
+
private readonly pendingOperations = new Map<number, IPendingBrowserOperation>();
|
|
305
|
+
private startTask?: Promise<void>;
|
|
306
|
+
private receiveTask?: Promise<void>;
|
|
307
|
+
private stopTask?: Promise<void>;
|
|
308
|
+
private failureReported = false;
|
|
309
|
+
|
|
310
|
+
constructor(private readonly options: IControllerBrowserViewTransportClientOptions) {}
|
|
311
|
+
|
|
312
|
+
public get streamGeneration(): number {
|
|
313
|
+
return this.options.streamGeneration;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
public start(): Promise<void> {
|
|
317
|
+
if (this.lifecycle === 'active') return Promise.resolve();
|
|
318
|
+
if (this.startTask) return this.startTask;
|
|
319
|
+
if (this.lifecycle !== 'new') {
|
|
320
|
+
return Promise.reject(new Error('The browser transport cannot be started.'));
|
|
321
|
+
}
|
|
322
|
+
this.lifecycle = 'opening';
|
|
323
|
+
const receiveTask = this.receiveEvents();
|
|
324
|
+
this.receiveTask = receiveTask;
|
|
325
|
+
void receiveTask.catch((errorArg) => this.fail(
|
|
326
|
+
errorArg,
|
|
327
|
+
!(errorArg instanceof interfaces.ControllerBrowserTransportProtocolError),
|
|
328
|
+
));
|
|
329
|
+
const startTask = Promise.all([
|
|
330
|
+
this.options.events.opened,
|
|
331
|
+
this.options.operations.opened,
|
|
332
|
+
]).then(() => {
|
|
333
|
+
if (this.lifecycle !== 'opening') throw new Error('The browser transport stopped while opening.');
|
|
334
|
+
this.lifecycle = 'active';
|
|
335
|
+
}).catch((errorArg) => {
|
|
336
|
+
this.fail(errorArg, true);
|
|
337
|
+
throw errorArg;
|
|
338
|
+
});
|
|
339
|
+
this.startTask = startTask;
|
|
340
|
+
return startTask;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
public async operate(
|
|
344
|
+
operationArg: interfaces.TControllerBrowserViewOperation,
|
|
345
|
+
signalArg?: AbortSignal,
|
|
346
|
+
): Promise<interfaces.TControllerBrowserOperationResult> {
|
|
347
|
+
if (this.lifecycle !== 'active') throw new Error('The browser transport is unavailable.');
|
|
348
|
+
signalArg?.throwIfAborted();
|
|
349
|
+
if (
|
|
350
|
+
this.pendingOperations.size
|
|
351
|
+
>= interfaces.controllerBrowserTransportMaximumPendingOperations
|
|
352
|
+
) throw new Error('The browser operation capacity was reached.');
|
|
353
|
+
const operationId = ++this.nextOperationId;
|
|
354
|
+
const packet = interfaces.encodeControllerBrowserTransportPacket({
|
|
355
|
+
protocol: interfaces.controllerBrowserTransportProtocol,
|
|
356
|
+
streamGeneration: this.options.streamGeneration,
|
|
357
|
+
type: 'operation',
|
|
358
|
+
operationId,
|
|
359
|
+
operation: operationArg,
|
|
360
|
+
});
|
|
361
|
+
const result = new Promise<interfaces.TControllerBrowserOperationResult>((resolve, reject) => {
|
|
362
|
+
const timeout = setTimeout(() => {
|
|
363
|
+
this.cancelPendingOperation(
|
|
364
|
+
operationId,
|
|
365
|
+
new DOMException('The browser operation timed out.', 'TimeoutError'),
|
|
366
|
+
);
|
|
367
|
+
}, browserOperationTimeoutMs);
|
|
368
|
+
const pending: IPendingBrowserOperation = { resolve, reject, timeout };
|
|
369
|
+
if (signalArg) {
|
|
370
|
+
pending.abortSignal = signalArg;
|
|
371
|
+
pending.abortListener = () => this.cancelPendingOperation(
|
|
372
|
+
operationId,
|
|
373
|
+
signalArg.reason ?? new DOMException('The operation was aborted.', 'AbortError'),
|
|
374
|
+
);
|
|
375
|
+
signalArg.addEventListener('abort', pending.abortListener, { once: true });
|
|
376
|
+
}
|
|
377
|
+
this.pendingOperations.set(operationId, pending);
|
|
378
|
+
});
|
|
379
|
+
let sendTask: Promise<void>;
|
|
380
|
+
try {
|
|
381
|
+
sendTask = this.options.operations.send(packet);
|
|
382
|
+
} catch (errorArg) {
|
|
383
|
+
this.rejectPendingOperation(operationId, errorArg);
|
|
384
|
+
this.fail(errorArg, true);
|
|
385
|
+
return result;
|
|
386
|
+
}
|
|
387
|
+
void sendTask.catch((errorArg) => {
|
|
388
|
+
this.rejectPendingOperation(operationId, errorArg);
|
|
389
|
+
this.fail(errorArg, true);
|
|
390
|
+
});
|
|
391
|
+
return result;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
public stop(reasonArg: unknown = new Error('The browser transport stopped.')): Promise<void> {
|
|
395
|
+
if (this.stopTask) return this.stopTask;
|
|
396
|
+
this.lifecycle = 'stopped';
|
|
397
|
+
this.rejectAllPending(reasonArg);
|
|
398
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
399
|
+
const settlement = Promise.allSettled([
|
|
400
|
+
Promise.resolve().then(() => this.options.events.abort(reasonArg)),
|
|
401
|
+
Promise.resolve().then(() => this.options.operations.abort(reasonArg)),
|
|
402
|
+
this.startTask,
|
|
403
|
+
this.receiveTask,
|
|
404
|
+
this.options.events.closed,
|
|
405
|
+
this.options.operations.closed,
|
|
406
|
+
].filter((candidateArg): candidateArg is Promise<void> => candidateArg !== undefined))
|
|
407
|
+
.then(() => undefined);
|
|
408
|
+
const boundedStop = Promise.race([
|
|
409
|
+
settlement,
|
|
410
|
+
new Promise<void>((resolve) => {
|
|
411
|
+
timeout = setTimeout(resolve, browserTransportStopTimeoutMs);
|
|
412
|
+
}),
|
|
413
|
+
]).finally(() => {
|
|
414
|
+
if (timeout) clearTimeout(timeout);
|
|
415
|
+
});
|
|
416
|
+
this.stopTask = boundedStop;
|
|
417
|
+
return boundedStop;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private async receiveEvents(): Promise<void> {
|
|
421
|
+
while (this.lifecycle !== 'failed' && this.lifecycle !== 'stopped') {
|
|
422
|
+
const chunk = await this.options.events.receive();
|
|
423
|
+
if (chunk === undefined) {
|
|
424
|
+
if (this.isStopped()) return;
|
|
425
|
+
await this.options.events.accept();
|
|
426
|
+
throw new Error('The browser event stream closed unexpectedly.');
|
|
427
|
+
}
|
|
428
|
+
const packet = assertServerPacket(
|
|
429
|
+
interfaces.decodeControllerBrowserTransportPacket(chunk),
|
|
430
|
+
);
|
|
431
|
+
if (packet.streamGeneration !== this.options.streamGeneration) {
|
|
432
|
+
throw new interfaces.ControllerBrowserTransportProtocolError(
|
|
433
|
+
'The browser packet belongs to a stale stream generation.',
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
if (packet.type === 'operationResult') {
|
|
437
|
+
this.settlePendingOperation(packet.operationId, packet.result);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (packet.type === 'state') {
|
|
441
|
+
this.options.onEvent({ type: 'state', state: packet.state });
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (packet.type === 'error') {
|
|
445
|
+
this.options.onEvent({ type: 'error', error: packet.error });
|
|
446
|
+
if (packet.error.fatal) {
|
|
447
|
+
this.fail(new Error(packet.error.message), false);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const data = await this.options.events.receive();
|
|
453
|
+
if (data === undefined || data.byteLength !== packet.frame.dataByteLength) {
|
|
454
|
+
throw new interfaces.ControllerBrowserTransportProtocolError(
|
|
455
|
+
'The browser frame payload length does not match its metadata.',
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
const { dataByteLength: _dataByteLength, ...frame } = packet.frame;
|
|
459
|
+
const browserFrame: interfaces.IControllerBrowserFrame = {
|
|
460
|
+
...frame,
|
|
461
|
+
data: Uint8Array.from(data),
|
|
462
|
+
};
|
|
463
|
+
this.options.onEvent({ type: 'frame', frame: browserFrame });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private cancelPendingOperation(operationIdArg: number, reasonArg: unknown): void {
|
|
468
|
+
if (!this.rejectPendingOperation(operationIdArg, reasonArg)) return;
|
|
469
|
+
const packet = interfaces.encodeControllerBrowserTransportPacket({
|
|
470
|
+
protocol: interfaces.controllerBrowserTransportProtocol,
|
|
471
|
+
streamGeneration: this.options.streamGeneration,
|
|
472
|
+
type: 'cancel',
|
|
473
|
+
operationId: operationIdArg,
|
|
474
|
+
});
|
|
475
|
+
void Promise.resolve().then(() => this.options.operations.send(packet))
|
|
476
|
+
.catch((errorArg) => this.fail(errorArg, true));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private settlePendingOperation(
|
|
480
|
+
operationIdArg: number,
|
|
481
|
+
resultArg: interfaces.TControllerBrowserOperationResult,
|
|
482
|
+
): void {
|
|
483
|
+
const pending = this.takePendingOperation(operationIdArg);
|
|
484
|
+
if (!pending) return;
|
|
485
|
+
if (resultArg.status === 'failed') {
|
|
486
|
+
pending.reject(new ControllerBrowserOperationError(resultArg.error.code, resultArg.error.message));
|
|
487
|
+
} else if (resultArg.status === 'cancelled') {
|
|
488
|
+
pending.reject(new DOMException('The browser operation was cancelled.', 'AbortError'));
|
|
489
|
+
} else {
|
|
490
|
+
pending.resolve(resultArg);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private rejectPendingOperation(operationIdArg: number, reasonArg: unknown): boolean {
|
|
495
|
+
const pending = this.takePendingOperation(operationIdArg);
|
|
496
|
+
if (!pending) return false;
|
|
497
|
+
pending.reject(reasonArg);
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private takePendingOperation(operationIdArg: number): IPendingBrowserOperation | undefined {
|
|
502
|
+
const pending = this.pendingOperations.get(operationIdArg);
|
|
503
|
+
if (!pending) return undefined;
|
|
504
|
+
this.pendingOperations.delete(operationIdArg);
|
|
505
|
+
clearTimeout(pending.timeout);
|
|
506
|
+
if (pending.abortSignal && pending.abortListener) {
|
|
507
|
+
pending.abortSignal.removeEventListener('abort', pending.abortListener);
|
|
508
|
+
}
|
|
509
|
+
return pending;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private rejectAllPending(reasonArg: unknown): void {
|
|
513
|
+
for (const operationId of [...this.pendingOperations.keys()]) {
|
|
514
|
+
this.rejectPendingOperation(operationId, reasonArg);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private fail(errorArg: unknown, recoverableArg: boolean): void {
|
|
519
|
+
if (this.lifecycle === 'failed' || this.lifecycle === 'stopped') return;
|
|
520
|
+
this.lifecycle = 'failed';
|
|
521
|
+
this.rejectAllPending(errorArg);
|
|
522
|
+
void this.options.events.abort(errorArg).catch(() => undefined);
|
|
523
|
+
void this.options.operations.abort(errorArg).catch(() => undefined);
|
|
524
|
+
if (this.failureReported) return;
|
|
525
|
+
this.failureReported = true;
|
|
526
|
+
this.options.onFailure(errorArg, recoverableArg);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private isStopped(): boolean {
|
|
530
|
+
return this.lifecycle === 'stopped';
|
|
531
|
+
}
|
|
532
|
+
}
|
|
@@ -279,18 +279,11 @@ export interface IControllerSocketClientOptions {
|
|
|
279
279
|
onTerminalOutput?: (
|
|
280
280
|
output: interfaces.IReq_ControllerTerminalOutput['request'],
|
|
281
281
|
) => void | Promise<void>;
|
|
282
|
-
onBrowserViewEvent?: (
|
|
283
|
-
event: TControllerBrowserViewEventMessage,
|
|
284
|
-
) => void | Promise<void>;
|
|
285
282
|
/** Fires when the transport fail-closes the connection (e.g. frame limits). */
|
|
286
283
|
onTransportClose?: (closeCode: number, reason: string) => void;
|
|
284
|
+
onReconnectExhausted?: (attempts: number, endpoint: string) => void;
|
|
287
285
|
}
|
|
288
286
|
|
|
289
|
-
export type TControllerBrowserViewEventMessage =
|
|
290
|
-
plugins.typedrequest.TReverseVirtualStreamDirections<
|
|
291
|
-
interfaces.IReq_ControllerBrowserViewEvent['request']
|
|
292
|
-
>;
|
|
293
|
-
|
|
294
287
|
export interface IControllerSocketRequestOptions
|
|
295
288
|
extends plugins.typedrequest.ITypedRequestFireOptions {
|
|
296
289
|
abortSignal?: AbortSignal;
|
|
@@ -502,15 +495,6 @@ export class ControllerSocketClient {
|
|
|
502
495
|
},
|
|
503
496
|
),
|
|
504
497
|
);
|
|
505
|
-
this.typedRouter.addTypedHandler(
|
|
506
|
-
new plugins.typedrequest.TypedHandler<interfaces.IReq_ControllerBrowserViewEvent>(
|
|
507
|
-
'controller.browser.view.event',
|
|
508
|
-
async (eventArg) => {
|
|
509
|
-
await this.options.onBrowserViewEvent?.(eventArg);
|
|
510
|
-
return {};
|
|
511
|
-
},
|
|
512
|
-
),
|
|
513
|
-
);
|
|
514
498
|
}
|
|
515
499
|
|
|
516
500
|
public get status(): plugins.typedsocket.TConnectionStatus {
|
|
@@ -608,6 +592,11 @@ export class ControllerSocketClient {
|
|
|
608
592
|
diagnosticArg.closeCode,
|
|
609
593
|
typeof diagnosticArg.reason === 'string' ? diagnosticArg.reason : '',
|
|
610
594
|
);
|
|
595
|
+
} else if (diagnosticArg.kind === 'reconnectExhausted') {
|
|
596
|
+
this.options.onReconnectExhausted?.(
|
|
597
|
+
diagnosticArg.attempts,
|
|
598
|
+
diagnosticArg.endpoint,
|
|
599
|
+
);
|
|
611
600
|
}
|
|
612
601
|
});
|
|
613
602
|
|