@floegence/redevplugin-ui 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge-capability.d.ts +7 -0
- package/dist/bridge-capability.js +8 -0
- package/dist/capability-client.d.ts +27 -7
- package/dist/capability-client.js +25 -19
- package/dist/contracts.gen.d.ts +84 -66
- package/dist/contracts.gen.js +87 -66
- package/dist/error-codes.gen.d.ts +4 -0
- package/dist/error-codes.gen.js +206 -0
- package/dist/errors.d.ts +22 -4
- package/dist/errors.js +42 -60
- package/dist/http.d.ts +16 -8
- package/dist/http.js +151 -15
- package/dist/local-import.d.ts +6 -11
- package/dist/local-import.js +58 -13
- package/dist/opaque-surface-policy.gen.d.ts +4 -3
- package/dist/opaque-surface-policy.gen.js +4 -2
- package/dist/openapi.gen.d.ts +3745 -0
- package/dist/openapi.gen.js +5 -0
- package/dist/platform.d.ts +148 -529
- package/dist/platform.js +250 -81
- package/dist/plugin.d.ts +2 -2
- package/dist/surface-scope.d.ts +2 -2
- package/dist/surface-scope.js +22 -6
- package/dist/surface.d.ts +64 -31
- package/dist/surface.js +1430 -200
- package/dist/trusted-parent.d.ts +3 -3
- package/dist/trusted-parent.js +1 -1
- package/dist/ui-patch-validator.d.ts +67 -0
- package/dist/ui-patch-validator.js +125 -0
- package/dist/ui-reconciler.d.ts +10 -0
- package/dist/ui-reconciler.js +283 -0
- package/package.json +1 -1
package/dist/surface.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { PluginBridgeError, pluginBridgeErrorCodes } from "./errors.js";
|
|
1
|
+
import { PluginBridgeError, PluginPlatformRequestError, PluginTransportError, pluginBridgeErrorCodes, } from "./errors.js";
|
|
2
|
+
import { pluginBridgeCapabilityEffect, } from "./bridge-capability.js";
|
|
2
3
|
import { pluginUIProtocolVersion } from "./contracts.gen.js";
|
|
3
4
|
import { opaqueSurfaceAllowedTags, opaqueSurfaceGlobalAttributes, opaqueSurfaceRenderLimits, opaqueSurfaceSafeInputTypes, opaqueSurfaceTagAttributes, } from "./opaque-surface-policy.gen.js";
|
|
4
|
-
import { defaultFetch, hasAllowedKeys, hasExactKeys, isRecord,
|
|
5
|
+
import { defaultFetch, hasAllowedKeys, hasExactKeys, isRecord, readMutationPlatformResponse, readPlatformResponse, } from "./http.js";
|
|
5
6
|
import { defaultPluginSurfaceScope, registerPluginSurface, } from "./surface-scope.js";
|
|
6
|
-
|
|
7
|
+
import { PluginUIReconcileError, reconcileValidatedPluginUITrees, validatePluginUITree, } from "./ui-reconciler.js";
|
|
8
|
+
export const opaqueSurfaceDocumentSchemaVersion = "redevplugin.opaque_surface_document.v3";
|
|
7
9
|
export const pluginRiskPlanSchemaVersion = "redevplugin.capability.risk_plan.v1";
|
|
8
|
-
const opaquePluginBridgeGlobalKey = "
|
|
10
|
+
const opaquePluginBridgeGlobalKey = "__redevpluginWorkerBridge";
|
|
9
11
|
const maxPendingPluginBridgeRequests = 256;
|
|
10
|
-
const maxPluginBridgeMessageBytes =
|
|
12
|
+
const maxPluginBridgeMessageBytes = opaqueSurfaceRenderLimits.max_message_bytes;
|
|
11
13
|
const maxRetainedPluginStreamHandles = 128;
|
|
14
|
+
const maxPluginJSONStructuralNodes = 32 * 1024;
|
|
12
15
|
const streamCredentialInvalidatingErrorCodes = new Set([
|
|
13
16
|
"PLUGIN_BRIDGE_DISPOSED",
|
|
14
17
|
"PLUGIN_BRIDGE_HANDSHAKE_FAILED",
|
|
@@ -21,7 +24,7 @@ const streamCredentialInvalidatingErrorCodes = new Set([
|
|
|
21
24
|
"PLUGIN_GRANT_INVALID",
|
|
22
25
|
"PLUGIN_LEASE_INVALID",
|
|
23
26
|
"PLUGIN_LEASE_REPLAYED",
|
|
24
|
-
"
|
|
27
|
+
"PLUGIN_MANAGEMENT_REVISION_MISMATCH",
|
|
25
28
|
"PLUGIN_STREAM_CANCELLED",
|
|
26
29
|
"PLUGIN_STREAM_TICKET_INVALID",
|
|
27
30
|
"PLUGIN_TOKEN_EXPIRED",
|
|
@@ -49,7 +52,15 @@ export class PluginBridgeClient {
|
|
|
49
52
|
#readyPromise;
|
|
50
53
|
#resolveReady;
|
|
51
54
|
#rejectReady;
|
|
52
|
-
#
|
|
55
|
+
#lifecycleState = "active";
|
|
56
|
+
#handlingLifecycle = false;
|
|
57
|
+
#renderRevision = 0;
|
|
58
|
+
#committedTree;
|
|
59
|
+
#pendingRender;
|
|
60
|
+
#renderLoop;
|
|
61
|
+
#controlEditRevisions = new Map();
|
|
62
|
+
#pendingStreamDeliveries = new Map();
|
|
63
|
+
#streamReadTails = new Map();
|
|
53
64
|
#onMessage = (event) => {
|
|
54
65
|
void this.#handleMessage(event);
|
|
55
66
|
};
|
|
@@ -72,7 +83,11 @@ export class PluginBridgeClient {
|
|
|
72
83
|
this.#assertActive();
|
|
73
84
|
return this.#ready ? Promise.resolve() : this.#readyPromise;
|
|
74
85
|
}
|
|
75
|
-
call(method, params) {
|
|
86
|
+
call(method, params, options = {}) {
|
|
87
|
+
const effect = options[pluginBridgeCapabilityEffect];
|
|
88
|
+
return this.#call(method, params, options, effect === undefined || effect !== "read");
|
|
89
|
+
}
|
|
90
|
+
#call(method, params, options, mutation) {
|
|
76
91
|
this.#assertActive();
|
|
77
92
|
if (!validMethod(method)) {
|
|
78
93
|
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin bridge method and params are invalid");
|
|
@@ -91,21 +106,58 @@ export class PluginBridgeClient {
|
|
|
91
106
|
return this.#request(id, {
|
|
92
107
|
type: "redevplugin.bridge.call",
|
|
93
108
|
request,
|
|
94
|
-
});
|
|
109
|
+
}, { mutation, signal: options.signal });
|
|
95
110
|
}
|
|
96
|
-
readStream(streamHandle) {
|
|
111
|
+
readStream(streamHandle, options = {}) {
|
|
97
112
|
this.#assertActive();
|
|
98
113
|
if (!validOpaqueHandle(streamHandle, "stream")) {
|
|
99
114
|
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin stream handle is invalid");
|
|
100
115
|
}
|
|
116
|
+
const previous = this.#streamReadTails.get(streamHandle);
|
|
117
|
+
const read = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() => {
|
|
118
|
+
this.#assertActive();
|
|
119
|
+
if (options.signal?.aborted)
|
|
120
|
+
throw streamReadAbortedError();
|
|
121
|
+
return this.#readStream(streamHandle, options.signal);
|
|
122
|
+
});
|
|
123
|
+
this.#streamReadTails.set(streamHandle, read);
|
|
124
|
+
void read.finally(() => {
|
|
125
|
+
if (this.#streamReadTails.get(streamHandle) === read)
|
|
126
|
+
this.#streamReadTails.delete(streamHandle);
|
|
127
|
+
}).catch(() => undefined);
|
|
128
|
+
return abortableStreamRead(read, options.signal);
|
|
129
|
+
}
|
|
130
|
+
async #readStream(streamHandle, signal) {
|
|
131
|
+
const pending = this.#pendingStreamDeliveries.get(streamHandle);
|
|
132
|
+
if (pending) {
|
|
133
|
+
await this.#acknowledgeStream(streamHandle, pending.deliveryID, signal);
|
|
134
|
+
this.#pendingStreamDeliveries.delete(streamHandle);
|
|
135
|
+
return pending.result;
|
|
136
|
+
}
|
|
101
137
|
const id = this.#requestID("stream");
|
|
102
|
-
|
|
138
|
+
const privateResult = await this.#request(id, {
|
|
103
139
|
type: "redevplugin.bridge.stream.read",
|
|
104
140
|
id,
|
|
105
141
|
stream_handle: streamHandle,
|
|
106
|
-
});
|
|
142
|
+
}, { cancellationKind: "stream", signal });
|
|
143
|
+
const { delivery_id: deliveryID, ...result } = privateResult;
|
|
144
|
+
if (!deliveryID)
|
|
145
|
+
return result;
|
|
146
|
+
this.#pendingStreamDeliveries.set(streamHandle, { deliveryID, result });
|
|
147
|
+
await this.#acknowledgeStream(streamHandle, deliveryID, signal);
|
|
148
|
+
this.#pendingStreamDeliveries.delete(streamHandle);
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
async #acknowledgeStream(streamHandle, deliveryID, signal) {
|
|
152
|
+
const id = this.#requestID("stream_ack");
|
|
153
|
+
await this.#request(id, {
|
|
154
|
+
type: "redevplugin.bridge.stream.ack",
|
|
155
|
+
id,
|
|
156
|
+
stream_handle: streamHandle,
|
|
157
|
+
delivery_id: deliveryID,
|
|
158
|
+
}, { cancellationKind: "stream", signal });
|
|
107
159
|
}
|
|
108
|
-
cancelOperation(operationID, reason) {
|
|
160
|
+
cancelOperation(operationID, reason, options = {}) {
|
|
109
161
|
this.#assertActive();
|
|
110
162
|
if (!validOpaqueHandle(operationID, "operation") || (reason !== undefined && (typeof reason !== "string" || reason.length > 256))) {
|
|
111
163
|
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin operation cancellation is invalid");
|
|
@@ -116,15 +168,22 @@ export class PluginBridgeClient {
|
|
|
116
168
|
id,
|
|
117
169
|
operation_id: operationID,
|
|
118
170
|
reason,
|
|
119
|
-
}));
|
|
171
|
+
}), { mutation: true, signal: options.signal });
|
|
120
172
|
}
|
|
121
173
|
render(tree) {
|
|
122
174
|
this.#assertActive();
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
175
|
+
let validated;
|
|
176
|
+
try {
|
|
177
|
+
validated = validatePluginUITree(tree);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
throw new PluginBridgeError("PLUGIN_UI_PROTOCOL_VIOLATION", error instanceof Error ? error.message : "Plugin UI tree is invalid");
|
|
181
|
+
}
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
const waiters = this.#pendingRender?.waiters ?? [];
|
|
184
|
+
waiters.push({ resolve, reject });
|
|
185
|
+
this.#pendingRender = { tree: validated, waiters };
|
|
186
|
+
this.#startRenderLoop();
|
|
128
187
|
});
|
|
129
188
|
}
|
|
130
189
|
openCanvas(canvasId) {
|
|
@@ -137,7 +196,7 @@ export class PluginBridgeClient {
|
|
|
137
196
|
type: "redevplugin.ui.canvas.open",
|
|
138
197
|
id,
|
|
139
198
|
canvas_id: canvasId,
|
|
140
|
-
}, "canvas", canvasId);
|
|
199
|
+
}, { kind: "canvas", identifier: canvasId });
|
|
141
200
|
}
|
|
142
201
|
updateCanvasAccessibility(canvasId, state) {
|
|
143
202
|
this.#assertActive();
|
|
@@ -163,7 +222,7 @@ export class PluginBridgeClient {
|
|
|
163
222
|
type: "redevplugin.ui.asset.image.open",
|
|
164
223
|
id,
|
|
165
224
|
asset_id: assetId,
|
|
166
|
-
}, "asset", assetId);
|
|
225
|
+
}, { kind: "asset", identifier: assetId });
|
|
167
226
|
}
|
|
168
227
|
onAction(action, handler) {
|
|
169
228
|
this.#assertActive();
|
|
@@ -201,24 +260,97 @@ export class PluginBridgeClient {
|
|
|
201
260
|
};
|
|
202
261
|
}
|
|
203
262
|
dispose() {
|
|
204
|
-
if (this.#disposed)
|
|
263
|
+
if (this.#lifecycleState === "disposed")
|
|
205
264
|
return;
|
|
206
|
-
this.#
|
|
265
|
+
this.#lifecycleState = "disposed";
|
|
207
266
|
const disposedError = new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin bridge client is disposed");
|
|
208
267
|
if (!this.#ready)
|
|
209
268
|
this.#rejectReady(disposedError);
|
|
210
269
|
this.#port.removeEventListener("message", this.#onMessage);
|
|
211
270
|
for (const [id, pending] of this.#pending) {
|
|
212
271
|
clearTimeout(pending.timer);
|
|
272
|
+
pending.abortCleanup?.();
|
|
213
273
|
pending.reject(new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", `Plugin bridge request ${id} was disposed`));
|
|
214
274
|
}
|
|
215
275
|
this.#pending.clear();
|
|
276
|
+
this.#pendingStreamDeliveries.clear();
|
|
216
277
|
this.#actionHandlers.clear();
|
|
217
278
|
this.#canvasInputHandlers.clear();
|
|
218
279
|
this.#lifecycleHandlers.clear();
|
|
280
|
+
for (const waiter of this.#pendingRender?.waiters ?? [])
|
|
281
|
+
waiter.reject(disposedError);
|
|
282
|
+
this.#pendingRender = undefined;
|
|
283
|
+
this.#controlEditRevisions.clear();
|
|
219
284
|
this.#port.close();
|
|
220
285
|
}
|
|
221
|
-
#
|
|
286
|
+
async #drainRenders() {
|
|
287
|
+
while (this.#pendingRender && this.#acceptsPluginWork()) {
|
|
288
|
+
const pending = this.#pendingRender;
|
|
289
|
+
this.#pendingRender = undefined;
|
|
290
|
+
const id = this.#requestID("render");
|
|
291
|
+
const nextRevision = this.#renderRevision + 1;
|
|
292
|
+
try {
|
|
293
|
+
if (!this.#committedTree) {
|
|
294
|
+
await this.#request(id, {
|
|
295
|
+
type: "redevplugin.ui.mount",
|
|
296
|
+
id,
|
|
297
|
+
revision: 1,
|
|
298
|
+
tree: pending.tree,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
const operations = reconcileValidatedPluginUITrees(this.#committedTree, pending.tree, {
|
|
303
|
+
controlEditRevisions: this.#controlEditRevisions,
|
|
304
|
+
});
|
|
305
|
+
if (operations.length > 0) {
|
|
306
|
+
await this.#request(id, {
|
|
307
|
+
type: "redevplugin.ui.patch",
|
|
308
|
+
id,
|
|
309
|
+
base_revision: this.#renderRevision,
|
|
310
|
+
revision: nextRevision,
|
|
311
|
+
operations,
|
|
312
|
+
});
|
|
313
|
+
this.#renderRevision = nextRevision;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
this.#committedTree = pending.tree;
|
|
317
|
+
if (this.#renderRevision === 0)
|
|
318
|
+
this.#renderRevision = 1;
|
|
319
|
+
for (const waiter of pending.waiters)
|
|
320
|
+
waiter.resolve();
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
const bridgeError = error instanceof PluginUIReconcileError
|
|
324
|
+
? new PluginBridgeError("PLUGIN_UI_PROTOCOL_VIOLATION", error.message)
|
|
325
|
+
: error;
|
|
326
|
+
for (const waiter of pending.waiters)
|
|
327
|
+
waiter.reject(bridgeError);
|
|
328
|
+
const queued = this.#pendingRender;
|
|
329
|
+
for (const waiter of queued?.waiters ?? [])
|
|
330
|
+
waiter.reject(bridgeError);
|
|
331
|
+
this.#pendingRender = undefined;
|
|
332
|
+
throw bridgeError;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (this.#pendingRender) {
|
|
336
|
+
const error = new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin UI cannot render after quiescing starts");
|
|
337
|
+
for (const waiter of this.#pendingRender.waiters)
|
|
338
|
+
waiter.reject(error);
|
|
339
|
+
this.#pendingRender = undefined;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
#startRenderLoop() {
|
|
343
|
+
if (this.#renderLoop)
|
|
344
|
+
return;
|
|
345
|
+
this.#renderLoop = this.#drainRenders()
|
|
346
|
+
.catch(() => undefined)
|
|
347
|
+
.finally(() => {
|
|
348
|
+
this.#renderLoop = undefined;
|
|
349
|
+
if (this.#pendingRender)
|
|
350
|
+
this.#startRenderLoop();
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
#request(id, message, options = {}) {
|
|
222
354
|
let normalizedMessage;
|
|
223
355
|
try {
|
|
224
356
|
normalizedMessage = normalizePluginJSONObject(message);
|
|
@@ -232,9 +364,13 @@ export class PluginBridgeClient {
|
|
|
232
364
|
if (this.#pending.size >= maxPendingPluginBridgeRequests) {
|
|
233
365
|
throw new PluginBridgeError("PLUGIN_JSON_LIMIT_EXCEEDED", "Plugin bridge has too many pending requests");
|
|
234
366
|
}
|
|
367
|
+
if (options.signal?.aborted)
|
|
368
|
+
return Promise.reject(requestCancelledError(options, false));
|
|
369
|
+
let posted = false;
|
|
235
370
|
const result = new Promise((resolve, reject) => {
|
|
236
371
|
const timer = setTimeout(() => {
|
|
237
|
-
|
|
372
|
+
const pending = this.#takePending(id);
|
|
373
|
+
if (!pending)
|
|
238
374
|
return;
|
|
239
375
|
try {
|
|
240
376
|
this.#port.postMessage({ type: "redevplugin.bridge.cancel", id });
|
|
@@ -242,39 +378,65 @@ export class PluginBridgeClient {
|
|
|
242
378
|
catch {
|
|
243
379
|
// The request is already locally cancelled when the port closes concurrently.
|
|
244
380
|
}
|
|
245
|
-
reject(new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin bridge request ${id} timed out
|
|
381
|
+
pending.reject(new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin bridge request ${id} timed out`, undefined, undefined, mutationOutcome(options, posted)));
|
|
246
382
|
}, this.timeoutMs);
|
|
383
|
+
const onAbort = () => {
|
|
384
|
+
const pending = this.#takePending(id);
|
|
385
|
+
if (!pending)
|
|
386
|
+
return;
|
|
387
|
+
if (posted) {
|
|
388
|
+
try {
|
|
389
|
+
this.#port.postMessage({ type: "redevplugin.bridge.cancel", id });
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// The request is already locally cancelled when the port closes concurrently.
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
pending.reject(requestCancelledError(options, posted));
|
|
396
|
+
};
|
|
247
397
|
this.#pending.set(id, {
|
|
248
398
|
resolve: (value) => resolve(value),
|
|
249
399
|
reject,
|
|
250
400
|
timer,
|
|
251
|
-
|
|
252
|
-
|
|
401
|
+
abortCleanup: options.signal ? () => options.signal?.removeEventListener("abort", onAbort) : undefined,
|
|
402
|
+
kind: options.kind ?? "json",
|
|
403
|
+
identifier: options.identifier,
|
|
253
404
|
});
|
|
405
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
406
|
+
if (options.signal?.aborted)
|
|
407
|
+
onAbort();
|
|
254
408
|
});
|
|
409
|
+
if (!this.#pending.has(id))
|
|
410
|
+
return result;
|
|
255
411
|
try {
|
|
256
412
|
this.#port.postMessage(normalizedMessage);
|
|
413
|
+
posted = true;
|
|
257
414
|
}
|
|
258
415
|
catch {
|
|
259
|
-
const pending = this.#
|
|
416
|
+
const pending = this.#takePending(id);
|
|
260
417
|
if (pending) {
|
|
261
|
-
|
|
262
|
-
clearTimeout(pending.timer);
|
|
263
|
-
pending.reject(new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", `Plugin bridge request ${id} could not be posted`));
|
|
418
|
+
pending.reject(new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", `Plugin bridge request ${id} could not be posted`, undefined, undefined, options.mutation ? "not_committed" : undefined));
|
|
264
419
|
}
|
|
265
420
|
}
|
|
266
421
|
return result;
|
|
267
422
|
}
|
|
423
|
+
#takePending(id) {
|
|
424
|
+
const pending = this.#pending.get(id);
|
|
425
|
+
if (!pending)
|
|
426
|
+
return undefined;
|
|
427
|
+
this.#pending.delete(id);
|
|
428
|
+
clearTimeout(pending.timer);
|
|
429
|
+
pending.abortCleanup?.();
|
|
430
|
+
return pending;
|
|
431
|
+
}
|
|
268
432
|
async #handleMessage(event) {
|
|
269
|
-
if (this.#disposed)
|
|
433
|
+
if (this.#lifecycleState === "disposed")
|
|
270
434
|
return;
|
|
271
435
|
const data = event.data;
|
|
272
436
|
if (isCanvasReadyCandidate(data)) {
|
|
273
|
-
const pending = this.#
|
|
437
|
+
const pending = this.#takePending(data.id);
|
|
274
438
|
if (!pending)
|
|
275
439
|
return;
|
|
276
|
-
this.#pending.delete(data.id);
|
|
277
|
-
clearTimeout(pending.timer);
|
|
278
440
|
if (pending.kind !== "canvas" || pending.identifier !== data.canvas_id || !isCanvasReadyMessage(data)) {
|
|
279
441
|
pending.reject(new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", `Plugin canvas response ${data.id} is invalid`));
|
|
280
442
|
return;
|
|
@@ -289,11 +451,9 @@ export class PluginBridgeClient {
|
|
|
289
451
|
return;
|
|
290
452
|
}
|
|
291
453
|
if (isImageReadyCandidate(data)) {
|
|
292
|
-
const pending = this.#
|
|
454
|
+
const pending = this.#takePending(data.id);
|
|
293
455
|
if (!pending)
|
|
294
456
|
return;
|
|
295
|
-
this.#pending.delete(data.id);
|
|
296
|
-
clearTimeout(pending.timer);
|
|
297
457
|
if (pending.kind !== "asset" || pending.identifier !== data.asset_id || !isImageReadyMessage(data)) {
|
|
298
458
|
pending.reject(new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", `Plugin image response ${data.id} is invalid`));
|
|
299
459
|
return;
|
|
@@ -304,11 +464,9 @@ export class PluginBridgeClient {
|
|
|
304
464
|
if (!messageWithinLimit(data))
|
|
305
465
|
return;
|
|
306
466
|
if (isBridgeResponseCandidate(data)) {
|
|
307
|
-
const pending = this.#
|
|
467
|
+
const pending = this.#takePending(data.id);
|
|
308
468
|
if (!pending)
|
|
309
469
|
return;
|
|
310
|
-
this.#pending.delete(data.id);
|
|
311
|
-
clearTimeout(pending.timer);
|
|
312
470
|
if (!isBridgeResponse(data)) {
|
|
313
471
|
pending.reject(new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", `Plugin bridge response ${data.id} is invalid`));
|
|
314
472
|
return;
|
|
@@ -319,7 +477,7 @@ export class PluginBridgeClient {
|
|
|
319
477
|
else if (data.ok)
|
|
320
478
|
pending.resolve(data.data);
|
|
321
479
|
else
|
|
322
|
-
pending.reject(new PluginBridgeError(data.error_code, data.error, undefined, data.error_details));
|
|
480
|
+
pending.reject(new PluginBridgeError(data.error_code, data.error, undefined, data.error_details, data.mutation_outcome));
|
|
323
481
|
return;
|
|
324
482
|
}
|
|
325
483
|
if (isLifecycleMessage(data)) {
|
|
@@ -327,6 +485,9 @@ export class PluginBridgeClient {
|
|
|
327
485
|
this.#ready = true;
|
|
328
486
|
this.#resolveReady();
|
|
329
487
|
}
|
|
488
|
+
if (data.quiesce_id)
|
|
489
|
+
this.#lifecycleState = "quiescing";
|
|
490
|
+
this.#handlingLifecycle = true;
|
|
330
491
|
await Promise.allSettled(Array.from(this.#lifecycleHandlers, async (handler) => {
|
|
331
492
|
try {
|
|
332
493
|
await handler(data.event);
|
|
@@ -335,19 +496,23 @@ export class PluginBridgeClient {
|
|
|
335
496
|
// A plugin lifecycle observer cannot block bounded surface teardown.
|
|
336
497
|
}
|
|
337
498
|
}));
|
|
499
|
+
this.#handlingLifecycle = false;
|
|
338
500
|
if (data.quiesce_id) {
|
|
339
501
|
this.#port.postMessage({
|
|
340
502
|
type: "redevplugin.bridge.lifecycle_ack",
|
|
341
503
|
quiesce_id: data.quiesce_id,
|
|
342
504
|
});
|
|
505
|
+
this.#lifecycleState = "quiesced";
|
|
343
506
|
}
|
|
344
|
-
if (data.event.type === "dispose")
|
|
507
|
+
if (data.event.type === "dispose" && !data.quiesce_id)
|
|
345
508
|
this.dispose();
|
|
346
509
|
return;
|
|
347
510
|
}
|
|
348
511
|
if (isActionMessage(data)) {
|
|
512
|
+
this.#controlEditRevisions.set(data.target_key, data.edit_revision);
|
|
513
|
+
const action = publicActionEvent(data);
|
|
349
514
|
for (const handler of this.#actionHandlers.get(data.action) ?? [])
|
|
350
|
-
handler(
|
|
515
|
+
handler(action);
|
|
351
516
|
return;
|
|
352
517
|
}
|
|
353
518
|
const canvasInput = publicCanvasInputMessage(data);
|
|
@@ -360,10 +525,14 @@ export class PluginBridgeClient {
|
|
|
360
525
|
return `${prefix}_${this.#nextID++}`;
|
|
361
526
|
}
|
|
362
527
|
#assertActive() {
|
|
363
|
-
if (this.#
|
|
528
|
+
if (!this.#acceptsPluginWork()) {
|
|
364
529
|
throw new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin bridge client is disposed");
|
|
365
530
|
}
|
|
366
531
|
}
|
|
532
|
+
#acceptsPluginWork() {
|
|
533
|
+
return this.#lifecycleState === "active" ||
|
|
534
|
+
(this.#lifecycleState === "quiescing" && this.#handlingLifecycle);
|
|
535
|
+
}
|
|
367
536
|
}
|
|
368
537
|
export const defaultPluginSurfaceReloadMax = 2;
|
|
369
538
|
export const defaultPluginSurfaceReloadWindowMs = 30_000;
|
|
@@ -449,14 +618,14 @@ export async function trustedParentBridgeHandshakeTranscriptSHA256(handshake, br
|
|
|
449
618
|
}
|
|
450
619
|
const encoder = new TextEncoder();
|
|
451
620
|
const fields = [
|
|
452
|
-
"redevplugin.bridge.handshake.
|
|
621
|
+
"redevplugin.bridge.handshake.v3",
|
|
453
622
|
handshake.plugin_id,
|
|
454
623
|
handshake.surface_id,
|
|
455
624
|
handshake.surface_instance_id,
|
|
456
625
|
handshake.active_fingerprint,
|
|
457
626
|
handshake.bridge_nonce,
|
|
458
627
|
handshake.asset_session_nonce,
|
|
459
|
-
String(handshake.
|
|
628
|
+
String(handshake.management_revision),
|
|
460
629
|
String(handshake.revoke_epoch),
|
|
461
630
|
handshake.ui_protocol_version,
|
|
462
631
|
bridgeChannelID,
|
|
@@ -488,6 +657,53 @@ export function createReDevPluginSurfaceTransport(options = {}) {
|
|
|
488
657
|
});
|
|
489
658
|
return transport;
|
|
490
659
|
}
|
|
660
|
+
function requireSurfaceTransportInternals(transport) {
|
|
661
|
+
const internals = surfaceTransportInternals.get(transport);
|
|
662
|
+
if (!internals) {
|
|
663
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface host transport is invalid");
|
|
664
|
+
}
|
|
665
|
+
return internals;
|
|
666
|
+
}
|
|
667
|
+
async function revokeSurfaceBootstrap(transport, bootstrap, timeoutMs, keepalive) {
|
|
668
|
+
const path = `/_redevplugin/api/plugins/surfaces/${encodeURIComponent(bootstrap.surfaceInstanceId)}/dispose`;
|
|
669
|
+
const controller = new AbortController();
|
|
670
|
+
let timedOut = false;
|
|
671
|
+
let timer;
|
|
672
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
673
|
+
timer = setTimeout(() => {
|
|
674
|
+
timedOut = true;
|
|
675
|
+
controller.abort();
|
|
676
|
+
reject(new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}`, undefined, undefined, "unknown"));
|
|
677
|
+
}, timeoutMs);
|
|
678
|
+
});
|
|
679
|
+
try {
|
|
680
|
+
const response = await Promise.race([
|
|
681
|
+
transport.fetch(transport.apiBaseURL + path, {
|
|
682
|
+
method: "POST",
|
|
683
|
+
headers: { "Accept": "application/json", "Content-Type": "application/json" },
|
|
684
|
+
body: JSON.stringify({ bridge_nonce: bootstrap.bridgeNonce }),
|
|
685
|
+
credentials: "same-origin",
|
|
686
|
+
signal: controller.signal,
|
|
687
|
+
keepalive,
|
|
688
|
+
}),
|
|
689
|
+
timeout,
|
|
690
|
+
]);
|
|
691
|
+
await readMutationPlatformResponse(response);
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
if (timedOut) {
|
|
695
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}`, undefined, undefined, "unknown");
|
|
696
|
+
}
|
|
697
|
+
if (error instanceof PluginBridgeError || error instanceof PluginPlatformRequestError || error instanceof PluginTransportError) {
|
|
698
|
+
throw error;
|
|
699
|
+
}
|
|
700
|
+
throw new PluginTransportError(`Plugin surface request failed for POST ${path}`, error, "unknown");
|
|
701
|
+
}
|
|
702
|
+
finally {
|
|
703
|
+
if (timer)
|
|
704
|
+
clearTimeout(timer);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
491
707
|
export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
492
708
|
const scriptNonce = options.scriptNonce ?? randomOpaqueNonce();
|
|
493
709
|
if (!/^[A-Za-z0-9_-]{8,128}$/.test(scriptNonce)) {
|
|
@@ -520,6 +736,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
520
736
|
const maxRendersPerSecond = ${opaqueSurfaceRenderLimits.max_renders_per_second};
|
|
521
737
|
const maxRenderDepth = ${opaqueSurfaceRenderLimits.max_render_depth};
|
|
522
738
|
const maxRenderNodes = ${opaqueSurfaceRenderLimits.max_render_nodes};
|
|
739
|
+
const maxPatchOperations = ${opaqueSurfaceRenderLimits.max_patch_operations};
|
|
523
740
|
const maxAttributesPerElement = ${opaqueSurfaceRenderLimits.max_attributes_per_element};
|
|
524
741
|
const maxTextLength = ${opaqueSurfaceRenderLimits.max_text_length};
|
|
525
742
|
const maxAttributeValueLength = ${opaqueSurfaceRenderLimits.max_attribute_value_length};
|
|
@@ -553,7 +770,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
553
770
|
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
554
771
|
const canonicalJSON = (value, depth = 0, state = { nodes: 0 }, seen = new Set()) => {
|
|
555
772
|
state.nodes += 1;
|
|
556
|
-
if (state.nodes >
|
|
773
|
+
if (state.nodes > ${maxPluginJSONStructuralNodes} || depth > 64) return false;
|
|
557
774
|
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
558
775
|
if (typeof value === "number") return Number.isFinite(value);
|
|
559
776
|
if (typeof value !== "object" || seen.has(value)) return false;
|
|
@@ -581,6 +798,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
581
798
|
const validIdentifier = (value) => typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value);
|
|
582
799
|
const validResourceIdentifier = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value);
|
|
583
800
|
const validOpaqueHandle = (value, prefix) => typeof value === "string" && value.startsWith(prefix + "_") && /^[A-Za-z0-9_-]{8,160}$/.test(value);
|
|
801
|
+
const validDeliveryID = (value) => typeof value === "string" && /^delivery_[A-Za-z0-9_-]{8,128}$/.test(value);
|
|
584
802
|
const validDigest = (value) => typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
|
|
585
803
|
const validPath = (value) => typeof value === "string" && value.length > 0 && value.length <= 512 && !value.startsWith("/") && !value.includes("\\\\") && !value.split("/").some((part) => !part || part === "." || part === "..");
|
|
586
804
|
const validAttribute = (tag, name, value) => {
|
|
@@ -635,9 +853,13 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
635
853
|
let workerHeartbeatTimeout;
|
|
636
854
|
let pendingQuiesceID;
|
|
637
855
|
const pendingWorkerRequests = new Set();
|
|
638
|
-
const requestSequence = { rpc: 0, stream: 0, render: 0, operation: 0, canvas: 0, asset: 0 };
|
|
856
|
+
const requestSequence = { rpc: 0, stream: 0, stream_ack: 0, render: 0, operation: 0, canvas: 0, asset: 0 };
|
|
639
857
|
let renderWindowStartedAt = 0;
|
|
640
858
|
let renderCount = 0;
|
|
859
|
+
let uiRevision = 0;
|
|
860
|
+
let uiGraph = new Map();
|
|
861
|
+
let uiNodes = new Map();
|
|
862
|
+
const controlEdits = new Map();
|
|
641
863
|
let lastAutofocusIdentity = "";
|
|
642
864
|
const pendingAssets = new Map();
|
|
643
865
|
const queuedAssets = [];
|
|
@@ -712,6 +934,27 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
712
934
|
verifiedAssetBytes.clear();
|
|
713
935
|
assetByLogicalID.clear();
|
|
714
936
|
};
|
|
937
|
+
const terminateQuiescedWorker = () => {
|
|
938
|
+
if (workerHeartbeatTimer) clearTimeout(workerHeartbeatTimer);
|
|
939
|
+
if (workerHeartbeatTimeout) clearTimeout(workerHeartbeatTimeout);
|
|
940
|
+
workerHeartbeatTimer = undefined;
|
|
941
|
+
workerHeartbeatTimeout = undefined;
|
|
942
|
+
workerHeartbeatPendingID = undefined;
|
|
943
|
+
workerReady = false;
|
|
944
|
+
if (workerControlPort) {
|
|
945
|
+
workerControlPort.close();
|
|
946
|
+
workerControlPort = undefined;
|
|
947
|
+
}
|
|
948
|
+
if (workerPort) {
|
|
949
|
+
workerPort.close();
|
|
950
|
+
workerPort = undefined;
|
|
951
|
+
}
|
|
952
|
+
if (worker) {
|
|
953
|
+
worker.terminate();
|
|
954
|
+
worker = undefined;
|
|
955
|
+
}
|
|
956
|
+
pendingWorkerRequests.clear();
|
|
957
|
+
};
|
|
715
958
|
const validateCanvasIdentifiers = (root) => {
|
|
716
959
|
const identifiers = new Set();
|
|
717
960
|
let totalPixels = 0;
|
|
@@ -749,37 +992,96 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
749
992
|
document.head.append(style);
|
|
750
993
|
}
|
|
751
994
|
};
|
|
752
|
-
const
|
|
995
|
+
const applyWorkerAttribute = (element, tag, name, attributeValue) => {
|
|
996
|
+
const lower = name.toLowerCase();
|
|
997
|
+
if (!validAttribute(tag, name, attributeValue)) throw new Error("plugin render attribute is not allowed");
|
|
998
|
+
if (lower === "value" && "value" in element) element.value = String(attributeValue);
|
|
999
|
+
if (lower === "checked" && "checked" in element) element.checked = attributeValue === true;
|
|
1000
|
+
if (lower === "autofocus") {
|
|
1001
|
+
if (attributeValue !== false) element.setAttribute("data-redevplugin-renderer-autofocus", "");
|
|
1002
|
+
else element.removeAttribute("data-redevplugin-renderer-autofocus");
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (typeof attributeValue === "boolean") {
|
|
1006
|
+
const serialized = lower.startsWith("aria-") ? String(attributeValue) : "";
|
|
1007
|
+
if (attributeValue || serialized) element.setAttribute(name, serialized);
|
|
1008
|
+
else element.removeAttribute(name);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
element.setAttribute(name, String(attributeValue));
|
|
1012
|
+
};
|
|
1013
|
+
const buildWorkerSubtree = (value, state, depth, parentKey, graph = new Map(), nodes = new Map()) => {
|
|
753
1014
|
state.nodes += 1;
|
|
754
|
-
if (state.nodes > maxRenderNodes || depth > maxRenderDepth
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
1015
|
+
if (state.nodes > maxRenderNodes || depth > maxRenderDepth || !isRecord(value) || typeof value.key !== "string" ||
|
|
1016
|
+
!validResourceIdentifier(value.key) || graph.has(value.key)) {
|
|
1017
|
+
throw new Error("plugin render tree exceeds limits or contains an invalid key");
|
|
1018
|
+
}
|
|
1019
|
+
if (value.type === "text") {
|
|
1020
|
+
if (!exactKeys(value, ["type", "key", "text"]) || typeof value.text !== "string" || value.text.length > maxTextLength) {
|
|
1021
|
+
throw new Error("plugin render text node is invalid");
|
|
1022
|
+
}
|
|
1023
|
+
const node = document.createTextNode(value.text);
|
|
1024
|
+
graph.set(value.key, {
|
|
1025
|
+
key: value.key,
|
|
1026
|
+
type: "text",
|
|
1027
|
+
text: value.text,
|
|
1028
|
+
parentKey,
|
|
1029
|
+
prevKey: null,
|
|
1030
|
+
nextKey: null,
|
|
1031
|
+
firstChildKey: null,
|
|
1032
|
+
lastChildKey: null,
|
|
1033
|
+
height: 1,
|
|
1034
|
+
heightDirty: false,
|
|
1035
|
+
});
|
|
1036
|
+
nodes.set(value.key, node);
|
|
1037
|
+
return { node, graph, nodes };
|
|
1038
|
+
}
|
|
1039
|
+
if (!Object.keys(value).every((key) => ["type", "key", "tag", "attributes", "children"].includes(key)) ||
|
|
1040
|
+
value.type !== "element" || typeof value.tag !== "string") {
|
|
1041
|
+
throw new Error("plugin render node is invalid");
|
|
758
1042
|
}
|
|
759
|
-
if (!exactKeys(value, Object.prototype.hasOwnProperty.call(value, "attributes") && Object.prototype.hasOwnProperty.call(value, "children") ? ["type", "tag", "attributes", "children"] : Object.prototype.hasOwnProperty.call(value, "attributes") ? ["type", "tag", "attributes"] : Object.prototype.hasOwnProperty.call(value, "children") ? ["type", "tag", "children"] : ["type", "tag"]) || value.type !== "element" || typeof value.tag !== "string") throw new Error("plugin render node is invalid");
|
|
760
1043
|
const tag = value.tag.toLowerCase();
|
|
761
1044
|
if (!allowedTags.has(tag)) throw new Error("plugin render tag is not allowed");
|
|
762
|
-
const
|
|
1045
|
+
const attributes = {};
|
|
763
1046
|
if (value.attributes !== undefined) {
|
|
764
1047
|
if (!isRecord(value.attributes) || Object.keys(value.attributes).length > maxAttributesPerElement) throw new Error("plugin render attributes are invalid");
|
|
765
1048
|
for (const [name, attributeValue] of Object.entries(value.attributes)) {
|
|
766
1049
|
if (!validAttribute(tag, name, attributeValue)) throw new Error("plugin render attribute is not allowed");
|
|
767
|
-
|
|
768
|
-
if (attributeValue !== false) element.setAttribute("data-redevplugin-renderer-autofocus", "");
|
|
769
|
-
continue;
|
|
770
|
-
}
|
|
771
|
-
if (typeof attributeValue === "boolean") {
|
|
772
|
-
if (attributeValue) element.setAttribute(name, "");
|
|
773
|
-
} else {
|
|
774
|
-
element.setAttribute(name, String(attributeValue));
|
|
775
|
-
}
|
|
1050
|
+
attributes[name] = attributeValue;
|
|
776
1051
|
}
|
|
777
1052
|
}
|
|
778
|
-
if (value.children !== undefined)
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1053
|
+
if (value.children !== undefined && !Array.isArray(value.children)) throw new Error("plugin render children are invalid");
|
|
1054
|
+
const element = document.createElement(tag);
|
|
1055
|
+
element.setAttribute("data-redevplugin-key", value.key);
|
|
1056
|
+
for (const [name, attributeValue] of Object.entries(attributes)) applyWorkerAttribute(element, tag, name, attributeValue);
|
|
1057
|
+
const record = {
|
|
1058
|
+
key: value.key,
|
|
1059
|
+
type: "element",
|
|
1060
|
+
tag,
|
|
1061
|
+
attributes,
|
|
1062
|
+
parentKey,
|
|
1063
|
+
prevKey: null,
|
|
1064
|
+
nextKey: null,
|
|
1065
|
+
firstChildKey: null,
|
|
1066
|
+
lastChildKey: null,
|
|
1067
|
+
height: 1,
|
|
1068
|
+
heightDirty: false,
|
|
1069
|
+
};
|
|
1070
|
+
graph.set(value.key, record);
|
|
1071
|
+
nodes.set(value.key, element);
|
|
1072
|
+
let previousKey = null;
|
|
1073
|
+
for (const child of value.children || []) {
|
|
1074
|
+
const built = buildWorkerSubtree(child, state, depth + 1, value.key, graph, nodes);
|
|
1075
|
+
const childRecord = graph.get(child.key);
|
|
1076
|
+
childRecord.prevKey = previousKey;
|
|
1077
|
+
if (previousKey) graph.get(previousKey).nextKey = child.key;
|
|
1078
|
+
else record.firstChildKey = child.key;
|
|
1079
|
+
record.lastChildKey = child.key;
|
|
1080
|
+
record.height = Math.max(record.height, childRecord.height + 1);
|
|
1081
|
+
previousKey = child.key;
|
|
1082
|
+
element.append(built.node);
|
|
1083
|
+
}
|
|
1084
|
+
return { node: element, graph, nodes };
|
|
783
1085
|
};
|
|
784
1086
|
const renderElementIdentity = (element) => ({
|
|
785
1087
|
tag: element.tagName,
|
|
@@ -843,22 +1145,328 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
843
1145
|
document.body.scrollTop = snapshot.bodyScrollTop;
|
|
844
1146
|
document.body.scrollLeft = snapshot.bodyScrollLeft;
|
|
845
1147
|
};
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1148
|
+
const transferredCanvasKey = (key) => {
|
|
1149
|
+
const element = uiNodes.get(key);
|
|
1150
|
+
if (!(element instanceof HTMLCanvasElement)) return false;
|
|
1151
|
+
for (const runtime of canvasRuntimes.values()) if (runtime.canvas === element) return true;
|
|
1152
|
+
return false;
|
|
1153
|
+
};
|
|
1154
|
+
const editableControlAttribute = (tag, name) => {
|
|
1155
|
+
const normalized = name.toLowerCase();
|
|
1156
|
+
return (normalized === "value" && ["input", "textarea", "select", "option"].includes(tag)) ||
|
|
1157
|
+
(normalized === "checked" && tag === "input");
|
|
1158
|
+
};
|
|
1159
|
+
const createGraphOverlay = (base) => {
|
|
1160
|
+
const changed = new Map();
|
|
1161
|
+
const deleted = new Set();
|
|
1162
|
+
let count = base.size;
|
|
1163
|
+
const get = (key) => changed.has(key) ? changed.get(key) : deleted.has(key) ? undefined : base.get(key);
|
|
1164
|
+
const edit = (key) => {
|
|
1165
|
+
const current = get(key);
|
|
1166
|
+
if (!current) throw new Error("plugin UI patch target does not exist");
|
|
1167
|
+
if (changed.has(key)) return current;
|
|
1168
|
+
const copy = { ...current, ...(current.attributes ? { attributes: { ...current.attributes } } : {}) };
|
|
1169
|
+
changed.set(key, copy);
|
|
1170
|
+
return copy;
|
|
1171
|
+
};
|
|
1172
|
+
const add = (record) => {
|
|
1173
|
+
if (get(record.key)) throw new Error("plugin UI insertion duplicates a key");
|
|
1174
|
+
changed.set(record.key, { ...record, ...(record.attributes ? { attributes: { ...record.attributes } } : {}) });
|
|
1175
|
+
count += 1;
|
|
1176
|
+
};
|
|
1177
|
+
const remove = (key) => {
|
|
1178
|
+
if (!get(key)) throw new Error("plugin UI removal target does not exist");
|
|
1179
|
+
changed.delete(key);
|
|
1180
|
+
if (base.has(key)) deleted.add(key);
|
|
1181
|
+
count -= 1;
|
|
1182
|
+
};
|
|
1183
|
+
const commit = () => {
|
|
1184
|
+
for (const key of deleted) base.delete(key);
|
|
1185
|
+
for (const [key, record] of changed) base.set(key, record);
|
|
1186
|
+
};
|
|
1187
|
+
return { get, edit, add, remove, commit, count: () => count };
|
|
1188
|
+
};
|
|
1189
|
+
const graphDepth = (graph, key) => {
|
|
1190
|
+
let depth = 0;
|
|
1191
|
+
let cursor = graph.get(key);
|
|
1192
|
+
const seen = new Set();
|
|
1193
|
+
while (cursor) {
|
|
1194
|
+
if (seen.has(cursor.key)) throw new Error("plugin UI graph contains a cycle");
|
|
1195
|
+
seen.add(cursor.key);
|
|
1196
|
+
depth += 1;
|
|
1197
|
+
cursor = cursor.parentKey ? graph.get(cursor.parentKey) : undefined;
|
|
1198
|
+
}
|
|
1199
|
+
return depth;
|
|
1200
|
+
};
|
|
1201
|
+
const graphSubtreeKeys = (graph, rootKey) => {
|
|
1202
|
+
const keys = [];
|
|
1203
|
+
const stack = [rootKey];
|
|
1204
|
+
while (stack.length > 0) {
|
|
1205
|
+
const key = stack.pop();
|
|
1206
|
+
const record = key && graph.get(key);
|
|
1207
|
+
if (!record) throw new Error("plugin UI graph is inconsistent");
|
|
1208
|
+
keys.push(key);
|
|
1209
|
+
const children = [];
|
|
1210
|
+
let childKey = record.firstChildKey;
|
|
1211
|
+
while (childKey) {
|
|
1212
|
+
const child = graph.get(childKey);
|
|
1213
|
+
if (!child || child.parentKey !== key) throw new Error("plugin UI child graph is inconsistent");
|
|
1214
|
+
children.push(childKey);
|
|
1215
|
+
childKey = child.nextKey;
|
|
1216
|
+
}
|
|
1217
|
+
stack.push(...children.reverse());
|
|
1218
|
+
}
|
|
1219
|
+
return keys;
|
|
1220
|
+
};
|
|
1221
|
+
const graphSubtreeHeight = (graph, rootKey) => {
|
|
1222
|
+
const record = graph.get(rootKey);
|
|
1223
|
+
if (!record) throw new Error("plugin UI graph is inconsistent");
|
|
1224
|
+
if (!record.heightDirty) return record.height;
|
|
1225
|
+
let height = 1;
|
|
1226
|
+
let childKey = record.firstChildKey;
|
|
1227
|
+
while (childKey) {
|
|
1228
|
+
const child = graph.get(childKey);
|
|
1229
|
+
if (!child || child.parentKey !== rootKey) throw new Error("plugin UI child graph is inconsistent");
|
|
1230
|
+
height = Math.max(height, graphSubtreeHeight(graph, childKey) + 1);
|
|
1231
|
+
childKey = child.nextKey;
|
|
1232
|
+
}
|
|
1233
|
+
const edited = graph.edit(rootKey);
|
|
1234
|
+
edited.height = height;
|
|
1235
|
+
edited.heightDirty = false;
|
|
1236
|
+
return height;
|
|
1237
|
+
};
|
|
1238
|
+
const graphParentChainContains = (graph, rootKey, targetKey) => {
|
|
1239
|
+
let cursor = graph.get(rootKey);
|
|
1240
|
+
const seen = new Set();
|
|
1241
|
+
while (cursor) {
|
|
1242
|
+
if (cursor.key === targetKey) return true;
|
|
1243
|
+
if (seen.has(cursor.key)) throw new Error("plugin UI graph contains a cycle");
|
|
1244
|
+
seen.add(cursor.key);
|
|
1245
|
+
cursor = cursor.parentKey ? graph.get(cursor.parentKey) : undefined;
|
|
1246
|
+
}
|
|
1247
|
+
return false;
|
|
1248
|
+
};
|
|
1249
|
+
const graphSubtreeHasTransferredCanvas = (graph, rootKey) => {
|
|
1250
|
+
for (const runtime of canvasRuntimes.values()) {
|
|
1251
|
+
const key = runtime.canvas.getAttribute("data-redevplugin-key");
|
|
1252
|
+
if (!key) continue;
|
|
1253
|
+
let cursor = graph.get(key);
|
|
1254
|
+
const seen = new Set();
|
|
1255
|
+
while (cursor) {
|
|
1256
|
+
if (cursor.key === rootKey) return true;
|
|
1257
|
+
if (seen.has(cursor.key)) throw new Error("plugin UI graph contains a cycle");
|
|
1258
|
+
seen.add(cursor.key);
|
|
1259
|
+
cursor = cursor.parentKey ? graph.get(cursor.parentKey) : undefined;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return false;
|
|
1263
|
+
};
|
|
1264
|
+
const markGraphHeightDirty = (graph, key) => {
|
|
1265
|
+
let cursorKey = key;
|
|
1266
|
+
const seen = new Set();
|
|
1267
|
+
while (cursorKey) {
|
|
1268
|
+
if (seen.has(cursorKey)) throw new Error("plugin UI graph contains a cycle");
|
|
1269
|
+
seen.add(cursorKey);
|
|
1270
|
+
const record = graph.edit(cursorKey);
|
|
1271
|
+
record.heightDirty = true;
|
|
1272
|
+
cursorKey = record.parentKey;
|
|
1273
|
+
}
|
|
1274
|
+
};
|
|
1275
|
+
const detachGraphNode = (graph, key) => {
|
|
1276
|
+
const record = graph.edit(key);
|
|
1277
|
+
if (!record.parentKey) throw new Error("plugin UI root cannot be detached");
|
|
1278
|
+
const parentKey = record.parentKey;
|
|
1279
|
+
const parent = graph.edit(parentKey);
|
|
1280
|
+
if (record.prevKey) graph.edit(record.prevKey).nextKey = record.nextKey;
|
|
1281
|
+
else parent.firstChildKey = record.nextKey;
|
|
1282
|
+
if (record.nextKey) graph.edit(record.nextKey).prevKey = record.prevKey;
|
|
1283
|
+
else parent.lastChildKey = record.prevKey;
|
|
1284
|
+
record.parentKey = undefined;
|
|
1285
|
+
record.prevKey = null;
|
|
1286
|
+
record.nextKey = null;
|
|
1287
|
+
markGraphHeightDirty(graph, parentKey);
|
|
1288
|
+
};
|
|
1289
|
+
const attachGraphNode = (graph, key, parentKey, beforeKey) => {
|
|
1290
|
+
const record = graph.edit(key);
|
|
1291
|
+
const parent = graph.edit(parentKey);
|
|
1292
|
+
if (parent.type !== "element" || record.parentKey) throw new Error("plugin UI insertion parent is invalid");
|
|
1293
|
+
let previousKey = parent.lastChildKey;
|
|
1294
|
+
if (beforeKey !== null) {
|
|
1295
|
+
if (beforeKey === key) throw new Error("plugin UI anchor cannot target the moved node");
|
|
1296
|
+
const before = graph.get(beforeKey);
|
|
1297
|
+
if (!before || before.parentKey !== parentKey) throw new Error("plugin UI anchor is invalid");
|
|
1298
|
+
previousKey = before.prevKey;
|
|
1299
|
+
graph.edit(beforeKey).prevKey = key;
|
|
1300
|
+
} else {
|
|
1301
|
+
parent.lastChildKey = key;
|
|
1302
|
+
}
|
|
1303
|
+
if (previousKey) graph.edit(previousKey).nextKey = key;
|
|
1304
|
+
else parent.firstChildKey = key;
|
|
1305
|
+
record.parentKey = parentKey;
|
|
1306
|
+
record.prevKey = previousKey;
|
|
1307
|
+
record.nextKey = beforeKey;
|
|
1308
|
+
if (beforeKey === null) parent.lastChildKey = key;
|
|
1309
|
+
markGraphHeightDirty(graph, parentKey);
|
|
1310
|
+
};
|
|
1311
|
+
const validatePatch = (operations) => {
|
|
1312
|
+
if (!Array.isArray(operations) || operations.length > maxPatchOperations) throw new Error("plugin UI patch exceeds limits");
|
|
1313
|
+
const graph = createGraphOverlay(uiGraph);
|
|
1314
|
+
const plan = [];
|
|
1315
|
+
for (const operation of operations) {
|
|
1316
|
+
if (!isRecord(operation) || typeof operation.type !== "string") throw new Error("plugin UI patch operation is invalid");
|
|
1317
|
+
if (operation.type === "set_text") {
|
|
1318
|
+
if (!exactKeys(operation, ["type", "target_key", "text"]) || !validResourceIdentifier(operation.target_key) ||
|
|
1319
|
+
typeof operation.text !== "string" || operation.text.length > maxTextLength) throw new Error("plugin UI text patch is invalid");
|
|
1320
|
+
const target = graph.edit(operation.target_key);
|
|
1321
|
+
if (target.type !== "text") throw new Error("plugin UI text patch target is invalid");
|
|
1322
|
+
target.text = operation.text;
|
|
1323
|
+
plan.push(operation);
|
|
1324
|
+
} else if (operation.type === "patch_attributes") {
|
|
1325
|
+
if (!exactKeys(operation, ["type", "target_key", "set", "remove"]) || !validResourceIdentifier(operation.target_key) ||
|
|
1326
|
+
!isRecord(operation.set) || Object.keys(operation.set).length > maxAttributesPerElement || !Array.isArray(operation.remove) ||
|
|
1327
|
+
operation.remove.length > maxAttributesPerElement || new Set(operation.remove).size !== operation.remove.length ||
|
|
1328
|
+
operation.remove.some((name) => typeof name !== "string")) throw new Error("plugin UI attribute patch is invalid");
|
|
1329
|
+
const target = graph.edit(operation.target_key);
|
|
1330
|
+
if (target.type !== "element" || transferredCanvasKey(target.key) || Object.keys(operation.set).some((name) => editableControlAttribute(target.tag, name)) ||
|
|
1331
|
+
operation.remove.some((name) => editableControlAttribute(target.tag, name))) throw new Error("plugin UI attribute patch target is invalid");
|
|
1332
|
+
target.attributes ||= {};
|
|
1333
|
+
for (const [name, value] of Object.entries(operation.set)) {
|
|
1334
|
+
if (!validAttribute(target.tag, name, value)) throw new Error("plugin UI attribute patch is not allowed");
|
|
1335
|
+
target.attributes[name] = value;
|
|
1336
|
+
}
|
|
1337
|
+
for (const name of operation.remove) delete target.attributes[name];
|
|
1338
|
+
plan.push(operation);
|
|
1339
|
+
} else if (operation.type === "patch_control") {
|
|
1340
|
+
if (!Object.keys(operation).every((key) => ["type", "target_key", "edit_revision", "value", "checked"].includes(key)) ||
|
|
1341
|
+
!validResourceIdentifier(operation.target_key) || !Number.isSafeInteger(operation.edit_revision) || operation.edit_revision < 0 ||
|
|
1342
|
+
(operation.value === undefined && operation.checked === undefined) ||
|
|
1343
|
+
(operation.value !== undefined && operation.value !== null && typeof operation.value !== "string") ||
|
|
1344
|
+
(operation.checked !== undefined && operation.checked !== null && typeof operation.checked !== "boolean")) throw new Error("plugin UI control patch is invalid");
|
|
1345
|
+
const target = graph.edit(operation.target_key);
|
|
1346
|
+
if (target.type !== "element" || !["input", "textarea", "select", "option"].includes(target.tag)) throw new Error("plugin UI control patch target is invalid");
|
|
1347
|
+
target.attributes ||= {};
|
|
1348
|
+
if (operation.value !== undefined) operation.value === null ? delete target.attributes.value : target.attributes.value = operation.value;
|
|
1349
|
+
if (operation.checked !== undefined) operation.checked === null ? delete target.attributes.checked : target.attributes.checked = operation.checked;
|
|
1350
|
+
plan.push(operation);
|
|
1351
|
+
} else if (operation.type === "insert_child") {
|
|
1352
|
+
if (!exactKeys(operation, ["type", "parent_key", "before_key", "node"]) || !validResourceIdentifier(operation.parent_key) ||
|
|
1353
|
+
(operation.before_key !== null && !validResourceIdentifier(operation.before_key))) throw new Error("plugin UI insertion is invalid");
|
|
1354
|
+
const parent = graph.get(operation.parent_key);
|
|
1355
|
+
if (!parent || parent.type !== "element") throw new Error("plugin UI insertion target is invalid");
|
|
1356
|
+
const state = { nodes: 0 };
|
|
1357
|
+
const built = buildWorkerSubtree(operation.node, state, graphDepth(graph, operation.parent_key) + 1, undefined);
|
|
1358
|
+
if (graph.count() + state.nodes > maxRenderNodes) throw new Error("plugin UI insertion exceeds node limits");
|
|
1359
|
+
for (const record of built.graph.values()) graph.add(record);
|
|
1360
|
+
attachGraphNode(graph, operation.node.key, operation.parent_key, operation.before_key);
|
|
1361
|
+
plan.push({ ...operation, built });
|
|
1362
|
+
} else if (operation.type === "remove_child") {
|
|
1363
|
+
if (!exactKeys(operation, ["type", "target_key"]) || !validResourceIdentifier(operation.target_key)) throw new Error("plugin UI removal is invalid");
|
|
1364
|
+
const target = graph.get(operation.target_key);
|
|
1365
|
+
if (!target || !target.parentKey) throw new Error("plugin UI removal target is invalid");
|
|
1366
|
+
if (graphSubtreeHasTransferredCanvas(graph, operation.target_key)) throw new Error("transferred canvas cannot be removed");
|
|
1367
|
+
const removedKeys = graphSubtreeKeys(graph, operation.target_key);
|
|
1368
|
+
detachGraphNode(graph, operation.target_key);
|
|
1369
|
+
for (const key of removedKeys) graph.remove(key);
|
|
1370
|
+
plan.push({ ...operation, removedKeys });
|
|
1371
|
+
} else if (operation.type === "move_child") {
|
|
1372
|
+
if (!exactKeys(operation, ["type", "target_key", "parent_key", "before_key"]) || !validResourceIdentifier(operation.target_key) ||
|
|
1373
|
+
!validResourceIdentifier(operation.parent_key) || (operation.before_key !== null && !validResourceIdentifier(operation.before_key))) {
|
|
1374
|
+
throw new Error("plugin UI move is invalid");
|
|
1375
|
+
}
|
|
1376
|
+
const target = graph.get(operation.target_key);
|
|
1377
|
+
const parent = graph.get(operation.parent_key);
|
|
1378
|
+
if (!target || !target.parentKey || !parent || parent.type !== "element" ||
|
|
1379
|
+
graphParentChainContains(graph, operation.parent_key, operation.target_key) ||
|
|
1380
|
+
graphSubtreeHasTransferredCanvas(graph, operation.target_key)) throw new Error("plugin UI move target is invalid");
|
|
1381
|
+
if (graphDepth(graph, operation.parent_key) + graphSubtreeHeight(graph, operation.target_key) > maxRenderDepth) {
|
|
1382
|
+
throw new Error("plugin UI move exceeds depth limits");
|
|
1383
|
+
}
|
|
1384
|
+
detachGraphNode(graph, operation.target_key);
|
|
1385
|
+
attachGraphNode(graph, operation.target_key, operation.parent_key, operation.before_key);
|
|
1386
|
+
plan.push(operation);
|
|
1387
|
+
} else {
|
|
1388
|
+
throw new Error("plugin UI patch operation type is unsupported");
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
return { graph, plan };
|
|
1392
|
+
};
|
|
1393
|
+
const applyPatchOperation = (operation) => {
|
|
1394
|
+
const parent = operation.parent_key && uiNodes.get(operation.parent_key);
|
|
1395
|
+
const target = operation.target_key && uiNodes.get(operation.target_key);
|
|
1396
|
+
if (operation.type === "set_text") {
|
|
1397
|
+
target.nodeValue = operation.text;
|
|
1398
|
+
} else if (operation.type === "patch_attributes") {
|
|
1399
|
+
for (const name of operation.remove) {
|
|
1400
|
+
target.removeAttribute(name);
|
|
1401
|
+
if (name.toLowerCase() === "autofocus") target.removeAttribute("data-redevplugin-renderer-autofocus");
|
|
1402
|
+
}
|
|
1403
|
+
for (const [name, value] of Object.entries(operation.set)) applyWorkerAttribute(target, target.tagName.toLowerCase(), name, value);
|
|
1404
|
+
} else if (operation.type === "patch_control") {
|
|
1405
|
+
const edit = controlEdits.get(operation.target_key) || { revision: 0, isComposing: false };
|
|
1406
|
+
if (edit.revision !== operation.edit_revision || edit.isComposing) return;
|
|
1407
|
+
if (operation.value !== undefined && "value" in target) target.value = operation.value === null ? "" : operation.value;
|
|
1408
|
+
if (operation.checked !== undefined && "checked" in target) target.checked = operation.checked === true;
|
|
1409
|
+
} else if (operation.type === "insert_child") {
|
|
1410
|
+
const before = operation.before_key === null ? null : uiNodes.get(operation.before_key);
|
|
1411
|
+
parent.insertBefore(operation.built.node, before || null);
|
|
1412
|
+
for (const [key, node] of operation.built.nodes) uiNodes.set(key, node);
|
|
1413
|
+
} else if (operation.type === "remove_child") {
|
|
1414
|
+
for (const key of operation.removedKeys) uiNodes.delete(key);
|
|
1415
|
+
target.remove();
|
|
1416
|
+
} else if (operation.type === "move_child") {
|
|
1417
|
+
const before = operation.before_key === null ? null : uiNodes.get(operation.before_key);
|
|
1418
|
+
parent.insertBefore(target, before || null);
|
|
1419
|
+
}
|
|
1420
|
+
};
|
|
1421
|
+
const onAnimationFrame = (commit) => new Promise((resolve, reject) => requestAnimationFrame(() => {
|
|
1422
|
+
try { commit(); resolve(); } catch (error) { reject(error); }
|
|
1423
|
+
}));
|
|
1424
|
+
const mountWorkerTree = async (tree) => {
|
|
1425
|
+
if (uiRevision !== 0) throw new Error("plugin UI mount may only occur once");
|
|
1426
|
+
const built = buildWorkerSubtree(tree, { nodes: 0 }, 1, undefined);
|
|
1427
|
+
const root = built.node;
|
|
1428
|
+
if (!(root instanceof Element)) throw new Error("plugin UI root must be an element");
|
|
1429
|
+
validateCanvasIdentifiers(root);
|
|
1430
|
+
const renderState = captureRenderState();
|
|
1431
|
+
await onAnimationFrame(() => {
|
|
1432
|
+
document.body.replaceChildren(root);
|
|
1433
|
+
restoreRenderState(renderState);
|
|
1434
|
+
});
|
|
1435
|
+
uiGraph = built.graph;
|
|
1436
|
+
uiNodes = built.nodes;
|
|
1437
|
+
uiRevision = 1;
|
|
1438
|
+
sendParent({ type: "redevplugin.surface.first_commit" });
|
|
1439
|
+
};
|
|
1440
|
+
const patchWorkerTree = async (baseRevision, revision, operations) => {
|
|
1441
|
+
if (uiRevision < 1 || baseRevision !== uiRevision || revision !== baseRevision + 1) throw new Error("plugin UI patch revision is invalid");
|
|
1442
|
+
const validated = validatePatch(operations);
|
|
853
1443
|
const renderState = captureRenderState();
|
|
854
|
-
|
|
855
|
-
|
|
1444
|
+
await onAnimationFrame(() => {
|
|
1445
|
+
for (const operation of validated.plan) applyPatchOperation(operation);
|
|
1446
|
+
validated.graph.commit();
|
|
1447
|
+
restoreRenderState(renderState);
|
|
1448
|
+
});
|
|
1449
|
+
uiRevision = revision;
|
|
856
1450
|
};
|
|
857
1451
|
const actionPayload = (event, element, eventType = event.type) => {
|
|
858
|
-
const
|
|
859
|
-
const
|
|
860
|
-
if (
|
|
861
|
-
|
|
1452
|
+
const target = event.target instanceof Element ? event.target.closest("[data-redevplugin-key]") : element;
|
|
1453
|
+
const targetKey = target?.getAttribute("data-redevplugin-key") || element.getAttribute("data-redevplugin-key");
|
|
1454
|
+
if (!validResourceIdentifier(targetKey)) throw new Error("plugin UI action target is invalid");
|
|
1455
|
+
const edit = controlEdits.get(targetKey) || { revision: 0, isComposing: false };
|
|
1456
|
+
if (eventType === "input" || eventType === "change") edit.revision += 1;
|
|
1457
|
+
edit.isComposing = Boolean(event.isComposing || edit.isComposing);
|
|
1458
|
+
controlEdits.set(targetKey, edit);
|
|
1459
|
+
const payload = {
|
|
1460
|
+
type: "redevplugin.ui.action",
|
|
1461
|
+
action: element.getAttribute("data-redevplugin-action"),
|
|
1462
|
+
event: eventType,
|
|
1463
|
+
target_key: targetKey,
|
|
1464
|
+
edit_revision: edit.revision,
|
|
1465
|
+
is_composing: edit.isComposing,
|
|
1466
|
+
};
|
|
1467
|
+
const eventTarget = event.target;
|
|
1468
|
+
if (eventTarget && typeof eventTarget.value === "string") payload.value = eventTarget.value.slice(0, maxTextLength);
|
|
1469
|
+
if (eventTarget && typeof eventTarget.checked === "boolean") payload.checked = eventTarget.checked;
|
|
862
1470
|
if (eventType === "submit" && element.tagName === "FORM") {
|
|
863
1471
|
const values = {};
|
|
864
1472
|
let count = 0;
|
|
@@ -871,7 +1479,24 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
871
1479
|
}
|
|
872
1480
|
return payload;
|
|
873
1481
|
};
|
|
1482
|
+
document.addEventListener("compositionstart", (event) => {
|
|
1483
|
+
const element = event.target instanceof Element ? event.target.closest("[data-redevplugin-key]") : null;
|
|
1484
|
+
const key = element?.getAttribute("data-redevplugin-key");
|
|
1485
|
+
if (!validResourceIdentifier(key)) return;
|
|
1486
|
+
const edit = controlEdits.get(key) || { revision: 0, isComposing: false };
|
|
1487
|
+
edit.isComposing = true;
|
|
1488
|
+
controlEdits.set(key, edit);
|
|
1489
|
+
}, true);
|
|
1490
|
+
document.addEventListener("compositionend", (event) => {
|
|
1491
|
+
const element = event.target instanceof Element ? event.target.closest("[data-redevplugin-key]") : null;
|
|
1492
|
+
const key = element?.getAttribute("data-redevplugin-key");
|
|
1493
|
+
if (!validResourceIdentifier(key)) return;
|
|
1494
|
+
const edit = controlEdits.get(key) || { revision: 0, isComposing: true };
|
|
1495
|
+
edit.isComposing = false;
|
|
1496
|
+
controlEdits.set(key, edit);
|
|
1497
|
+
}, true);
|
|
874
1498
|
const handleAction = (event) => {
|
|
1499
|
+
if (pendingQuiesceID) return;
|
|
875
1500
|
if (!["click", "input", "change", "submit"].includes(event.type)) return;
|
|
876
1501
|
const origin = event.target instanceof Element ? event.target : null;
|
|
877
1502
|
const element = origin ? origin.closest("[data-redevplugin-action]") : null;
|
|
@@ -891,7 +1516,31 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
891
1516
|
sendWorker(actionPayload(event, element));
|
|
892
1517
|
};
|
|
893
1518
|
for (const eventType of ["click", "input", "change", "submit"]) document.addEventListener(eventType, handleAction, true);
|
|
1519
|
+
const focusableModalElements = (modal) => Array.from(modal.querySelectorAll('button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'))
|
|
1520
|
+
.filter((element) => element instanceof HTMLElement && element.tabIndex >= 0 && element.getClientRects().length > 0);
|
|
894
1521
|
document.addEventListener("keydown", (event) => {
|
|
1522
|
+
if (event.key === "Tab" && !event.defaultPrevented && !event.repeat) {
|
|
1523
|
+
const modal = document.body.querySelector('[role="dialog"][aria-modal="true"]');
|
|
1524
|
+
if (modal instanceof HTMLElement) {
|
|
1525
|
+
const focusable = focusableModalElements(modal);
|
|
1526
|
+
if (focusable.length === 0) {
|
|
1527
|
+
event.preventDefault();
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
const first = focusable[0];
|
|
1531
|
+
const last = focusable[focusable.length - 1];
|
|
1532
|
+
const active = document.activeElement;
|
|
1533
|
+
const target = event.shiftKey
|
|
1534
|
+
? (active === first || !modal.contains(active) ? last : undefined)
|
|
1535
|
+
: (active === last || !modal.contains(active) ? first : undefined);
|
|
1536
|
+
if (target instanceof HTMLElement) {
|
|
1537
|
+
event.preventDefault();
|
|
1538
|
+
event.stopPropagation();
|
|
1539
|
+
try { target.focus({ preventScroll: true }); } catch { target.focus(); }
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
895
1544
|
if (event.key !== "Escape" || event.defaultPrevented || event.repeat) return;
|
|
896
1545
|
const origin = event.target instanceof Element ? event.target : document.activeElement instanceof Element ? document.activeElement : null;
|
|
897
1546
|
const owner = origin ? origin.closest("[data-redevplugin-escape-action]") : null;
|
|
@@ -899,7 +1548,17 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
899
1548
|
if (!owner || !document.body.contains(owner) || !validIdentifier(action)) return;
|
|
900
1549
|
event.preventDefault();
|
|
901
1550
|
event.stopPropagation();
|
|
902
|
-
|
|
1551
|
+
const targetKey = owner.getAttribute("data-redevplugin-key");
|
|
1552
|
+
if (!validResourceIdentifier(targetKey)) return;
|
|
1553
|
+
const edit = controlEdits.get(targetKey) || { revision: 0, isComposing: false };
|
|
1554
|
+
sendWorker({
|
|
1555
|
+
type: "redevplugin.ui.action",
|
|
1556
|
+
action,
|
|
1557
|
+
event: "escape",
|
|
1558
|
+
target_key: targetKey,
|
|
1559
|
+
edit_revision: edit.revision,
|
|
1560
|
+
is_composing: edit.isComposing,
|
|
1561
|
+
});
|
|
903
1562
|
}, true);
|
|
904
1563
|
const pumpAssets = () => {
|
|
905
1564
|
while (activeAssetReads < maxConcurrentAssetReads && queuedAssets.length > 0) {
|
|
@@ -1229,7 +1888,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
1229
1888
|
const validCall = (value) => exactKeys(value, ["type", "request"]) && value.type === "redevplugin.bridge.call" && isRecord(value.request) && Object.keys(value.request).every((key) => ["id", "method", "params"].includes(key)) && typeof value.request.id === "string" && value.request.id.length <= 128 && typeof value.request.method === "string" && /^[A-Za-z0-9._:-]{1,256}$/.test(value.request.method) && (value.request.params === undefined || isRecord(value.request.params));
|
|
1230
1889
|
const requestID = (value, expectedKind) => {
|
|
1231
1890
|
if (typeof value !== "string") return undefined;
|
|
1232
|
-
const match = /^(rpc|stream|render|operation|canvas|asset)_([1-9][0-9]{0,15})$/.exec(value);
|
|
1891
|
+
const match = /^(rpc|stream|stream_ack|render|operation|canvas|asset)_([1-9][0-9]{0,15})$/.exec(value);
|
|
1233
1892
|
if (!match || match[1] !== expectedKind) return undefined;
|
|
1234
1893
|
const sequence = Number(match[2]);
|
|
1235
1894
|
return Number.isSafeInteger(sequence) ? { kind: match[1], sequence } : undefined;
|
|
@@ -1447,7 +2106,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
1447
2106
|
if (exactKeys(message, ["type", "quiesce_id"]) && message.type === "redevplugin.bridge.lifecycle_ack" && validOpaqueHandle(message.quiesce_id, "quiesce") && message.quiesce_id === pendingQuiesceID) {
|
|
1448
2107
|
pendingQuiesceID = undefined;
|
|
1449
2108
|
sendParent({ type: "redevplugin.surface.quiesce_ack", quiesce_id: message.quiesce_id });
|
|
1450
|
-
|
|
2109
|
+
terminateQuiescedWorker();
|
|
1451
2110
|
return;
|
|
1452
2111
|
}
|
|
1453
2112
|
if (validCall(message)) {
|
|
@@ -1460,6 +2119,12 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
1460
2119
|
sendParent(message);
|
|
1461
2120
|
return;
|
|
1462
2121
|
}
|
|
2122
|
+
if (exactKeys(message, ["type", "id", "stream_handle", "delivery_id"]) && message.type === "redevplugin.bridge.stream.ack" &&
|
|
2123
|
+
requestID(message.id, "stream_ack") && validOpaqueHandle(message.stream_handle, "stream") && validDeliveryID(message.delivery_id)) {
|
|
2124
|
+
if (!acceptWorkerRequest(message.id, "stream_ack")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
|
|
2125
|
+
sendParent(message);
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
1463
2128
|
if (isRecord(message) && Object.keys(message).every((key) => ["type", "id", "operation_id", "reason"].includes(key)) &&
|
|
1464
2129
|
message.type === "redevplugin.bridge.operation.cancel" && typeof message.id === "string" &&
|
|
1465
2130
|
validOpaqueHandle(message.operation_id, "operation") && (message.reason === undefined || (typeof message.reason === "string" && message.reason.length <= 256))) {
|
|
@@ -1487,20 +2152,30 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
1487
2152
|
void deliverImageAsset(message.id, message.asset_id, asset);
|
|
1488
2153
|
return;
|
|
1489
2154
|
}
|
|
1490
|
-
if (exactKeys(message, ["type", "id", "tree"]) && message.type === "redevplugin.ui.
|
|
2155
|
+
if (exactKeys(message, ["type", "id", "revision", "tree"]) && message.type === "redevplugin.ui.mount" &&
|
|
2156
|
+
typeof message.id === "string" && message.revision === 1) {
|
|
1491
2157
|
if (!acceptWorkerRequest(message.id, "render")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
|
|
1492
2158
|
if (!renderRateAllowed()) {
|
|
1493
2159
|
completeWorkerRequest(message.id);
|
|
1494
2160
|
return rejectWorkerRequest(message.id, "plugin render rate limit exceeded");
|
|
1495
2161
|
}
|
|
1496
|
-
|
|
1497
|
-
applyWorkerRender(message.tree);
|
|
2162
|
+
void mountWorkerTree(message.tree).then(() => {
|
|
1498
2163
|
completeWorkerRequest(message.id);
|
|
1499
2164
|
sendWorker({ type: "redevplugin.bridge.response", id: message.id, ok: true });
|
|
1500
|
-
}
|
|
2165
|
+
}, (error) => fail(error && error.message || "plugin UI mount violated the protocol"));
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
if (exactKeys(message, ["type", "id", "base_revision", "revision", "operations"]) && message.type === "redevplugin.ui.patch" &&
|
|
2169
|
+
typeof message.id === "string" && Number.isSafeInteger(message.base_revision) && Number.isSafeInteger(message.revision)) {
|
|
2170
|
+
if (!acceptWorkerRequest(message.id, "render")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
|
|
2171
|
+
if (!renderRateAllowed()) {
|
|
1501
2172
|
completeWorkerRequest(message.id);
|
|
1502
|
-
|
|
2173
|
+
return rejectWorkerRequest(message.id, "plugin render rate limit exceeded");
|
|
1503
2174
|
}
|
|
2175
|
+
void patchWorkerTree(message.base_revision, message.revision, message.operations).then(() => {
|
|
2176
|
+
completeWorkerRequest(message.id);
|
|
2177
|
+
sendWorker({ type: "redevplugin.bridge.response", id: message.id, ok: true });
|
|
2178
|
+
}, (error) => fail(error && error.message || "plugin UI patch violated the protocol"));
|
|
1504
2179
|
return;
|
|
1505
2180
|
}
|
|
1506
2181
|
if (exactKeys(message, ["type", "id"]) && message.type === "redevplugin.bridge.cancel" && typeof message.id === "string") {
|
|
@@ -1573,9 +2248,9 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
1573
2248
|
})();`;
|
|
1574
2249
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${escapeHTMLAttribute(csp)}"><title>Plugin</title></head><body><script nonce="${scriptNonce}">${bootstrapScript}</script></body></html>`;
|
|
1575
2250
|
}
|
|
1576
|
-
|
|
1577
|
-
export class PluginSurfaceHost {
|
|
2251
|
+
class PluginSurfaceHostImplementation {
|
|
1578
2252
|
element;
|
|
2253
|
+
surfaceInstanceId;
|
|
1579
2254
|
bootstrap;
|
|
1580
2255
|
bridgeChannelId;
|
|
1581
2256
|
frameGenerationId;
|
|
@@ -1583,6 +2258,7 @@ export class PluginSurfaceHost {
|
|
|
1583
2258
|
#iframe;
|
|
1584
2259
|
#transport;
|
|
1585
2260
|
#createMessageChannel;
|
|
2261
|
+
#bridgeReady = false;
|
|
1586
2262
|
#loadTimeoutMs;
|
|
1587
2263
|
#requestTimeoutMs;
|
|
1588
2264
|
#leaseRenewalLeadMs;
|
|
@@ -1631,20 +2307,10 @@ export class PluginSurfaceHost {
|
|
|
1631
2307
|
const error = new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_FAILED", `Plugin iframe reloaded unexpectedly (attempt ${decision.attempt})`);
|
|
1632
2308
|
void this.#failSurface(error);
|
|
1633
2309
|
};
|
|
1634
|
-
|
|
1635
|
-
validateHostBootstrap(options.bootstrap);
|
|
1636
|
-
const transport = surfaceTransportInternals.get(options.hostTransport);
|
|
1637
|
-
if (!transport) {
|
|
1638
|
-
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface host transport is invalid");
|
|
1639
|
-
}
|
|
1640
|
-
return new PluginSurfaceHost(pluginSurfaceHostConstructorToken, options, transport, createPluginSurfaceFrame());
|
|
1641
|
-
}
|
|
1642
|
-
constructor(constructorToken, options, transport, iframe) {
|
|
1643
|
-
if (constructorToken !== pluginSurfaceHostConstructorToken) {
|
|
1644
|
-
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface hosts must be created through PluginSurfaceHost.create()");
|
|
1645
|
-
}
|
|
2310
|
+
constructor(options, transport, iframe) {
|
|
1646
2311
|
this.element = iframe;
|
|
1647
2312
|
this.bootstrap = options.bootstrap;
|
|
2313
|
+
this.surfaceInstanceId = options.bootstrap.surfaceInstanceId;
|
|
1648
2314
|
this.bridgeChannelId = options.bridgeChannelId ?? randomOpaqueHandle("bridge");
|
|
1649
2315
|
this.frameGenerationId = randomOpaqueHandle("frame");
|
|
1650
2316
|
this.surfaceHandle = randomOpaqueHandle("surface");
|
|
@@ -1670,16 +2336,22 @@ export class PluginSurfaceHost {
|
|
|
1670
2336
|
const startedAt = Date.now();
|
|
1671
2337
|
const progressTimer = setTimeout(() => {
|
|
1672
2338
|
if (!this.#ready && !this.#disposed) {
|
|
1673
|
-
this.#
|
|
2339
|
+
this.#reportOpeningProgress({
|
|
1674
2340
|
phase: "opening",
|
|
1675
2341
|
elapsedMs: Math.max(openingProgressDelayMs, Date.now() - startedAt),
|
|
1676
2342
|
});
|
|
1677
2343
|
}
|
|
1678
2344
|
}, openingProgressDelayMs);
|
|
1679
|
-
const signals = {
|
|
2345
|
+
const signals = {
|
|
2346
|
+
portAcknowledged: deferred(),
|
|
2347
|
+
firstPaint: deferred(),
|
|
2348
|
+
workerReady: deferred(),
|
|
2349
|
+
firstCommit: deferred(),
|
|
2350
|
+
};
|
|
1680
2351
|
void signals.portAcknowledged.promise.catch(() => undefined);
|
|
1681
2352
|
void signals.firstPaint.promise.catch(() => undefined);
|
|
1682
2353
|
void signals.workerReady.promise.catch(() => undefined);
|
|
2354
|
+
void signals.firstCommit.promise.catch(() => undefined);
|
|
1683
2355
|
this.#openSignals = signals;
|
|
1684
2356
|
const scriptNonce = randomOpaqueNonce();
|
|
1685
2357
|
this.#frameLoaded = false;
|
|
@@ -1696,7 +2368,7 @@ export class PluginSurfaceHost {
|
|
|
1696
2368
|
if (bridgeError.errorCode === "PLUGIN_BRIDGE_TIMEOUT")
|
|
1697
2369
|
this.#reloadLimiter.recordCrash();
|
|
1698
2370
|
this.#reportError(bridgeError);
|
|
1699
|
-
const revoke = this.#
|
|
2371
|
+
const revoke = this.#revokeSurface(false);
|
|
1700
2372
|
this.#disposeLocal();
|
|
1701
2373
|
await revoke;
|
|
1702
2374
|
throw bridgeError;
|
|
@@ -1741,18 +2413,27 @@ export class PluginSurfaceHost {
|
|
|
1741
2413
|
});
|
|
1742
2414
|
await Promise.all([signals.firstPaint.promise, signals.workerReady.promise]);
|
|
1743
2415
|
this.#assertActive();
|
|
1744
|
-
this.#
|
|
2416
|
+
this.#bridgeReady = true;
|
|
1745
2417
|
this.#scheduleLeaseRenewal();
|
|
1746
|
-
this.#reloadLimiter.recordHealthyLoad();
|
|
1747
2418
|
this.#postToRenderer({ type: "redevplugin.bridge.lifecycle", event: { type: "ready" } });
|
|
2419
|
+
await signals.firstCommit.promise;
|
|
2420
|
+
this.#assertActive();
|
|
2421
|
+
this.#ready = true;
|
|
2422
|
+
this.#reloadLimiter.recordHealthyLoad();
|
|
1748
2423
|
}
|
|
1749
2424
|
sendLifecycle(event) {
|
|
1750
2425
|
this.#assertReady();
|
|
1751
2426
|
this.#postToRenderer({ type: "redevplugin.bridge.lifecycle", event });
|
|
1752
2427
|
}
|
|
1753
2428
|
close() {
|
|
1754
|
-
if (this.#disposed)
|
|
1755
|
-
|
|
2429
|
+
if (this.#disposed) {
|
|
2430
|
+
const result = {
|
|
2431
|
+
quiesce: { outcome: "not_ready", durationMs: 0 },
|
|
2432
|
+
revokeDurationMs: 0,
|
|
2433
|
+
totalDurationMs: 0,
|
|
2434
|
+
};
|
|
2435
|
+
return this.#revokePromise ? this.#revokePromise.then(() => result) : Promise.resolve(result);
|
|
2436
|
+
}
|
|
1756
2437
|
if (this.#closePromise)
|
|
1757
2438
|
return this.#closePromise;
|
|
1758
2439
|
this.#closePromise = this.#closeSurface();
|
|
@@ -1760,14 +2441,19 @@ export class PluginSurfaceHost {
|
|
|
1760
2441
|
}
|
|
1761
2442
|
dispose() {
|
|
1762
2443
|
if (this.#disposed)
|
|
1763
|
-
return;
|
|
1764
|
-
|
|
2444
|
+
return this.#revokePromise ?? Promise.resolve();
|
|
2445
|
+
const revoke = this.#revokeSurface(true);
|
|
2446
|
+
void revoke.catch(() => undefined);
|
|
1765
2447
|
this.#disposeLocal();
|
|
2448
|
+
return revoke;
|
|
1766
2449
|
}
|
|
1767
2450
|
async #closeSurface() {
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
2451
|
+
const startedAt = performance.now();
|
|
2452
|
+
const quiesce = await this.#quiesceSurface();
|
|
2453
|
+
if (this.#disposed) {
|
|
2454
|
+
return { quiesce, revokeDurationMs: 0, totalDurationMs: performance.now() - startedAt };
|
|
2455
|
+
}
|
|
2456
|
+
const revokeStartedAt = performance.now();
|
|
1771
2457
|
const revoke = this.#revokeSurface(false);
|
|
1772
2458
|
this.#disposeLocal();
|
|
1773
2459
|
try {
|
|
@@ -1776,6 +2462,11 @@ export class PluginSurfaceHost {
|
|
|
1776
2462
|
catch (error) {
|
|
1777
2463
|
throw toBridgeError(error, "PLUGIN_BRIDGE_DISPOSED");
|
|
1778
2464
|
}
|
|
2465
|
+
return {
|
|
2466
|
+
quiesce,
|
|
2467
|
+
revokeDurationMs: performance.now() - revokeStartedAt,
|
|
2468
|
+
totalDurationMs: performance.now() - startedAt,
|
|
2469
|
+
};
|
|
1779
2470
|
}
|
|
1780
2471
|
#disposeLocal() {
|
|
1781
2472
|
if (this.#disposed)
|
|
@@ -1784,6 +2475,7 @@ export class PluginSurfaceHost {
|
|
|
1784
2475
|
this.#unregisterSurfaceScope?.();
|
|
1785
2476
|
this.#unregisterSurfaceScope = undefined;
|
|
1786
2477
|
this.#ready = false;
|
|
2478
|
+
this.#bridgeReady = false;
|
|
1787
2479
|
if (this.#leaseRenewalTimer)
|
|
1788
2480
|
clearTimeout(this.#leaseRenewalTimer);
|
|
1789
2481
|
this.#leaseRenewalTimer = undefined;
|
|
@@ -1795,6 +2487,7 @@ export class PluginSurfaceHost {
|
|
|
1795
2487
|
const disposedError = new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin surface host was disposed");
|
|
1796
2488
|
this.#openSignals?.firstPaint.reject(disposedError);
|
|
1797
2489
|
this.#openSignals?.workerReady.reject(disposedError);
|
|
2490
|
+
this.#openSignals?.firstCommit.reject(disposedError);
|
|
1798
2491
|
this.#openSignals?.portAcknowledged.reject(disposedError);
|
|
1799
2492
|
this.#initialFrameLoad?.reject(disposedError);
|
|
1800
2493
|
this.#quiesce?.acknowledged.reject(disposedError);
|
|
@@ -1837,6 +2530,10 @@ export class PluginSurfaceHost {
|
|
|
1837
2530
|
this.#openSignals?.workerReady.resolve();
|
|
1838
2531
|
return;
|
|
1839
2532
|
}
|
|
2533
|
+
if (hasExactKeys(data, ["type"]) && data.type === "redevplugin.surface.first_commit") {
|
|
2534
|
+
this.#openSignals?.firstCommit.resolve();
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
1840
2537
|
if (hasExactKeys(data, ["type", "quiesce_id"]) && data.type === "redevplugin.surface.quiesce_ack" && validOpaqueHandle(data.quiesce_id, "quiesce") && data.quiesce_id === this.#quiesce?.id) {
|
|
1841
2538
|
this.#quiesce.acknowledged.resolve();
|
|
1842
2539
|
return;
|
|
@@ -1858,6 +2555,10 @@ export class PluginSurfaceHost {
|
|
|
1858
2555
|
await this.#handleStreamRead(data.id, data.stream_handle);
|
|
1859
2556
|
return;
|
|
1860
2557
|
}
|
|
2558
|
+
if (isStreamAcknowledgeMessage(data)) {
|
|
2559
|
+
await this.#handleStreamAcknowledge(data.id, data.stream_handle, data.delivery_id);
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
1861
2562
|
if (isOperationCancelMessage(data)) {
|
|
1862
2563
|
await this.#handleOperationCancel(data);
|
|
1863
2564
|
return;
|
|
@@ -1871,7 +2572,7 @@ export class PluginSurfaceHost {
|
|
|
1871
2572
|
}
|
|
1872
2573
|
}
|
|
1873
2574
|
async #handleCall(request) {
|
|
1874
|
-
if (!this.#
|
|
2575
|
+
if ((!this.#bridgeReady && !this.#quiesce) || !this.#gatewayToken) {
|
|
1875
2576
|
this.#postError(request.id, "PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "Plugin bridge call arrived before the surface became ready");
|
|
1876
2577
|
return;
|
|
1877
2578
|
}
|
|
@@ -1889,7 +2590,7 @@ export class PluginSurfaceHost {
|
|
|
1889
2590
|
await this.#handleConfirmationRequired(request, bridgeError, controller.signal);
|
|
1890
2591
|
return;
|
|
1891
2592
|
}
|
|
1892
|
-
this.#postError(request.id, bridgeError.errorCode, bridgeError.message, bridgeError.details);
|
|
2593
|
+
this.#postError(request.id, bridgeError.errorCode, bridgeError.message, bridgeError.details, bridgeError.mutationOutcome);
|
|
1893
2594
|
}
|
|
1894
2595
|
finally {
|
|
1895
2596
|
this.#pendingRequestControllers.delete(request.id);
|
|
@@ -1897,11 +2598,11 @@ export class PluginSurfaceHost {
|
|
|
1897
2598
|
}
|
|
1898
2599
|
async #handleConfirmationRequired(request, originalError, signal) {
|
|
1899
2600
|
if (!this.#confirm) {
|
|
1900
|
-
this.#postError(request.id, originalError.errorCode, originalError.message, originalError.details);
|
|
2601
|
+
this.#postError(request.id, originalError.errorCode, originalError.message, originalError.details, originalError.mutationOutcome);
|
|
1901
2602
|
return;
|
|
1902
2603
|
}
|
|
1903
2604
|
try {
|
|
1904
|
-
const confirmation = await this.#
|
|
2605
|
+
const confirmation = await this.#preparePluginMethodConfirmation(request, signal);
|
|
1905
2606
|
const decision = await abortableConfirmationDecision(this.#confirm({
|
|
1906
2607
|
requestId: request.id,
|
|
1907
2608
|
method: request.method,
|
|
@@ -1929,47 +2630,60 @@ export class PluginSurfaceHost {
|
|
|
1929
2630
|
if (signal.aborted || this.#disposed)
|
|
1930
2631
|
return;
|
|
1931
2632
|
const bridgeError = toBridgeError(error, "PLUGIN_PERMISSION_DENIED");
|
|
1932
|
-
this.#postError(request.id, bridgeError.errorCode, bridgeError.message, bridgeError.details);
|
|
2633
|
+
this.#postError(request.id, bridgeError.errorCode, bridgeError.message, bridgeError.details, bridgeError.mutationOutcome);
|
|
1933
2634
|
}
|
|
1934
2635
|
}
|
|
1935
2636
|
async #handleStreamRead(id, streamHandle) {
|
|
1936
2637
|
const credential = this.#streamCredentials.get(streamHandle);
|
|
1937
|
-
if (!credential || credential.reading) {
|
|
1938
|
-
this.#postError(id, "PLUGIN_STREAM_TICKET_INVALID", "Plugin stream handle is invalid or
|
|
2638
|
+
if (!credential || credential.completed || credential.reading || credential.acknowledging) {
|
|
2639
|
+
this.#postError(id, "PLUGIN_STREAM_TICKET_INVALID", "Plugin stream handle is invalid, completed, or busy");
|
|
1939
2640
|
return;
|
|
1940
2641
|
}
|
|
1941
2642
|
if (credential.expiresAtMs <= Date.now()) {
|
|
1942
2643
|
this.#postError(id, "PLUGIN_STREAM_TICKET_INVALID", "Plugin stream handle is expired");
|
|
1943
2644
|
return;
|
|
1944
2645
|
}
|
|
2646
|
+
if (credential.pending) {
|
|
2647
|
+
this.#postResponse(id, credential.pending.response);
|
|
2648
|
+
return;
|
|
2649
|
+
}
|
|
1945
2650
|
credential.reading = true;
|
|
1946
2651
|
const controller = this.#registerPendingRequest(id);
|
|
1947
2652
|
try {
|
|
1948
|
-
const
|
|
1949
|
-
|
|
2653
|
+
const readPath = `/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/streams/read`;
|
|
2654
|
+
const readBody = () => ({ stream_id: credential.streamID, stream_ticket: credential.streamTicket, read_id: credential.readID });
|
|
2655
|
+
let result;
|
|
2656
|
+
try {
|
|
2657
|
+
result = await this.#postJSON(readPath, readBody, controller.signal);
|
|
2658
|
+
}
|
|
2659
|
+
catch (error) {
|
|
2660
|
+
if (!retryableStreamReadTransportFailure(error) || controller.signal.aborted || this.#disposed)
|
|
2661
|
+
throw error;
|
|
2662
|
+
result = await this.#postJSON(readPath, readBody, controller.signal);
|
|
2663
|
+
}
|
|
2664
|
+
if (!isStreamReadResult(result, credential.streamID, credential.readID, credential.lastSequence)) {
|
|
1950
2665
|
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin stream endpoint returned an invalid response");
|
|
1951
2666
|
}
|
|
1952
2667
|
const lastSequence = result.events.length > 0 ? result.events[result.events.length - 1].sequence : credential.lastSequence;
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
credential.
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
2668
|
+
const events = result.events.map(publicPluginStreamEvent);
|
|
2669
|
+
const response = result.done
|
|
2670
|
+
? { delivery_id: result.delivery_id, events, done: true, terminal_status: result.terminal_status, retry_after_ms: 0 }
|
|
2671
|
+
: { delivery_id: result.delivery_id, events, done: false, retry_after_ms: events.length === 0 ? 25 : 0 };
|
|
2672
|
+
if (result.delivery_id) {
|
|
2673
|
+
credential.pending = {
|
|
2674
|
+
deliveryID: result.delivery_id,
|
|
2675
|
+
lastSequence,
|
|
2676
|
+
done: result.done,
|
|
2677
|
+
response,
|
|
2678
|
+
};
|
|
1962
2679
|
}
|
|
2680
|
+
credential.reading = false;
|
|
1963
2681
|
if (!controller.signal.aborted && !this.#disposed)
|
|
1964
|
-
this.#postResponse(id,
|
|
1965
|
-
events: result.events.map(publicPluginStreamEvent),
|
|
1966
|
-
done: result.done,
|
|
1967
|
-
...(result.done ? { terminal_status: result.terminal_status } : {}),
|
|
1968
|
-
retry_after_ms: result.events.length === 0 && !result.done ? 25 : 0,
|
|
1969
|
-
});
|
|
2682
|
+
this.#postResponse(id, response);
|
|
1970
2683
|
}
|
|
1971
2684
|
catch (error) {
|
|
1972
|
-
|
|
2685
|
+
const bridgeError = toBridgeError(error, "PLUGIN_RUNTIME_UNAVAILABLE");
|
|
2686
|
+
if (streamReadFailureInvalidatesCredential(bridgeError)) {
|
|
1973
2687
|
this.#streamCredentials.delete(streamHandle);
|
|
1974
2688
|
}
|
|
1975
2689
|
else {
|
|
@@ -1977,17 +2691,62 @@ export class PluginSurfaceHost {
|
|
|
1977
2691
|
}
|
|
1978
2692
|
if (controller.signal.aborted || this.#disposed)
|
|
1979
2693
|
return;
|
|
1980
|
-
const bridgeError = toBridgeError(error, "PLUGIN_RUNTIME_UNAVAILABLE");
|
|
1981
2694
|
this.#postError(id, bridgeError.errorCode, bridgeError.message);
|
|
1982
2695
|
}
|
|
1983
2696
|
finally {
|
|
1984
2697
|
this.#pendingRequestControllers.delete(id);
|
|
1985
2698
|
}
|
|
1986
2699
|
}
|
|
2700
|
+
async #handleStreamAcknowledge(id, streamHandle, deliveryID) {
|
|
2701
|
+
const credential = this.#streamCredentials.get(streamHandle);
|
|
2702
|
+
if (!credential || credential.reading || credential.acknowledging) {
|
|
2703
|
+
this.#postError(id, "PLUGIN_STREAM_DELIVERY_INVALID", "Plugin stream delivery is invalid or busy", undefined, "not_committed");
|
|
2704
|
+
return;
|
|
2705
|
+
}
|
|
2706
|
+
if (credential.lastAcknowledgedDeliveryID === deliveryID) {
|
|
2707
|
+
this.#postResponse(id, undefined);
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
if (!credential.pending || credential.pending.deliveryID !== deliveryID) {
|
|
2711
|
+
this.#postError(id, "PLUGIN_STREAM_DELIVERY_INVALID", "Plugin stream delivery does not match the pending batch", undefined, "not_committed");
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
credential.acknowledging = true;
|
|
2715
|
+
const controller = this.#registerPendingRequest(id);
|
|
2716
|
+
try {
|
|
2717
|
+
const result = await this.#postMutationJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/streams/ack`, () => ({
|
|
2718
|
+
stream_id: credential.streamID,
|
|
2719
|
+
stream_ticket: credential.streamTicket,
|
|
2720
|
+
delivery_id: deliveryID,
|
|
2721
|
+
}), controller.signal);
|
|
2722
|
+
if (!hasExactKeys(result, ["acknowledged"]) || result.acknowledged !== true) {
|
|
2723
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin stream acknowledgement endpoint returned an invalid response", undefined, undefined, "unknown");
|
|
2724
|
+
}
|
|
2725
|
+
const pending = credential.pending;
|
|
2726
|
+
credential.lastAcknowledgedDeliveryID = deliveryID;
|
|
2727
|
+
credential.lastSequence = pending.lastSequence;
|
|
2728
|
+
credential.pending = undefined;
|
|
2729
|
+
credential.readID = randomOpaqueHandle("read");
|
|
2730
|
+
credential.completed = pending.done;
|
|
2731
|
+
credential.acknowledging = false;
|
|
2732
|
+
if (!controller.signal.aborted && !this.#disposed)
|
|
2733
|
+
this.#postResponse(id, undefined);
|
|
2734
|
+
}
|
|
2735
|
+
catch (error) {
|
|
2736
|
+
credential.acknowledging = false;
|
|
2737
|
+
if (controller.signal.aborted || this.#disposed)
|
|
2738
|
+
return;
|
|
2739
|
+
const bridgeError = toBridgeError(error, "PLUGIN_STREAM_DELIVERY_INVALID");
|
|
2740
|
+
this.#postError(id, bridgeError.errorCode, bridgeError.message, bridgeError.details, bridgeError.mutationOutcome);
|
|
2741
|
+
}
|
|
2742
|
+
finally {
|
|
2743
|
+
this.#pendingRequestControllers.delete(id);
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
1987
2746
|
async #handleOperationCancel(message) {
|
|
1988
2747
|
const controller = this.#registerPendingRequest(message.id);
|
|
1989
2748
|
try {
|
|
1990
|
-
await this.#
|
|
2749
|
+
await this.#postMutationJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/operations/cancel`, { operation_id: message.operation_id, bridge_channel_id: this.bridgeChannelId, reason: message.reason }, controller.signal);
|
|
1991
2750
|
this.#releaseOperationStreams(message.operation_id);
|
|
1992
2751
|
if (!controller.signal.aborted && !this.#disposed)
|
|
1993
2752
|
this.#postResponse(message.id, undefined);
|
|
@@ -1996,7 +2755,7 @@ export class PluginSurfaceHost {
|
|
|
1996
2755
|
if (controller.signal.aborted || this.#disposed)
|
|
1997
2756
|
return;
|
|
1998
2757
|
const bridgeError = toBridgeError(error, "PLUGIN_OPERATION_BLOCKED");
|
|
1999
|
-
this.#postError(message.id, bridgeError.errorCode, bridgeError.message);
|
|
2758
|
+
this.#postError(message.id, bridgeError.errorCode, bridgeError.message, bridgeError.details, bridgeError.mutationOutcome);
|
|
2000
2759
|
}
|
|
2001
2760
|
finally {
|
|
2002
2761
|
this.#pendingRequestControllers.delete(message.id);
|
|
@@ -2070,7 +2829,10 @@ export class PluginSurfaceHost {
|
|
|
2070
2829
|
streamTicket: result.stream_ticket,
|
|
2071
2830
|
expiresAtMs,
|
|
2072
2831
|
lastSequence: 0,
|
|
2832
|
+
readID: randomOpaqueHandle("read"),
|
|
2073
2833
|
reading: false,
|
|
2834
|
+
acknowledging: false,
|
|
2835
|
+
completed: false,
|
|
2074
2836
|
});
|
|
2075
2837
|
publicResult.stream_handle = handle;
|
|
2076
2838
|
}
|
|
@@ -2089,13 +2851,13 @@ export class PluginSurfaceHost {
|
|
|
2089
2851
|
}
|
|
2090
2852
|
}
|
|
2091
2853
|
#callRPC(request, confirmationID, signal) {
|
|
2092
|
-
return this.#
|
|
2854
|
+
return this.#postMutationJSON("/_redevplugin/api/plugins/rpc", () => this.#rpcBody(request, confirmationID), signal);
|
|
2093
2855
|
}
|
|
2094
|
-
#
|
|
2095
|
-
return this.#
|
|
2856
|
+
#preparePluginMethodConfirmation(request, signal) {
|
|
2857
|
+
return this.#postMutationJSON("/_redevplugin/api/plugins/confirmations/prepare", () => this.#rpcBody(request), signal);
|
|
2096
2858
|
}
|
|
2097
2859
|
async #rejectConfirmation(confirmationID, signal) {
|
|
2098
|
-
const result = await this.#
|
|
2860
|
+
const result = await this.#postMutationJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/confirmations/reject`, () => ({
|
|
2099
2861
|
plugin_instance_id: this.bootstrap.pluginInstanceId,
|
|
2100
2862
|
bridge_channel_id: this.bridgeChannelId,
|
|
2101
2863
|
plugin_gateway_token: this.#gatewayToken,
|
|
@@ -2119,7 +2881,7 @@ export class PluginSurfaceHost {
|
|
|
2119
2881
|
return body;
|
|
2120
2882
|
}
|
|
2121
2883
|
#prepareSurface() {
|
|
2122
|
-
return this.#
|
|
2884
|
+
return this.#postMutationJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/prepare`, { asset_ticket: this.bootstrap.assetTicket });
|
|
2123
2885
|
}
|
|
2124
2886
|
async #mintBridgeToken(previousGatewayToken, direct = false) {
|
|
2125
2887
|
const handshake = this.#handshake();
|
|
@@ -2131,7 +2893,7 @@ export class PluginSurfaceHost {
|
|
|
2131
2893
|
handshake_transcript_sha256: transcript,
|
|
2132
2894
|
...(previousGatewayToken ? { previous_plugin_gateway_token: previousGatewayToken } : {}),
|
|
2133
2895
|
};
|
|
2134
|
-
return direct ? this.#
|
|
2896
|
+
return direct ? this.#fetchMutationJSON(path, body) : this.#postMutationJSON(path, body);
|
|
2135
2897
|
}
|
|
2136
2898
|
#handshake() {
|
|
2137
2899
|
return {
|
|
@@ -2142,13 +2904,19 @@ export class PluginSurfaceHost {
|
|
|
2142
2904
|
active_fingerprint: this.bootstrap.activeFingerprint,
|
|
2143
2905
|
bridge_nonce: this.bootstrap.bridgeNonce,
|
|
2144
2906
|
asset_session_nonce: this.bootstrap.assetSessionNonce,
|
|
2145
|
-
|
|
2907
|
+
management_revision: this.bootstrap.managementRevision,
|
|
2146
2908
|
revoke_epoch: this.bootstrap.revokeEpoch,
|
|
2147
2909
|
ui_protocol_version: pluginUIProtocolVersion,
|
|
2148
2910
|
};
|
|
2149
2911
|
}
|
|
2150
2912
|
async #postJSON(path, body, signal) {
|
|
2151
|
-
|
|
2913
|
+
return this.#requestJSON(path, body, false, signal);
|
|
2914
|
+
}
|
|
2915
|
+
async #postMutationJSON(path, body, signal) {
|
|
2916
|
+
return this.#requestJSON(path, body, true, signal);
|
|
2917
|
+
}
|
|
2918
|
+
async #requestJSON(path, body, mutation, signal) {
|
|
2919
|
+
if (this.#bridgeReady) {
|
|
2152
2920
|
if (Date.now() >= this.#leaseRenewAtMs)
|
|
2153
2921
|
await this.#startLeaseRenewal();
|
|
2154
2922
|
else if (this.#leaseRenewalPromise)
|
|
@@ -2157,7 +2925,9 @@ export class PluginSurfaceHost {
|
|
|
2157
2925
|
const requestBody = typeof body === "function" ? body() : body;
|
|
2158
2926
|
this.#activeTransportRequests += 1;
|
|
2159
2927
|
try {
|
|
2160
|
-
return
|
|
2928
|
+
return mutation
|
|
2929
|
+
? await this.#fetchMutationJSON(path, requestBody, signal)
|
|
2930
|
+
: await this.#fetchJSON(path, requestBody, signal);
|
|
2161
2931
|
}
|
|
2162
2932
|
finally {
|
|
2163
2933
|
this.#activeTransportRequests -= 1;
|
|
@@ -2171,6 +2941,9 @@ export class PluginSurfaceHost {
|
|
|
2171
2941
|
async #fetchJSON(path, body, signal) {
|
|
2172
2942
|
return this.#fetchJSONRequest(path, body, { signal });
|
|
2173
2943
|
}
|
|
2944
|
+
async #fetchMutationJSON(path, body, signal) {
|
|
2945
|
+
return this.#fetchJSONRequest(path, body, { signal, mutation: true });
|
|
2946
|
+
}
|
|
2174
2947
|
async #fetchJSONRequest(path, body, options = {}) {
|
|
2175
2948
|
const controller = new AbortController();
|
|
2176
2949
|
let timedOut = false;
|
|
@@ -2187,7 +2960,7 @@ export class PluginSurfaceHost {
|
|
|
2187
2960
|
timer = setTimeout(() => {
|
|
2188
2961
|
timedOut = true;
|
|
2189
2962
|
controller.abort();
|
|
2190
|
-
reject(new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}
|
|
2963
|
+
reject(new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}`, undefined, undefined, options.mutation ? "unknown" : undefined));
|
|
2191
2964
|
}, this.#requestTimeoutMs);
|
|
2192
2965
|
});
|
|
2193
2966
|
try {
|
|
@@ -2202,12 +2975,16 @@ export class PluginSurfaceHost {
|
|
|
2202
2975
|
}),
|
|
2203
2976
|
timeout,
|
|
2204
2977
|
]);
|
|
2205
|
-
return await
|
|
2978
|
+
return options.mutation ? await readMutationPlatformResponse(response) : await readPlatformResponse(response);
|
|
2206
2979
|
}
|
|
2207
2980
|
catch (error) {
|
|
2208
|
-
if (timedOut)
|
|
2209
|
-
throw new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}
|
|
2210
|
-
|
|
2981
|
+
if (timedOut) {
|
|
2982
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}`, undefined, undefined, options.mutation ? "unknown" : undefined);
|
|
2983
|
+
}
|
|
2984
|
+
if (error instanceof PluginBridgeError || error instanceof PluginPlatformRequestError || error instanceof PluginTransportError) {
|
|
2985
|
+
throw error;
|
|
2986
|
+
}
|
|
2987
|
+
throw new PluginTransportError(`Plugin surface request failed for POST ${path}`, error, options.mutation ? "unknown" : undefined);
|
|
2211
2988
|
}
|
|
2212
2989
|
finally {
|
|
2213
2990
|
if (timer)
|
|
@@ -2245,7 +3022,7 @@ export class PluginSurfaceHost {
|
|
|
2245
3022
|
}
|
|
2246
3023
|
#startLeaseRenewal() {
|
|
2247
3024
|
this.#assertActive();
|
|
2248
|
-
if (!this.#
|
|
3025
|
+
if (!this.#bridgeReady || !this.#gatewayToken) {
|
|
2249
3026
|
return Promise.reject(new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "Plugin surface lease cannot renew before readiness"));
|
|
2250
3027
|
}
|
|
2251
3028
|
if (this.#leaseRenewalPromise)
|
|
@@ -2279,28 +3056,21 @@ export class PluginSurfaceHost {
|
|
|
2279
3056
|
#revokeSurface(keepalive) {
|
|
2280
3057
|
if (this.#revokePromise)
|
|
2281
3058
|
return this.#revokePromise;
|
|
2282
|
-
this.#revokePromise = (
|
|
2283
|
-
await this.#fetchJSONRequest(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/dispose`, { bridge_nonce: this.bootstrap.bridgeNonce }, { keepalive, independentLifecycle: true });
|
|
2284
|
-
})();
|
|
3059
|
+
this.#revokePromise = revokeSurfaceBootstrap(this.#transport, this.bootstrap, this.#requestTimeoutMs, keepalive);
|
|
2285
3060
|
return this.#revokePromise;
|
|
2286
3061
|
}
|
|
2287
|
-
async #bestEffortRevokeSurface(keepalive) {
|
|
2288
|
-
try {
|
|
2289
|
-
await this.#revokeSurface(keepalive);
|
|
2290
|
-
}
|
|
2291
|
-
catch {
|
|
2292
|
-
// The server may already have revoked the surface after a failed prepare.
|
|
2293
|
-
}
|
|
2294
|
-
}
|
|
2295
3062
|
async #quiesceSurface() {
|
|
2296
|
-
|
|
2297
|
-
|
|
3063
|
+
const startedAt = performance.now();
|
|
3064
|
+
if (!this.#ready || !this.#port || this.#quiesce) {
|
|
3065
|
+
return { outcome: "not_ready", durationMs: performance.now() - startedAt };
|
|
3066
|
+
}
|
|
2298
3067
|
const quiesce = {
|
|
2299
3068
|
id: randomOpaqueHandle("quiesce"),
|
|
2300
3069
|
acknowledged: deferred(),
|
|
2301
3070
|
};
|
|
2302
3071
|
void quiesce.acknowledged.promise.catch(() => undefined);
|
|
2303
3072
|
this.#quiesce = quiesce;
|
|
3073
|
+
this.#ready = false;
|
|
2304
3074
|
try {
|
|
2305
3075
|
this.#postToRenderer({
|
|
2306
3076
|
type: "redevplugin.bridge.lifecycle",
|
|
@@ -2308,9 +3078,12 @@ export class PluginSurfaceHost {
|
|
|
2308
3078
|
quiesce_id: quiesce.id,
|
|
2309
3079
|
});
|
|
2310
3080
|
await withTimeout(quiesce.acknowledged.promise, Math.min(this.#requestTimeoutMs, maxSurfaceQuiesceMs), "Plugin surface quiesce timed out");
|
|
3081
|
+
return { outcome: "acknowledged", durationMs: performance.now() - startedAt };
|
|
2311
3082
|
}
|
|
2312
3083
|
catch {
|
|
2313
|
-
|
|
3084
|
+
const error = new PluginBridgeError("PLUGIN_SURFACE_QUIESCE_TIMEOUT", "Plugin surface quiesce timed out");
|
|
3085
|
+
this.#reportError(error);
|
|
3086
|
+
return { outcome: "timed_out", durationMs: performance.now() - startedAt };
|
|
2314
3087
|
}
|
|
2315
3088
|
finally {
|
|
2316
3089
|
if (this.#quiesce === quiesce)
|
|
@@ -2321,10 +3094,11 @@ export class PluginSurfaceHost {
|
|
|
2321
3094
|
if (this.#disposed)
|
|
2322
3095
|
return;
|
|
2323
3096
|
this.#ready = false;
|
|
3097
|
+
this.#bridgeReady = false;
|
|
2324
3098
|
this.#openSignals?.firstPaint.reject(error);
|
|
2325
3099
|
this.#openSignals?.workerReady.reject(error);
|
|
2326
3100
|
this.#reportError(error);
|
|
2327
|
-
const revoke = this.#
|
|
3101
|
+
const revoke = this.#revokeSurface(true);
|
|
2328
3102
|
this.#disposeLocal();
|
|
2329
3103
|
await revoke;
|
|
2330
3104
|
}
|
|
@@ -2336,16 +3110,29 @@ export class PluginSurfaceHost {
|
|
|
2336
3110
|
}
|
|
2337
3111
|
this.#postToRenderer(response);
|
|
2338
3112
|
}
|
|
2339
|
-
#postError(id, errorCode, error, details) {
|
|
3113
|
+
#postError(id, errorCode, error, details, mutationOutcome) {
|
|
2340
3114
|
const errorDetails = details === undefined ? undefined : normalizePluginJSONObject(details);
|
|
2341
|
-
|
|
3115
|
+
const response = removeUndefined({
|
|
2342
3116
|
type: "redevplugin.bridge.response",
|
|
2343
3117
|
id,
|
|
2344
3118
|
ok: false,
|
|
2345
3119
|
error_code: errorCode,
|
|
2346
3120
|
error,
|
|
2347
3121
|
error_details: errorDetails,
|
|
2348
|
-
|
|
3122
|
+
mutation_outcome: mutationOutcome,
|
|
3123
|
+
});
|
|
3124
|
+
if (!messageWithinLimit(response)) {
|
|
3125
|
+
this.#postToRenderer(removeUndefined({
|
|
3126
|
+
type: "redevplugin.bridge.response",
|
|
3127
|
+
id,
|
|
3128
|
+
ok: false,
|
|
3129
|
+
error_code: "PLUGIN_JSON_LIMIT_EXCEEDED",
|
|
3130
|
+
error: "Plugin error response exceeds the bridge message limit",
|
|
3131
|
+
mutation_outcome: mutationOutcome,
|
|
3132
|
+
}));
|
|
3133
|
+
return;
|
|
3134
|
+
}
|
|
3135
|
+
this.#postToRenderer(response);
|
|
2349
3136
|
}
|
|
2350
3137
|
#postToRenderer(message) {
|
|
2351
3138
|
if (!this.#port) {
|
|
@@ -2361,6 +3148,14 @@ export class PluginSurfaceHost {
|
|
|
2361
3148
|
// Observers cannot weaken revocation or local teardown invariants.
|
|
2362
3149
|
}
|
|
2363
3150
|
}
|
|
3151
|
+
#reportOpeningProgress(progress) {
|
|
3152
|
+
try {
|
|
3153
|
+
this.#onOpeningProgress?.(progress);
|
|
3154
|
+
}
|
|
3155
|
+
catch {
|
|
3156
|
+
// Observers cannot weaken opening, revocation, or local teardown invariants.
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
2364
3159
|
#assertReady() {
|
|
2365
3160
|
this.#assertActive();
|
|
2366
3161
|
if (!this.#ready || !this.#port) {
|
|
@@ -2373,6 +3168,376 @@ export class PluginSurfaceHost {
|
|
|
2373
3168
|
}
|
|
2374
3169
|
}
|
|
2375
3170
|
}
|
|
3171
|
+
export function createPreparedPluginSurfaceHost(options) {
|
|
3172
|
+
return createPluginSurfaceHostImplementation(options);
|
|
3173
|
+
}
|
|
3174
|
+
function createPluginSurfaceHostImplementation(options) {
|
|
3175
|
+
validateHostBootstrap(options.bootstrap);
|
|
3176
|
+
const transport = requireSurfaceTransportInternals(options.hostTransport);
|
|
3177
|
+
return new PluginSurfaceHostImplementation(options, transport, createPluginSurfaceFrame());
|
|
3178
|
+
}
|
|
3179
|
+
const surfaceSlotInternals = new WeakMap();
|
|
3180
|
+
class PluginSurfaceCleanupError extends Error {
|
|
3181
|
+
cause;
|
|
3182
|
+
constructor(cause) {
|
|
3183
|
+
super("Plugin surface cleanup failed", { cause });
|
|
3184
|
+
this.name = "PluginSurfaceCleanupError";
|
|
3185
|
+
this.cause = cause;
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
export function openPluginSurfaceInSlot(slot, request) {
|
|
3189
|
+
const internals = surfaceSlotInternals.get(slot);
|
|
3190
|
+
if (!internals)
|
|
3191
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin surface slot is invalid");
|
|
3192
|
+
return internals.open(request);
|
|
3193
|
+
}
|
|
3194
|
+
export function openPreparedPluginSurfaceInSlot(slot, options) {
|
|
3195
|
+
const internals = surfaceSlotInternals.get(slot);
|
|
3196
|
+
if (!internals)
|
|
3197
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin surface slot is invalid");
|
|
3198
|
+
return internals.openPrepared(options);
|
|
3199
|
+
}
|
|
3200
|
+
function createPluginSurfaceOpeningLease(request) {
|
|
3201
|
+
let unregister = () => undefined;
|
|
3202
|
+
let revokePromise;
|
|
3203
|
+
let removeAbortListener = () => undefined;
|
|
3204
|
+
const lease = {
|
|
3205
|
+
options: Promise.resolve(undefined),
|
|
3206
|
+
pluginInstanceId: request.pluginInstanceId,
|
|
3207
|
+
signal: request.signal,
|
|
3208
|
+
abortError: request.abortError,
|
|
3209
|
+
owner: "opening",
|
|
3210
|
+
cancelled: false,
|
|
3211
|
+
adopt() {
|
|
3212
|
+
if (lease.owner !== "opening" || lease.cancelled) {
|
|
3213
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin surface opening was cancelled before adoption");
|
|
3214
|
+
}
|
|
3215
|
+
lease.owner = "host";
|
|
3216
|
+
unregister();
|
|
3217
|
+
removeAbortListener();
|
|
3218
|
+
},
|
|
3219
|
+
cancel() {
|
|
3220
|
+
lease.cancelled = true;
|
|
3221
|
+
if (lease.owner === "host")
|
|
3222
|
+
return Promise.resolve();
|
|
3223
|
+
return lease.revoke();
|
|
3224
|
+
},
|
|
3225
|
+
revoke() {
|
|
3226
|
+
if (lease.owner === "host") {
|
|
3227
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface ownership was already adopted");
|
|
3228
|
+
}
|
|
3229
|
+
lease.cancelled = true;
|
|
3230
|
+
lease.owner = "revoked";
|
|
3231
|
+
unregister();
|
|
3232
|
+
removeAbortListener();
|
|
3233
|
+
revokePromise ??= lease.options.then(async (options) => {
|
|
3234
|
+
await revokeSurfaceBootstrap(requireSurfaceTransportInternals(options.hostTransport), options.bootstrap, normalizeTimeout(options.requestTimeoutMs), false);
|
|
3235
|
+
}, () => undefined);
|
|
3236
|
+
return revokePromise;
|
|
3237
|
+
},
|
|
3238
|
+
};
|
|
3239
|
+
unregister = registerPluginSurface(request.surfaceScope, request.pluginInstanceId, () => lease.cancel());
|
|
3240
|
+
try {
|
|
3241
|
+
lease.options = Promise.resolve(request.open());
|
|
3242
|
+
}
|
|
3243
|
+
catch (error) {
|
|
3244
|
+
lease.options = Promise.reject(error);
|
|
3245
|
+
}
|
|
3246
|
+
void lease.options.catch(() => {
|
|
3247
|
+
if (lease.owner !== "opening")
|
|
3248
|
+
return;
|
|
3249
|
+
lease.owner = "revoked";
|
|
3250
|
+
unregister();
|
|
3251
|
+
removeAbortListener();
|
|
3252
|
+
});
|
|
3253
|
+
if (request.signal) {
|
|
3254
|
+
const onAbort = () => {
|
|
3255
|
+
void lease.cancel().catch(() => undefined);
|
|
3256
|
+
};
|
|
3257
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
3258
|
+
removeAbortListener = () => request.signal?.removeEventListener("abort", onAbort);
|
|
3259
|
+
if (request.signal.aborted)
|
|
3260
|
+
onAbort();
|
|
3261
|
+
}
|
|
3262
|
+
return lease;
|
|
3263
|
+
}
|
|
3264
|
+
export class PluginSurfaceSlot {
|
|
3265
|
+
element;
|
|
3266
|
+
#onStateChange;
|
|
3267
|
+
#onSurfaceClosed;
|
|
3268
|
+
#active;
|
|
3269
|
+
#opening;
|
|
3270
|
+
#transitionController;
|
|
3271
|
+
#tail = Promise.resolve();
|
|
3272
|
+
#disposed = false;
|
|
3273
|
+
#retired = new WeakMap();
|
|
3274
|
+
#disposePromise;
|
|
3275
|
+
static create(options) {
|
|
3276
|
+
if (!options.stage || typeof options.stage.append !== "function" || !options.stage.dataset) {
|
|
3277
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin surface slot requires an HTML stage");
|
|
3278
|
+
}
|
|
3279
|
+
return new PluginSurfaceSlot(options);
|
|
3280
|
+
}
|
|
3281
|
+
constructor(options) {
|
|
3282
|
+
this.element = options.stage;
|
|
3283
|
+
this.#onStateChange = options.onStateChange;
|
|
3284
|
+
this.#onSurfaceClosed = options.onSurfaceClosed;
|
|
3285
|
+
surfaceSlotInternals.set(this, {
|
|
3286
|
+
open: (request) => {
|
|
3287
|
+
this.#assertActive();
|
|
3288
|
+
return this.#openLease(createPluginSurfaceOpeningLease(request));
|
|
3289
|
+
},
|
|
3290
|
+
openPrepared: (preparedOptions) => this.#queueOpening(Promise.resolve(preparedOptions)),
|
|
3291
|
+
});
|
|
3292
|
+
this.#setState("empty");
|
|
3293
|
+
}
|
|
3294
|
+
#openLease(lease) {
|
|
3295
|
+
return this.#queueOpening(lease.options, lease);
|
|
3296
|
+
}
|
|
3297
|
+
#queueOpening(resolvedOptions, lease) {
|
|
3298
|
+
this.#assertActive();
|
|
3299
|
+
void resolvedOptions.catch(() => undefined);
|
|
3300
|
+
this.#transitionController?.abort();
|
|
3301
|
+
const controller = new AbortController();
|
|
3302
|
+
this.#transitionController = controller;
|
|
3303
|
+
this.#setState("opening");
|
|
3304
|
+
const transition = this.#tail.then(() => this.#openSurface(resolvedOptions, controller, lease), async (error) => {
|
|
3305
|
+
await this.#revokeOpening(lease);
|
|
3306
|
+
throw error;
|
|
3307
|
+
});
|
|
3308
|
+
this.#tail = transition.then(() => undefined, (error) => error instanceof PluginSurfaceCleanupError ? Promise.reject(error) : undefined);
|
|
3309
|
+
void this.#tail.catch(() => undefined);
|
|
3310
|
+
return transition.catch((error) => {
|
|
3311
|
+
if (error instanceof PluginSurfaceCleanupError)
|
|
3312
|
+
throw error.cause;
|
|
3313
|
+
throw error;
|
|
3314
|
+
});
|
|
3315
|
+
}
|
|
3316
|
+
async #openSurface(options, controller, lease) {
|
|
3317
|
+
let resolvedOptions;
|
|
3318
|
+
try {
|
|
3319
|
+
resolvedOptions = await options;
|
|
3320
|
+
}
|
|
3321
|
+
catch (error) {
|
|
3322
|
+
if (!this.#disposed && this.#transitionController === controller && !controller.signal.aborted) {
|
|
3323
|
+
this.#setState("error", toBridgeError(error, "PLUGIN_BRIDGE_HANDSHAKE_FAILED"));
|
|
3324
|
+
}
|
|
3325
|
+
throw error;
|
|
3326
|
+
}
|
|
3327
|
+
if (lease && resolvedOptions.bootstrap.pluginInstanceId !== lease.pluginInstanceId) {
|
|
3328
|
+
await this.#revokeOpening(lease);
|
|
3329
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface bootstrap identity does not match the opening lease");
|
|
3330
|
+
}
|
|
3331
|
+
if (this.#openingCancelled(controller, lease)) {
|
|
3332
|
+
await this.#revokeOpening(lease);
|
|
3333
|
+
this.#setCancelledState(controller);
|
|
3334
|
+
throw this.#openingCancellationError(lease);
|
|
3335
|
+
}
|
|
3336
|
+
const previous = this.#active;
|
|
3337
|
+
this.#active = undefined;
|
|
3338
|
+
if (previous) {
|
|
3339
|
+
try {
|
|
3340
|
+
await this.#awaitRetirement(previous);
|
|
3341
|
+
}
|
|
3342
|
+
catch (error) {
|
|
3343
|
+
await this.#revokeOpening(lease);
|
|
3344
|
+
throw error;
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
if (this.#openingCancelled(controller, lease)) {
|
|
3348
|
+
await this.#revokeOpening(lease);
|
|
3349
|
+
this.#setCancelledState(controller);
|
|
3350
|
+
throw this.#openingCancellationError(lease);
|
|
3351
|
+
}
|
|
3352
|
+
let host;
|
|
3353
|
+
try {
|
|
3354
|
+
host = createPluginSurfaceHostImplementation(resolvedOptions);
|
|
3355
|
+
}
|
|
3356
|
+
catch (error) {
|
|
3357
|
+
await this.#revokeOpening(lease);
|
|
3358
|
+
if (!this.#disposed && this.#transitionController === controller) {
|
|
3359
|
+
this.#setState("error", toBridgeError(error, "PLUGIN_BRIDGE_HANDSHAKE_FAILED"));
|
|
3360
|
+
}
|
|
3361
|
+
throw error;
|
|
3362
|
+
}
|
|
3363
|
+
this.#adoptOpening(lease);
|
|
3364
|
+
this.#opening = host;
|
|
3365
|
+
setSurfaceInteractive(host.element, false);
|
|
3366
|
+
this.element.append(host.element);
|
|
3367
|
+
try {
|
|
3368
|
+
await this.#openHostUntilCancelled(host, controller, lease);
|
|
3369
|
+
if (this.#openingCancelled(controller, lease) || this.#opening !== host) {
|
|
3370
|
+
await this.#awaitRetirement(host);
|
|
3371
|
+
this.#setCancelledState(controller);
|
|
3372
|
+
throw this.#openingCancellationError(lease);
|
|
3373
|
+
}
|
|
3374
|
+
this.#opening = undefined;
|
|
3375
|
+
this.#active = host;
|
|
3376
|
+
setSurfaceInteractive(host.element, true);
|
|
3377
|
+
this.#setState("ready");
|
|
3378
|
+
return host;
|
|
3379
|
+
}
|
|
3380
|
+
catch (error) {
|
|
3381
|
+
await this.#awaitRetirement(host);
|
|
3382
|
+
if (!this.#disposed && this.#transitionController === controller && !this.#openingCancelled(controller, lease)) {
|
|
3383
|
+
const bridgeError = toBridgeError(error, "PLUGIN_BRIDGE_HANDSHAKE_FAILED");
|
|
3384
|
+
this.#setState("error", bridgeError);
|
|
3385
|
+
}
|
|
3386
|
+
throw error;
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
async close() {
|
|
3390
|
+
this.#assertActive();
|
|
3391
|
+
this.#transitionController?.abort();
|
|
3392
|
+
this.#transitionController = undefined;
|
|
3393
|
+
this.#setState("empty");
|
|
3394
|
+
const closing = this.#tail.then(() => this.#closeCurrentSurface());
|
|
3395
|
+
this.#tail = closing.then(() => undefined, (error) => error instanceof PluginSurfaceCleanupError ? Promise.reject(error) : undefined);
|
|
3396
|
+
void this.#tail.catch(() => undefined);
|
|
3397
|
+
return this.#unwrapCleanupError(closing);
|
|
3398
|
+
}
|
|
3399
|
+
dispose() {
|
|
3400
|
+
if (this.#disposePromise)
|
|
3401
|
+
return this.#disposePromise;
|
|
3402
|
+
this.#disposed = true;
|
|
3403
|
+
this.#transitionController?.abort();
|
|
3404
|
+
this.#transitionController = undefined;
|
|
3405
|
+
this.#setState("disposed");
|
|
3406
|
+
const disposal = this.#tail.then(async () => {
|
|
3407
|
+
await this.#closeCurrentSurface();
|
|
3408
|
+
});
|
|
3409
|
+
this.#disposePromise = this.#unwrapCleanupError(disposal);
|
|
3410
|
+
void this.#disposePromise.catch(() => undefined);
|
|
3411
|
+
this.#tail = disposal.then(() => undefined, (error) => error instanceof PluginSurfaceCleanupError ? Promise.reject(error) : undefined);
|
|
3412
|
+
void this.#tail.catch(() => undefined);
|
|
3413
|
+
return this.#disposePromise;
|
|
3414
|
+
}
|
|
3415
|
+
async #closeCurrentSurface() {
|
|
3416
|
+
const hosts = new Set([this.#active, this.#opening].filter((host) => host !== undefined));
|
|
3417
|
+
this.#active = undefined;
|
|
3418
|
+
this.#opening = undefined;
|
|
3419
|
+
let result;
|
|
3420
|
+
const failures = [];
|
|
3421
|
+
for (const host of hosts) {
|
|
3422
|
+
try {
|
|
3423
|
+
const closed = await this.#retire(host);
|
|
3424
|
+
result ??= closed;
|
|
3425
|
+
}
|
|
3426
|
+
catch (error) {
|
|
3427
|
+
failures.push(error);
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
if (failures.length === 1)
|
|
3431
|
+
throw failures[0];
|
|
3432
|
+
if (failures.length > 1)
|
|
3433
|
+
throw new AggregateError(failures, "Plugin surface slot cleanup failed");
|
|
3434
|
+
return result;
|
|
3435
|
+
}
|
|
3436
|
+
#retire(host) {
|
|
3437
|
+
const existing = this.#retired.get(host);
|
|
3438
|
+
if (existing)
|
|
3439
|
+
return existing;
|
|
3440
|
+
setSurfaceInteractive(host.element, false);
|
|
3441
|
+
const closing = Promise.resolve().then(async () => {
|
|
3442
|
+
try {
|
|
3443
|
+
const result = await host.close();
|
|
3444
|
+
try {
|
|
3445
|
+
this.#onSurfaceClosed?.(result);
|
|
3446
|
+
}
|
|
3447
|
+
catch {
|
|
3448
|
+
// Observers cannot turn a completed revocation into a cleanup failure.
|
|
3449
|
+
}
|
|
3450
|
+
return result;
|
|
3451
|
+
}
|
|
3452
|
+
finally {
|
|
3453
|
+
host.element.remove();
|
|
3454
|
+
}
|
|
3455
|
+
});
|
|
3456
|
+
this.#retired.set(host, closing);
|
|
3457
|
+
return closing;
|
|
3458
|
+
}
|
|
3459
|
+
async #awaitRetirement(host) {
|
|
3460
|
+
try {
|
|
3461
|
+
return await this.#retire(host);
|
|
3462
|
+
}
|
|
3463
|
+
catch (error) {
|
|
3464
|
+
throw new PluginSurfaceCleanupError(error);
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
async #openHostUntilCancelled(host, controller, lease) {
|
|
3468
|
+
const signals = [controller.signal, lease?.signal].filter((signal) => signal !== undefined);
|
|
3469
|
+
let cancel;
|
|
3470
|
+
const cancelled = new Promise((_resolve, reject) => {
|
|
3471
|
+
cancel = () => {
|
|
3472
|
+
void this.#awaitRetirement(host).then(() => reject(this.#openingCancellationError(lease)), reject);
|
|
3473
|
+
};
|
|
3474
|
+
for (const signal of signals)
|
|
3475
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
3476
|
+
if (signals.some((signal) => signal.aborted))
|
|
3477
|
+
cancel();
|
|
3478
|
+
});
|
|
3479
|
+
try {
|
|
3480
|
+
await Promise.race([host.open(), cancelled]);
|
|
3481
|
+
}
|
|
3482
|
+
finally {
|
|
3483
|
+
for (const signal of signals)
|
|
3484
|
+
signal.removeEventListener("abort", cancel);
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
#openingCancelled(controller, lease) {
|
|
3488
|
+
return this.#disposed || controller.signal.aborted || lease?.cancelled === true || lease?.signal?.aborted === true;
|
|
3489
|
+
}
|
|
3490
|
+
#openingCancellationError(lease) {
|
|
3491
|
+
if (lease?.signal?.aborted)
|
|
3492
|
+
return lease.abortError();
|
|
3493
|
+
return new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin surface opening was superseded");
|
|
3494
|
+
}
|
|
3495
|
+
#setCancelledState(controller) {
|
|
3496
|
+
if (!this.#disposed && this.#transitionController === controller)
|
|
3497
|
+
this.#setState("empty");
|
|
3498
|
+
}
|
|
3499
|
+
#adoptOpening(lease) {
|
|
3500
|
+
lease?.adopt();
|
|
3501
|
+
}
|
|
3502
|
+
async #revokeOpening(lease) {
|
|
3503
|
+
if (!lease || lease.owner === "host")
|
|
3504
|
+
return;
|
|
3505
|
+
try {
|
|
3506
|
+
await lease.revoke();
|
|
3507
|
+
}
|
|
3508
|
+
catch (error) {
|
|
3509
|
+
throw new PluginSurfaceCleanupError(error);
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
async #unwrapCleanupError(result) {
|
|
3513
|
+
try {
|
|
3514
|
+
return await result;
|
|
3515
|
+
}
|
|
3516
|
+
catch (error) {
|
|
3517
|
+
if (error instanceof PluginSurfaceCleanupError)
|
|
3518
|
+
throw error.cause;
|
|
3519
|
+
throw error;
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
#setState(state, error) {
|
|
3523
|
+
this.element.dataset.redevpluginSurfaceState = state;
|
|
3524
|
+
try {
|
|
3525
|
+
this.#onStateChange?.(state, error);
|
|
3526
|
+
}
|
|
3527
|
+
catch {
|
|
3528
|
+
// Observers cannot alter the slot lifecycle state machine.
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
#assertActive() {
|
|
3532
|
+
if (this.#disposed)
|
|
3533
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin surface slot is disposed");
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
function setSurfaceInteractive(element, interactive) {
|
|
3537
|
+
element.hidden = !interactive;
|
|
3538
|
+
element.inert = !interactive;
|
|
3539
|
+
element.setAttribute("aria-hidden", interactive ? "false" : "true");
|
|
3540
|
+
}
|
|
2376
3541
|
function claimOpaquePluginBridge() {
|
|
2377
3542
|
const value = globalThis[opaquePluginBridgeGlobalKey];
|
|
2378
3543
|
if (!isRecord(value) || typeof value.claim !== "function") {
|
|
@@ -2478,7 +3643,7 @@ function validateHostBootstrap(bootstrap) {
|
|
|
2478
3643
|
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface bootstrap is incomplete");
|
|
2479
3644
|
}
|
|
2480
3645
|
}
|
|
2481
|
-
if (!Number.isSafeInteger(bootstrap.
|
|
3646
|
+
if (!Number.isSafeInteger(bootstrap.managementRevision) || bootstrap.managementRevision < 1 ||
|
|
2482
3647
|
!Number.isSafeInteger(bootstrap.revokeEpoch) || bootstrap.revokeEpoch < 1) {
|
|
2483
3648
|
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface revision is invalid");
|
|
2484
3649
|
}
|
|
@@ -2487,7 +3652,7 @@ function validateSurfacePreparation(bootstrap, preparation) {
|
|
|
2487
3652
|
if (!isSurfacePreparationResult(preparation)) {
|
|
2488
3653
|
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface prepare returned an invalid opaque document");
|
|
2489
3654
|
}
|
|
2490
|
-
if (preparation.
|
|
3655
|
+
if (preparation.management_revision !== bootstrap.managementRevision || preparation.revoke_epoch !== bootstrap.revokeEpoch) {
|
|
2491
3656
|
throw new PluginBridgeError("PLUGIN_GATEWAY_TOKEN_INVALID", "Plugin surface state changed during prepare");
|
|
2492
3657
|
}
|
|
2493
3658
|
if (preparation.asset_session_nonce !== bootstrap.assetSessionNonce) {
|
|
@@ -2505,7 +3670,7 @@ function isSurfacePreparationResult(value) {
|
|
|
2505
3670
|
"asset_session_nonce",
|
|
2506
3671
|
"entry_path",
|
|
2507
3672
|
"entry_sha256",
|
|
2508
|
-
"
|
|
3673
|
+
"management_revision",
|
|
2509
3674
|
"revoke_epoch",
|
|
2510
3675
|
"issued_at",
|
|
2511
3676
|
"expires_at",
|
|
@@ -2519,7 +3684,7 @@ function isSurfacePreparationResult(value) {
|
|
|
2519
3684
|
typeof value.asset_session_nonce === "string" && value.asset_session_nonce.length > 0 &&
|
|
2520
3685
|
validPackagePath(value.entry_path) &&
|
|
2521
3686
|
validSHA256(value.entry_sha256) &&
|
|
2522
|
-
Number.isSafeInteger(value.
|
|
3687
|
+
Number.isSafeInteger(value.management_revision) && Number(value.management_revision) >= 1 &&
|
|
2523
3688
|
Number.isSafeInteger(value.revoke_epoch) && Number(value.revoke_epoch) >= 1 &&
|
|
2524
3689
|
Number.isFinite(issuedAt) && Number.isFinite(expiresAt) && expiresAt > issuedAt &&
|
|
2525
3690
|
isOpaqueSurfaceDocument(value.document);
|
|
@@ -2585,6 +3750,13 @@ function isStreamReadMessage(value) {
|
|
|
2585
3750
|
validBridgeRequestID(value.id, "stream") &&
|
|
2586
3751
|
validOpaqueHandle(value.stream_handle, "stream");
|
|
2587
3752
|
}
|
|
3753
|
+
function isStreamAcknowledgeMessage(value) {
|
|
3754
|
+
return hasExactKeys(value, ["type", "id", "stream_handle", "delivery_id"]) &&
|
|
3755
|
+
value.type === "redevplugin.bridge.stream.ack" &&
|
|
3756
|
+
validBridgeRequestID(value.id, "stream_ack") &&
|
|
3757
|
+
validOpaqueHandle(value.stream_handle, "stream") &&
|
|
3758
|
+
validDeliveryID(value.delivery_id);
|
|
3759
|
+
}
|
|
2588
3760
|
function isOperationCancelMessage(value) {
|
|
2589
3761
|
return hasAllowedKeys(value, ["type", "id", "operation_id", "reason"]) &&
|
|
2590
3762
|
value.type === "redevplugin.bridge.operation.cancel" &&
|
|
@@ -2615,7 +3787,8 @@ function isBridgeResponse(value) {
|
|
|
2615
3787
|
return Object.keys(value).every((key) => ["type", "id", "ok", "data"].includes(key));
|
|
2616
3788
|
if (value.ok !== false || typeof value.error_code !== "string" || !pluginBridgeErrorCodeSet.has(value.error_code) ||
|
|
2617
3789
|
typeof value.error !== "string" || value.error.length > 4096 ||
|
|
2618
|
-
!Object.keys(value).every((key) => ["type", "id", "ok", "error_code", "error", "error_details"].includes(key))
|
|
3790
|
+
!Object.keys(value).every((key) => ["type", "id", "ok", "error_code", "error", "error_details", "mutation_outcome"].includes(key)) ||
|
|
3791
|
+
(value.mutation_outcome !== undefined && value.mutation_outcome !== "not_committed" && value.mutation_outcome !== "unknown")) {
|
|
2619
3792
|
return false;
|
|
2620
3793
|
}
|
|
2621
3794
|
if (value.error_code === "PLUGIN_CAPABILITY_ERROR")
|
|
@@ -2675,14 +3848,28 @@ function isLifecycleMessage(value) {
|
|
|
2675
3848
|
(value.quiesce_id === undefined || (value.event.type === "dispose" && validOpaqueHandle(value.quiesce_id, "quiesce")));
|
|
2676
3849
|
}
|
|
2677
3850
|
function isActionMessage(value) {
|
|
2678
|
-
return hasAllowedKeys(value, ["type", "action", "event", "value", "checked", "form_data"]) &&
|
|
3851
|
+
return hasAllowedKeys(value, ["type", "action", "event", "target_key", "edit_revision", "is_composing", "value", "checked", "form_data"]) &&
|
|
2679
3852
|
value.type === "redevplugin.ui.action" &&
|
|
2680
3853
|
validActionID(value.action) &&
|
|
2681
3854
|
(value.event === "click" || value.event === "input" || value.event === "change" || value.event === "submit" || value.event === "escape") &&
|
|
3855
|
+
validUIIdentifier(value.target_key) && Number.isSafeInteger(value.edit_revision) && Number(value.edit_revision) >= 0 &&
|
|
3856
|
+
typeof value.is_composing === "boolean" &&
|
|
2682
3857
|
(value.value == null || (typeof value.value === "string" && value.value.length <= 65536)) &&
|
|
2683
3858
|
(value.checked == null || typeof value.checked === "boolean") &&
|
|
2684
3859
|
(value.form_data == null || (isRecord(value.form_data) && Object.entries(value.form_data).every(([key, item]) => validActionID(key) && typeof item === "string" && item.length <= 65536)));
|
|
2685
3860
|
}
|
|
3861
|
+
function publicActionEvent(value) {
|
|
3862
|
+
return removeUndefined({
|
|
3863
|
+
action: value.action,
|
|
3864
|
+
event: value.event,
|
|
3865
|
+
targetKey: value.target_key,
|
|
3866
|
+
editRevision: value.edit_revision,
|
|
3867
|
+
isComposing: value.is_composing,
|
|
3868
|
+
value: value.value,
|
|
3869
|
+
checked: value.checked,
|
|
3870
|
+
form_data: value.form_data,
|
|
3871
|
+
});
|
|
3872
|
+
}
|
|
2686
3873
|
function isCanvasReadyCandidate(value) {
|
|
2687
3874
|
return isRecord(value) && value.type === "redevplugin.ui.canvas.ready" && typeof value.id === "string" && typeof value.canvas_id === "string";
|
|
2688
3875
|
}
|
|
@@ -2801,25 +3988,23 @@ function isGatewayTokenResult(value) {
|
|
|
2801
3988
|
typeof value.asset_session_id === "string" && value.asset_session_id.length > 0 &&
|
|
2802
3989
|
typeof value.issued_at === "string" && typeof value.expires_at === "string";
|
|
2803
3990
|
}
|
|
2804
|
-
function isStreamReadResult(value, expectedStreamID, previousSequence) {
|
|
3991
|
+
function isStreamReadResult(value, expectedStreamID, expectedReadID, previousSequence) {
|
|
2805
3992
|
if (!isRecord(value) || typeof value.done !== "boolean" || !Array.isArray(value.events))
|
|
2806
3993
|
return false;
|
|
3994
|
+
const hasDelivery = value.events.length > 0 || value.done;
|
|
2807
3995
|
const expectedKeys = value.done
|
|
2808
|
-
? ["done", "events", "terminal_status"]
|
|
2809
|
-
:
|
|
3996
|
+
? ["delivery_id", "done", "events", "read_id", "terminal_status"]
|
|
3997
|
+
: hasDelivery
|
|
3998
|
+
? ["delivery_id", "done", "events", "read_id"]
|
|
3999
|
+
: ["done", "events", "read_id"];
|
|
2810
4000
|
if (!hasExactKeys(value, expectedKeys))
|
|
2811
4001
|
return false;
|
|
4002
|
+
if (value.read_id !== expectedReadID || (hasDelivery && !validDeliveryID(value.delivery_id)))
|
|
4003
|
+
return false;
|
|
2812
4004
|
if (value.done) {
|
|
2813
4005
|
if (!validPluginStreamTerminalStatus(value.terminal_status))
|
|
2814
4006
|
return false;
|
|
2815
4007
|
}
|
|
2816
|
-
else {
|
|
2817
|
-
const expiresAt = Date.parse(String(value.next_stream_expires_at));
|
|
2818
|
-
if (typeof value.next_stream_ticket !== "string" || value.next_stream_ticket.length === 0 ||
|
|
2819
|
-
typeof value.next_stream_ticket_id !== "string" || value.next_stream_ticket_id.length === 0 ||
|
|
2820
|
-
!Number.isFinite(expiresAt) || expiresAt <= Date.now())
|
|
2821
|
-
return false;
|
|
2822
|
-
}
|
|
2823
4008
|
let terminal = false;
|
|
2824
4009
|
for (const event of value.events) {
|
|
2825
4010
|
if (!isStreamEvent(event) || event.stream_id !== expectedStreamID || event.sequence <= previousSequence || terminal)
|
|
@@ -2860,7 +4045,7 @@ const pluginMethodPattern = new RegExp("^[-A-Za-z0-9._:]{1,256}$");
|
|
|
2860
4045
|
const pluginActionPattern = new RegExp("^[-A-Za-z0-9._:]{1,128}$");
|
|
2861
4046
|
const pluginUIIdentifierPattern = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$");
|
|
2862
4047
|
const opaqueHandlePattern = new RegExp("^[-A-Za-z0-9_]{8,160}$");
|
|
2863
|
-
const bridgeRequestIDPattern = /^(rpc|stream|render|operation|canvas|asset)_([1-9][0-9]{0,15})$/;
|
|
4048
|
+
const bridgeRequestIDPattern = /^(rpc|stream|stream_ack|render|operation|canvas|asset)_([1-9][0-9]{0,15})$/;
|
|
2864
4049
|
function validBridgeRequestID(value, expectedKind) {
|
|
2865
4050
|
if (typeof value !== "string")
|
|
2866
4051
|
return false;
|
|
@@ -2881,6 +4066,9 @@ function validUIIdentifier(value) {
|
|
|
2881
4066
|
function validOpaqueHandle(value, prefix) {
|
|
2882
4067
|
return typeof value === "string" && value.startsWith(`${prefix}_`) && opaqueHandlePattern.test(value);
|
|
2883
4068
|
}
|
|
4069
|
+
function validDeliveryID(value) {
|
|
4070
|
+
return typeof value === "string" && /^delivery_[A-Za-z0-9_-]{8,128}$/.test(value);
|
|
4071
|
+
}
|
|
2884
4072
|
function validSHA256(value) {
|
|
2885
4073
|
return typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
|
|
2886
4074
|
}
|
|
@@ -2968,6 +4156,38 @@ function isPluginRiskEffect(value) {
|
|
|
2968
4156
|
function confirmationDecisionAccepted(decision) {
|
|
2969
4157
|
return typeof decision === "boolean" ? decision : decision.confirmed;
|
|
2970
4158
|
}
|
|
4159
|
+
function streamReadAbortedError() {
|
|
4160
|
+
return new PluginBridgeError("PLUGIN_STREAM_CANCELLED", "Plugin stream read was aborted");
|
|
4161
|
+
}
|
|
4162
|
+
function mutationOutcome(options, posted) {
|
|
4163
|
+
if (!options.mutation)
|
|
4164
|
+
return undefined;
|
|
4165
|
+
return posted ? "unknown" : "not_committed";
|
|
4166
|
+
}
|
|
4167
|
+
function requestCancelledError(options, posted) {
|
|
4168
|
+
if (options.cancellationKind === "stream")
|
|
4169
|
+
return streamReadAbortedError();
|
|
4170
|
+
return new PluginBridgeError("PLUGIN_BRIDGE_CANCELLED", "Plugin bridge request was cancelled", undefined, undefined, mutationOutcome(options, posted));
|
|
4171
|
+
}
|
|
4172
|
+
function abortableStreamRead(read, signal) {
|
|
4173
|
+
if (!signal)
|
|
4174
|
+
return read;
|
|
4175
|
+
if (signal.aborted)
|
|
4176
|
+
return Promise.reject(streamReadAbortedError());
|
|
4177
|
+
return new Promise((resolve, reject) => {
|
|
4178
|
+
let settled = false;
|
|
4179
|
+
const finish = (callback, value) => {
|
|
4180
|
+
if (settled)
|
|
4181
|
+
return;
|
|
4182
|
+
settled = true;
|
|
4183
|
+
signal.removeEventListener("abort", onAbort);
|
|
4184
|
+
callback(value);
|
|
4185
|
+
};
|
|
4186
|
+
const onAbort = () => finish(reject, streamReadAbortedError());
|
|
4187
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
4188
|
+
read.then((value) => finish(resolve, value), (error) => finish(reject, error));
|
|
4189
|
+
});
|
|
4190
|
+
}
|
|
2971
4191
|
function abortableConfirmationDecision(decision, signal) {
|
|
2972
4192
|
if (signal.aborted) {
|
|
2973
4193
|
return Promise.reject(new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin confirmation was aborted"));
|
|
@@ -3006,7 +4226,7 @@ function normalizePluginJSONObject(value) {
|
|
|
3006
4226
|
}
|
|
3007
4227
|
function normalizePluginJSONValue(value, depth = 0, state = { nodes: 0, seen: new Set() }) {
|
|
3008
4228
|
state.nodes += 1;
|
|
3009
|
-
if (state.nodes >
|
|
4229
|
+
if (state.nodes > maxPluginJSONStructuralNodes || depth > 64)
|
|
3010
4230
|
throw new TypeError("JSON value exceeds structural limits");
|
|
3011
4231
|
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
3012
4232
|
return value;
|
|
@@ -3093,15 +4313,25 @@ function removeUndefined(value) {
|
|
|
3093
4313
|
delete value[key];
|
|
3094
4314
|
return value;
|
|
3095
4315
|
}
|
|
3096
|
-
function toBridgeError(error,
|
|
4316
|
+
function toBridgeError(error, defaultCode) {
|
|
3097
4317
|
if (error instanceof PluginBridgeError)
|
|
3098
4318
|
return error;
|
|
4319
|
+
if (error instanceof PluginPlatformRequestError) {
|
|
4320
|
+
return new PluginBridgeError(error.errorCode, error.message, undefined, error.details, error.mutationOutcome);
|
|
4321
|
+
}
|
|
4322
|
+
if (error instanceof PluginTransportError) {
|
|
4323
|
+
return new PluginBridgeError(defaultCode, error.message, undefined, undefined, error.mutationOutcome);
|
|
4324
|
+
}
|
|
3099
4325
|
if (error instanceof Error)
|
|
3100
|
-
return new PluginBridgeError(
|
|
3101
|
-
return new PluginBridgeError(
|
|
4326
|
+
return new PluginBridgeError(defaultCode, error.message);
|
|
4327
|
+
return new PluginBridgeError(defaultCode, String(error));
|
|
3102
4328
|
}
|
|
3103
4329
|
function streamReadFailureInvalidatesCredential(error) {
|
|
3104
4330
|
if (!(error instanceof PluginBridgeError))
|
|
3105
4331
|
return true;
|
|
3106
4332
|
return streamCredentialInvalidatingErrorCodes.has(error.errorCode);
|
|
3107
4333
|
}
|
|
4334
|
+
function retryableStreamReadTransportFailure(error) {
|
|
4335
|
+
return error instanceof PluginTransportError ||
|
|
4336
|
+
(error instanceof PluginBridgeError && error.errorCode === "PLUGIN_BRIDGE_TIMEOUT");
|
|
4337
|
+
}
|