@floegence/redevplugin-ui 0.4.3 → 0.6.5
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 +261 -117
- package/dist/contracts.gen.js +286 -118
- package/dist/error-codes.gen.d.ts +4 -0
- package/dist/error-codes.gen.js +218 -0
- package/dist/errors.d.ts +22 -4
- package/dist/errors.js +42 -60
- package/dist/http.d.ts +20 -8
- package/dist/http.js +187 -15
- package/dist/local-import.d.ts +6 -11
- package/dist/local-import.js +64 -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 +3991 -0
- package/dist/openapi.gen.js +5 -0
- package/dist/platform.d.ts +150 -529
- package/dist/platform.js +303 -102
- package/dist/plugin.d.ts +2 -2
- package/dist/surface-scope.d.ts +3 -2
- package/dist/surface-scope.js +54 -13
- package/dist/surface.d.ts +64 -31
- package/dist/surface.js +1466 -203
- 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 +4 -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,39 +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
|
-
const lower = name.toLowerCase();
|
|
767
1049
|
if (!validAttribute(tag, name, attributeValue)) throw new Error("plugin render attribute is not allowed");
|
|
768
|
-
|
|
769
|
-
if (attributeValue !== false) element.setAttribute("data-redevplugin-renderer-autofocus", "");
|
|
770
|
-
continue;
|
|
771
|
-
}
|
|
772
|
-
if (typeof attributeValue === "boolean") {
|
|
773
|
-
const serialized = lower.startsWith("aria-") ? String(attributeValue) : "";
|
|
774
|
-
if (attributeValue || serialized) element.setAttribute(name, serialized);
|
|
775
|
-
} else {
|
|
776
|
-
element.setAttribute(name, String(attributeValue));
|
|
777
|
-
}
|
|
1050
|
+
attributes[name] = attributeValue;
|
|
778
1051
|
}
|
|
779
1052
|
}
|
|
780
|
-
if (value.children !== undefined)
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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 };
|
|
785
1085
|
};
|
|
786
1086
|
const renderElementIdentity = (element) => ({
|
|
787
1087
|
tag: element.tagName,
|
|
@@ -845,22 +1145,328 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
845
1145
|
document.body.scrollTop = snapshot.bodyScrollTop;
|
|
846
1146
|
document.body.scrollLeft = snapshot.bodyScrollLeft;
|
|
847
1147
|
};
|
|
848
|
-
const
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
const
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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);
|
|
855
1430
|
const renderState = captureRenderState();
|
|
856
|
-
|
|
857
|
-
|
|
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);
|
|
1443
|
+
const renderState = captureRenderState();
|
|
1444
|
+
await onAnimationFrame(() => {
|
|
1445
|
+
for (const operation of validated.plan) applyPatchOperation(operation);
|
|
1446
|
+
validated.graph.commit();
|
|
1447
|
+
restoreRenderState(renderState);
|
|
1448
|
+
});
|
|
1449
|
+
uiRevision = revision;
|
|
858
1450
|
};
|
|
859
1451
|
const actionPayload = (event, element, eventType = event.type) => {
|
|
860
|
-
const
|
|
861
|
-
const
|
|
862
|
-
if (
|
|
863
|
-
|
|
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;
|
|
864
1470
|
if (eventType === "submit" && element.tagName === "FORM") {
|
|
865
1471
|
const values = {};
|
|
866
1472
|
let count = 0;
|
|
@@ -873,7 +1479,24 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
873
1479
|
}
|
|
874
1480
|
return payload;
|
|
875
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);
|
|
876
1498
|
const handleAction = (event) => {
|
|
1499
|
+
if (pendingQuiesceID) return;
|
|
877
1500
|
if (!["click", "input", "change", "submit"].includes(event.type)) return;
|
|
878
1501
|
const origin = event.target instanceof Element ? event.target : null;
|
|
879
1502
|
const element = origin ? origin.closest("[data-redevplugin-action]") : null;
|
|
@@ -925,7 +1548,17 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
925
1548
|
if (!owner || !document.body.contains(owner) || !validIdentifier(action)) return;
|
|
926
1549
|
event.preventDefault();
|
|
927
1550
|
event.stopPropagation();
|
|
928
|
-
|
|
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
|
+
});
|
|
929
1562
|
}, true);
|
|
930
1563
|
const pumpAssets = () => {
|
|
931
1564
|
while (activeAssetReads < maxConcurrentAssetReads && queuedAssets.length > 0) {
|
|
@@ -1255,7 +1888,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
1255
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));
|
|
1256
1889
|
const requestID = (value, expectedKind) => {
|
|
1257
1890
|
if (typeof value !== "string") return undefined;
|
|
1258
|
-
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);
|
|
1259
1892
|
if (!match || match[1] !== expectedKind) return undefined;
|
|
1260
1893
|
const sequence = Number(match[2]);
|
|
1261
1894
|
return Number.isSafeInteger(sequence) ? { kind: match[1], sequence } : undefined;
|
|
@@ -1473,7 +2106,7 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
1473
2106
|
if (exactKeys(message, ["type", "quiesce_id"]) && message.type === "redevplugin.bridge.lifecycle_ack" && validOpaqueHandle(message.quiesce_id, "quiesce") && message.quiesce_id === pendingQuiesceID) {
|
|
1474
2107
|
pendingQuiesceID = undefined;
|
|
1475
2108
|
sendParent({ type: "redevplugin.surface.quiesce_ack", quiesce_id: message.quiesce_id });
|
|
1476
|
-
|
|
2109
|
+
terminateQuiescedWorker();
|
|
1477
2110
|
return;
|
|
1478
2111
|
}
|
|
1479
2112
|
if (validCall(message)) {
|
|
@@ -1486,6 +2119,12 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
1486
2119
|
sendParent(message);
|
|
1487
2120
|
return;
|
|
1488
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
|
+
}
|
|
1489
2128
|
if (isRecord(message) && Object.keys(message).every((key) => ["type", "id", "operation_id", "reason"].includes(key)) &&
|
|
1490
2129
|
message.type === "redevplugin.bridge.operation.cancel" && typeof message.id === "string" &&
|
|
1491
2130
|
validOpaqueHandle(message.operation_id, "operation") && (message.reason === undefined || (typeof message.reason === "string" && message.reason.length <= 256))) {
|
|
@@ -1513,20 +2152,30 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
1513
2152
|
void deliverImageAsset(message.id, message.asset_id, asset);
|
|
1514
2153
|
return;
|
|
1515
2154
|
}
|
|
1516
|
-
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) {
|
|
1517
2157
|
if (!acceptWorkerRequest(message.id, "render")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
|
|
1518
2158
|
if (!renderRateAllowed()) {
|
|
1519
2159
|
completeWorkerRequest(message.id);
|
|
1520
2160
|
return rejectWorkerRequest(message.id, "plugin render rate limit exceeded");
|
|
1521
2161
|
}
|
|
1522
|
-
|
|
1523
|
-
applyWorkerRender(message.tree);
|
|
2162
|
+
void mountWorkerTree(message.tree).then(() => {
|
|
1524
2163
|
completeWorkerRequest(message.id);
|
|
1525
2164
|
sendWorker({ type: "redevplugin.bridge.response", id: message.id, ok: true });
|
|
1526
|
-
}
|
|
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()) {
|
|
1527
2172
|
completeWorkerRequest(message.id);
|
|
1528
|
-
|
|
2173
|
+
return rejectWorkerRequest(message.id, "plugin render rate limit exceeded");
|
|
1529
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"));
|
|
1530
2179
|
return;
|
|
1531
2180
|
}
|
|
1532
2181
|
if (exactKeys(message, ["type", "id"]) && message.type === "redevplugin.bridge.cancel" && typeof message.id === "string") {
|
|
@@ -1599,9 +2248,9 @@ export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
|
1599
2248
|
})();`;
|
|
1600
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>`;
|
|
1601
2250
|
}
|
|
1602
|
-
|
|
1603
|
-
export class PluginSurfaceHost {
|
|
2251
|
+
class PluginSurfaceHostImplementation {
|
|
1604
2252
|
element;
|
|
2253
|
+
surfaceInstanceId;
|
|
1605
2254
|
bootstrap;
|
|
1606
2255
|
bridgeChannelId;
|
|
1607
2256
|
frameGenerationId;
|
|
@@ -1609,6 +2258,7 @@ export class PluginSurfaceHost {
|
|
|
1609
2258
|
#iframe;
|
|
1610
2259
|
#transport;
|
|
1611
2260
|
#createMessageChannel;
|
|
2261
|
+
#bridgeReady = false;
|
|
1612
2262
|
#loadTimeoutMs;
|
|
1613
2263
|
#requestTimeoutMs;
|
|
1614
2264
|
#leaseRenewalLeadMs;
|
|
@@ -1639,6 +2289,7 @@ export class PluginSurfaceHost {
|
|
|
1639
2289
|
#closePromise;
|
|
1640
2290
|
#revokePromise;
|
|
1641
2291
|
#unregisterSurfaceScope;
|
|
2292
|
+
#onLocalInvalidation;
|
|
1642
2293
|
#opened = false;
|
|
1643
2294
|
#ready = false;
|
|
1644
2295
|
#disposed = false;
|
|
@@ -1657,20 +2308,10 @@ export class PluginSurfaceHost {
|
|
|
1657
2308
|
const error = new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_FAILED", `Plugin iframe reloaded unexpectedly (attempt ${decision.attempt})`);
|
|
1658
2309
|
void this.#failSurface(error);
|
|
1659
2310
|
};
|
|
1660
|
-
|
|
1661
|
-
validateHostBootstrap(options.bootstrap);
|
|
1662
|
-
const transport = surfaceTransportInternals.get(options.hostTransport);
|
|
1663
|
-
if (!transport) {
|
|
1664
|
-
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface host transport is invalid");
|
|
1665
|
-
}
|
|
1666
|
-
return new PluginSurfaceHost(pluginSurfaceHostConstructorToken, options, transport, createPluginSurfaceFrame());
|
|
1667
|
-
}
|
|
1668
|
-
constructor(constructorToken, options, transport, iframe) {
|
|
1669
|
-
if (constructorToken !== pluginSurfaceHostConstructorToken) {
|
|
1670
|
-
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface hosts must be created through PluginSurfaceHost.create()");
|
|
1671
|
-
}
|
|
2311
|
+
constructor(options, transport, iframe) {
|
|
1672
2312
|
this.element = iframe;
|
|
1673
2313
|
this.bootstrap = options.bootstrap;
|
|
2314
|
+
this.surfaceInstanceId = options.bootstrap.surfaceInstanceId;
|
|
1674
2315
|
this.bridgeChannelId = options.bridgeChannelId ?? randomOpaqueHandle("bridge");
|
|
1675
2316
|
this.frameGenerationId = randomOpaqueHandle("frame");
|
|
1676
2317
|
this.surfaceHandle = randomOpaqueHandle("surface");
|
|
@@ -1685,7 +2326,7 @@ export class PluginSurfaceHost {
|
|
|
1685
2326
|
this.#onOpeningProgress = options.onOpeningProgress;
|
|
1686
2327
|
this.#onError = options.onError;
|
|
1687
2328
|
hardenPluginSurfaceFrame(this.#iframe);
|
|
1688
|
-
this.#unregisterSurfaceScope = registerPluginSurface(options.surfaceScope ?? defaultPluginSurfaceScope, this.bootstrap.pluginInstanceId, () => this.#disposeLocal());
|
|
2329
|
+
this.#unregisterSurfaceScope = registerPluginSurface(options.surfaceScope ?? defaultPluginSurfaceScope, this.bootstrap.pluginInstanceId, () => this.#disposeLocal(), () => this.#invalidateLocal());
|
|
1689
2330
|
}
|
|
1690
2331
|
async open() {
|
|
1691
2332
|
this.#assertActive();
|
|
@@ -1696,16 +2337,22 @@ export class PluginSurfaceHost {
|
|
|
1696
2337
|
const startedAt = Date.now();
|
|
1697
2338
|
const progressTimer = setTimeout(() => {
|
|
1698
2339
|
if (!this.#ready && !this.#disposed) {
|
|
1699
|
-
this.#
|
|
2340
|
+
this.#reportOpeningProgress({
|
|
1700
2341
|
phase: "opening",
|
|
1701
2342
|
elapsedMs: Math.max(openingProgressDelayMs, Date.now() - startedAt),
|
|
1702
2343
|
});
|
|
1703
2344
|
}
|
|
1704
2345
|
}, openingProgressDelayMs);
|
|
1705
|
-
const signals = {
|
|
2346
|
+
const signals = {
|
|
2347
|
+
portAcknowledged: deferred(),
|
|
2348
|
+
firstPaint: deferred(),
|
|
2349
|
+
workerReady: deferred(),
|
|
2350
|
+
firstCommit: deferred(),
|
|
2351
|
+
};
|
|
1706
2352
|
void signals.portAcknowledged.promise.catch(() => undefined);
|
|
1707
2353
|
void signals.firstPaint.promise.catch(() => undefined);
|
|
1708
2354
|
void signals.workerReady.promise.catch(() => undefined);
|
|
2355
|
+
void signals.firstCommit.promise.catch(() => undefined);
|
|
1709
2356
|
this.#openSignals = signals;
|
|
1710
2357
|
const scriptNonce = randomOpaqueNonce();
|
|
1711
2358
|
this.#frameLoaded = false;
|
|
@@ -1722,7 +2369,7 @@ export class PluginSurfaceHost {
|
|
|
1722
2369
|
if (bridgeError.errorCode === "PLUGIN_BRIDGE_TIMEOUT")
|
|
1723
2370
|
this.#reloadLimiter.recordCrash();
|
|
1724
2371
|
this.#reportError(bridgeError);
|
|
1725
|
-
const revoke = this.#
|
|
2372
|
+
const revoke = this.#revokeSurface(false);
|
|
1726
2373
|
this.#disposeLocal();
|
|
1727
2374
|
await revoke;
|
|
1728
2375
|
throw bridgeError;
|
|
@@ -1767,18 +2414,27 @@ export class PluginSurfaceHost {
|
|
|
1767
2414
|
});
|
|
1768
2415
|
await Promise.all([signals.firstPaint.promise, signals.workerReady.promise]);
|
|
1769
2416
|
this.#assertActive();
|
|
1770
|
-
this.#
|
|
2417
|
+
this.#bridgeReady = true;
|
|
1771
2418
|
this.#scheduleLeaseRenewal();
|
|
1772
|
-
this.#reloadLimiter.recordHealthyLoad();
|
|
1773
2419
|
this.#postToRenderer({ type: "redevplugin.bridge.lifecycle", event: { type: "ready" } });
|
|
2420
|
+
await signals.firstCommit.promise;
|
|
2421
|
+
this.#assertActive();
|
|
2422
|
+
this.#ready = true;
|
|
2423
|
+
this.#reloadLimiter.recordHealthyLoad();
|
|
1774
2424
|
}
|
|
1775
2425
|
sendLifecycle(event) {
|
|
1776
2426
|
this.#assertReady();
|
|
1777
2427
|
this.#postToRenderer({ type: "redevplugin.bridge.lifecycle", event });
|
|
1778
2428
|
}
|
|
1779
2429
|
close() {
|
|
1780
|
-
if (this.#disposed)
|
|
1781
|
-
|
|
2430
|
+
if (this.#disposed) {
|
|
2431
|
+
const result = {
|
|
2432
|
+
quiesce: { outcome: "not_ready", durationMs: 0 },
|
|
2433
|
+
revokeDurationMs: 0,
|
|
2434
|
+
totalDurationMs: 0,
|
|
2435
|
+
};
|
|
2436
|
+
return this.#revokePromise ? this.#revokePromise.then(() => result) : Promise.resolve(result);
|
|
2437
|
+
}
|
|
1782
2438
|
if (this.#closePromise)
|
|
1783
2439
|
return this.#closePromise;
|
|
1784
2440
|
this.#closePromise = this.#closeSurface();
|
|
@@ -1786,14 +2442,24 @@ export class PluginSurfaceHost {
|
|
|
1786
2442
|
}
|
|
1787
2443
|
dispose() {
|
|
1788
2444
|
if (this.#disposed)
|
|
1789
|
-
return;
|
|
1790
|
-
|
|
2445
|
+
return this.#revokePromise ?? Promise.resolve();
|
|
2446
|
+
const revoke = this.#revokeSurface(true);
|
|
2447
|
+
void revoke.catch(() => undefined);
|
|
1791
2448
|
this.#disposeLocal();
|
|
2449
|
+
return revoke;
|
|
1792
2450
|
}
|
|
1793
|
-
|
|
1794
|
-
|
|
2451
|
+
observeLocalInvalidation(observer) {
|
|
2452
|
+
this.#onLocalInvalidation = observer;
|
|
1795
2453
|
if (this.#disposed)
|
|
1796
|
-
|
|
2454
|
+
observer();
|
|
2455
|
+
}
|
|
2456
|
+
async #closeSurface() {
|
|
2457
|
+
const startedAt = performance.now();
|
|
2458
|
+
const quiesce = await this.#quiesceSurface();
|
|
2459
|
+
if (this.#disposed) {
|
|
2460
|
+
return { quiesce, revokeDurationMs: 0, totalDurationMs: performance.now() - startedAt };
|
|
2461
|
+
}
|
|
2462
|
+
const revokeStartedAt = performance.now();
|
|
1797
2463
|
const revoke = this.#revokeSurface(false);
|
|
1798
2464
|
this.#disposeLocal();
|
|
1799
2465
|
try {
|
|
@@ -1802,6 +2468,11 @@ export class PluginSurfaceHost {
|
|
|
1802
2468
|
catch (error) {
|
|
1803
2469
|
throw toBridgeError(error, "PLUGIN_BRIDGE_DISPOSED");
|
|
1804
2470
|
}
|
|
2471
|
+
return {
|
|
2472
|
+
quiesce,
|
|
2473
|
+
revokeDurationMs: performance.now() - revokeStartedAt,
|
|
2474
|
+
totalDurationMs: performance.now() - startedAt,
|
|
2475
|
+
};
|
|
1805
2476
|
}
|
|
1806
2477
|
#disposeLocal() {
|
|
1807
2478
|
if (this.#disposed)
|
|
@@ -1810,6 +2481,7 @@ export class PluginSurfaceHost {
|
|
|
1810
2481
|
this.#unregisterSurfaceScope?.();
|
|
1811
2482
|
this.#unregisterSurfaceScope = undefined;
|
|
1812
2483
|
this.#ready = false;
|
|
2484
|
+
this.#bridgeReady = false;
|
|
1813
2485
|
if (this.#leaseRenewalTimer)
|
|
1814
2486
|
clearTimeout(this.#leaseRenewalTimer);
|
|
1815
2487
|
this.#leaseRenewalTimer = undefined;
|
|
@@ -1821,6 +2493,7 @@ export class PluginSurfaceHost {
|
|
|
1821
2493
|
const disposedError = new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin surface host was disposed");
|
|
1822
2494
|
this.#openSignals?.firstPaint.reject(disposedError);
|
|
1823
2495
|
this.#openSignals?.workerReady.reject(disposedError);
|
|
2496
|
+
this.#openSignals?.firstCommit.reject(disposedError);
|
|
1824
2497
|
this.#openSignals?.portAcknowledged.reject(disposedError);
|
|
1825
2498
|
this.#initialFrameLoad?.reject(disposedError);
|
|
1826
2499
|
this.#quiesce?.acknowledged.reject(disposedError);
|
|
@@ -1845,6 +2518,16 @@ export class PluginSurfaceHost {
|
|
|
1845
2518
|
}
|
|
1846
2519
|
this.#iframe.srcdoc = "";
|
|
1847
2520
|
}
|
|
2521
|
+
#invalidateLocal() {
|
|
2522
|
+
this.#disposeLocal();
|
|
2523
|
+
this.#iframe.remove?.();
|
|
2524
|
+
try {
|
|
2525
|
+
this.#onLocalInvalidation?.();
|
|
2526
|
+
}
|
|
2527
|
+
catch {
|
|
2528
|
+
// Slot state observers cannot weaken local session containment.
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
1848
2531
|
async #handlePortMessage(event) {
|
|
1849
2532
|
if (this.#disposed || !messageWithinLimit(event.data))
|
|
1850
2533
|
return;
|
|
@@ -1863,6 +2546,10 @@ export class PluginSurfaceHost {
|
|
|
1863
2546
|
this.#openSignals?.workerReady.resolve();
|
|
1864
2547
|
return;
|
|
1865
2548
|
}
|
|
2549
|
+
if (hasExactKeys(data, ["type"]) && data.type === "redevplugin.surface.first_commit") {
|
|
2550
|
+
this.#openSignals?.firstCommit.resolve();
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
1866
2553
|
if (hasExactKeys(data, ["type", "quiesce_id"]) && data.type === "redevplugin.surface.quiesce_ack" && validOpaqueHandle(data.quiesce_id, "quiesce") && data.quiesce_id === this.#quiesce?.id) {
|
|
1867
2554
|
this.#quiesce.acknowledged.resolve();
|
|
1868
2555
|
return;
|
|
@@ -1884,6 +2571,10 @@ export class PluginSurfaceHost {
|
|
|
1884
2571
|
await this.#handleStreamRead(data.id, data.stream_handle);
|
|
1885
2572
|
return;
|
|
1886
2573
|
}
|
|
2574
|
+
if (isStreamAcknowledgeMessage(data)) {
|
|
2575
|
+
await this.#handleStreamAcknowledge(data.id, data.stream_handle, data.delivery_id);
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
1887
2578
|
if (isOperationCancelMessage(data)) {
|
|
1888
2579
|
await this.#handleOperationCancel(data);
|
|
1889
2580
|
return;
|
|
@@ -1897,7 +2588,7 @@ export class PluginSurfaceHost {
|
|
|
1897
2588
|
}
|
|
1898
2589
|
}
|
|
1899
2590
|
async #handleCall(request) {
|
|
1900
|
-
if (!this.#
|
|
2591
|
+
if ((!this.#bridgeReady && !this.#quiesce) || !this.#gatewayToken) {
|
|
1901
2592
|
this.#postError(request.id, "PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "Plugin bridge call arrived before the surface became ready");
|
|
1902
2593
|
return;
|
|
1903
2594
|
}
|
|
@@ -1915,7 +2606,7 @@ export class PluginSurfaceHost {
|
|
|
1915
2606
|
await this.#handleConfirmationRequired(request, bridgeError, controller.signal);
|
|
1916
2607
|
return;
|
|
1917
2608
|
}
|
|
1918
|
-
this.#postError(request.id, bridgeError.errorCode, bridgeError.message, bridgeError.details);
|
|
2609
|
+
this.#postError(request.id, bridgeError.errorCode, bridgeError.message, bridgeError.details, bridgeError.mutationOutcome);
|
|
1919
2610
|
}
|
|
1920
2611
|
finally {
|
|
1921
2612
|
this.#pendingRequestControllers.delete(request.id);
|
|
@@ -1923,11 +2614,11 @@ export class PluginSurfaceHost {
|
|
|
1923
2614
|
}
|
|
1924
2615
|
async #handleConfirmationRequired(request, originalError, signal) {
|
|
1925
2616
|
if (!this.#confirm) {
|
|
1926
|
-
this.#postError(request.id, originalError.errorCode, originalError.message, originalError.details);
|
|
2617
|
+
this.#postError(request.id, originalError.errorCode, originalError.message, originalError.details, originalError.mutationOutcome);
|
|
1927
2618
|
return;
|
|
1928
2619
|
}
|
|
1929
2620
|
try {
|
|
1930
|
-
const confirmation = await this.#
|
|
2621
|
+
const confirmation = await this.#preparePluginMethodConfirmation(request, signal);
|
|
1931
2622
|
const decision = await abortableConfirmationDecision(this.#confirm({
|
|
1932
2623
|
requestId: request.id,
|
|
1933
2624
|
method: request.method,
|
|
@@ -1955,47 +2646,60 @@ export class PluginSurfaceHost {
|
|
|
1955
2646
|
if (signal.aborted || this.#disposed)
|
|
1956
2647
|
return;
|
|
1957
2648
|
const bridgeError = toBridgeError(error, "PLUGIN_PERMISSION_DENIED");
|
|
1958
|
-
this.#postError(request.id, bridgeError.errorCode, bridgeError.message, bridgeError.details);
|
|
2649
|
+
this.#postError(request.id, bridgeError.errorCode, bridgeError.message, bridgeError.details, bridgeError.mutationOutcome);
|
|
1959
2650
|
}
|
|
1960
2651
|
}
|
|
1961
2652
|
async #handleStreamRead(id, streamHandle) {
|
|
1962
2653
|
const credential = this.#streamCredentials.get(streamHandle);
|
|
1963
|
-
if (!credential || credential.reading) {
|
|
1964
|
-
this.#postError(id, "PLUGIN_STREAM_TICKET_INVALID", "Plugin stream handle is invalid or
|
|
2654
|
+
if (!credential || credential.completed || credential.reading || credential.acknowledging) {
|
|
2655
|
+
this.#postError(id, "PLUGIN_STREAM_TICKET_INVALID", "Plugin stream handle is invalid, completed, or busy");
|
|
1965
2656
|
return;
|
|
1966
2657
|
}
|
|
1967
2658
|
if (credential.expiresAtMs <= Date.now()) {
|
|
1968
2659
|
this.#postError(id, "PLUGIN_STREAM_TICKET_INVALID", "Plugin stream handle is expired");
|
|
1969
2660
|
return;
|
|
1970
2661
|
}
|
|
2662
|
+
if (credential.pending) {
|
|
2663
|
+
this.#postResponse(id, credential.pending.response);
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
1971
2666
|
credential.reading = true;
|
|
1972
2667
|
const controller = this.#registerPendingRequest(id);
|
|
1973
2668
|
try {
|
|
1974
|
-
const
|
|
1975
|
-
|
|
2669
|
+
const readPath = `/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/streams/read`;
|
|
2670
|
+
const readBody = () => ({ stream_id: credential.streamID, stream_ticket: credential.streamTicket, read_id: credential.readID });
|
|
2671
|
+
let result;
|
|
2672
|
+
try {
|
|
2673
|
+
result = await this.#postJSON(readPath, readBody, controller.signal);
|
|
2674
|
+
}
|
|
2675
|
+
catch (error) {
|
|
2676
|
+
if (!retryableStreamReadTransportFailure(error) || controller.signal.aborted || this.#disposed)
|
|
2677
|
+
throw error;
|
|
2678
|
+
result = await this.#postJSON(readPath, readBody, controller.signal);
|
|
2679
|
+
}
|
|
2680
|
+
if (!isStreamReadResult(result, credential.streamID, credential.readID, credential.lastSequence)) {
|
|
1976
2681
|
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin stream endpoint returned an invalid response");
|
|
1977
2682
|
}
|
|
1978
2683
|
const lastSequence = result.events.length > 0 ? result.events[result.events.length - 1].sequence : credential.lastSequence;
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
credential.
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
2684
|
+
const events = result.events.map(publicPluginStreamEvent);
|
|
2685
|
+
const response = result.done
|
|
2686
|
+
? { delivery_id: result.delivery_id, events, done: true, terminal_status: result.terminal_status, retry_after_ms: 0 }
|
|
2687
|
+
: { delivery_id: result.delivery_id, events, done: false, retry_after_ms: events.length === 0 ? 25 : 0 };
|
|
2688
|
+
if (result.delivery_id) {
|
|
2689
|
+
credential.pending = {
|
|
2690
|
+
deliveryID: result.delivery_id,
|
|
2691
|
+
lastSequence,
|
|
2692
|
+
done: result.done,
|
|
2693
|
+
response,
|
|
2694
|
+
};
|
|
1988
2695
|
}
|
|
2696
|
+
credential.reading = false;
|
|
1989
2697
|
if (!controller.signal.aborted && !this.#disposed)
|
|
1990
|
-
this.#postResponse(id,
|
|
1991
|
-
events: result.events.map(publicPluginStreamEvent),
|
|
1992
|
-
done: result.done,
|
|
1993
|
-
...(result.done ? { terminal_status: result.terminal_status } : {}),
|
|
1994
|
-
retry_after_ms: result.events.length === 0 && !result.done ? 25 : 0,
|
|
1995
|
-
});
|
|
2698
|
+
this.#postResponse(id, response);
|
|
1996
2699
|
}
|
|
1997
2700
|
catch (error) {
|
|
1998
|
-
|
|
2701
|
+
const bridgeError = toBridgeError(error, "PLUGIN_RUNTIME_UNAVAILABLE");
|
|
2702
|
+
if (streamReadFailureInvalidatesCredential(bridgeError)) {
|
|
1999
2703
|
this.#streamCredentials.delete(streamHandle);
|
|
2000
2704
|
}
|
|
2001
2705
|
else {
|
|
@@ -2003,17 +2707,62 @@ export class PluginSurfaceHost {
|
|
|
2003
2707
|
}
|
|
2004
2708
|
if (controller.signal.aborted || this.#disposed)
|
|
2005
2709
|
return;
|
|
2006
|
-
const bridgeError = toBridgeError(error, "PLUGIN_RUNTIME_UNAVAILABLE");
|
|
2007
2710
|
this.#postError(id, bridgeError.errorCode, bridgeError.message);
|
|
2008
2711
|
}
|
|
2009
2712
|
finally {
|
|
2010
2713
|
this.#pendingRequestControllers.delete(id);
|
|
2011
2714
|
}
|
|
2012
2715
|
}
|
|
2716
|
+
async #handleStreamAcknowledge(id, streamHandle, deliveryID) {
|
|
2717
|
+
const credential = this.#streamCredentials.get(streamHandle);
|
|
2718
|
+
if (!credential || credential.reading || credential.acknowledging) {
|
|
2719
|
+
this.#postError(id, "PLUGIN_STREAM_DELIVERY_INVALID", "Plugin stream delivery is invalid or busy", undefined, "not_committed");
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
if (credential.lastAcknowledgedDeliveryID === deliveryID) {
|
|
2723
|
+
this.#postResponse(id, undefined);
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
if (!credential.pending || credential.pending.deliveryID !== deliveryID) {
|
|
2727
|
+
this.#postError(id, "PLUGIN_STREAM_DELIVERY_INVALID", "Plugin stream delivery does not match the pending batch", undefined, "not_committed");
|
|
2728
|
+
return;
|
|
2729
|
+
}
|
|
2730
|
+
credential.acknowledging = true;
|
|
2731
|
+
const controller = this.#registerPendingRequest(id);
|
|
2732
|
+
try {
|
|
2733
|
+
const result = await this.#postMutationJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/streams/ack`, () => ({
|
|
2734
|
+
stream_id: credential.streamID,
|
|
2735
|
+
stream_ticket: credential.streamTicket,
|
|
2736
|
+
delivery_id: deliveryID,
|
|
2737
|
+
}), controller.signal);
|
|
2738
|
+
if (!hasExactKeys(result, ["acknowledged"]) || result.acknowledged !== true) {
|
|
2739
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin stream acknowledgement endpoint returned an invalid response", undefined, undefined, "unknown");
|
|
2740
|
+
}
|
|
2741
|
+
const pending = credential.pending;
|
|
2742
|
+
credential.lastAcknowledgedDeliveryID = deliveryID;
|
|
2743
|
+
credential.lastSequence = pending.lastSequence;
|
|
2744
|
+
credential.pending = undefined;
|
|
2745
|
+
credential.readID = randomOpaqueHandle("read");
|
|
2746
|
+
credential.completed = pending.done;
|
|
2747
|
+
credential.acknowledging = false;
|
|
2748
|
+
if (!controller.signal.aborted && !this.#disposed)
|
|
2749
|
+
this.#postResponse(id, undefined);
|
|
2750
|
+
}
|
|
2751
|
+
catch (error) {
|
|
2752
|
+
credential.acknowledging = false;
|
|
2753
|
+
if (controller.signal.aborted || this.#disposed)
|
|
2754
|
+
return;
|
|
2755
|
+
const bridgeError = toBridgeError(error, "PLUGIN_STREAM_DELIVERY_INVALID");
|
|
2756
|
+
this.#postError(id, bridgeError.errorCode, bridgeError.message, bridgeError.details, bridgeError.mutationOutcome);
|
|
2757
|
+
}
|
|
2758
|
+
finally {
|
|
2759
|
+
this.#pendingRequestControllers.delete(id);
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2013
2762
|
async #handleOperationCancel(message) {
|
|
2014
2763
|
const controller = this.#registerPendingRequest(message.id);
|
|
2015
2764
|
try {
|
|
2016
|
-
await this.#
|
|
2765
|
+
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);
|
|
2017
2766
|
this.#releaseOperationStreams(message.operation_id);
|
|
2018
2767
|
if (!controller.signal.aborted && !this.#disposed)
|
|
2019
2768
|
this.#postResponse(message.id, undefined);
|
|
@@ -2022,7 +2771,7 @@ export class PluginSurfaceHost {
|
|
|
2022
2771
|
if (controller.signal.aborted || this.#disposed)
|
|
2023
2772
|
return;
|
|
2024
2773
|
const bridgeError = toBridgeError(error, "PLUGIN_OPERATION_BLOCKED");
|
|
2025
|
-
this.#postError(message.id, bridgeError.errorCode, bridgeError.message);
|
|
2774
|
+
this.#postError(message.id, bridgeError.errorCode, bridgeError.message, bridgeError.details, bridgeError.mutationOutcome);
|
|
2026
2775
|
}
|
|
2027
2776
|
finally {
|
|
2028
2777
|
this.#pendingRequestControllers.delete(message.id);
|
|
@@ -2096,7 +2845,10 @@ export class PluginSurfaceHost {
|
|
|
2096
2845
|
streamTicket: result.stream_ticket,
|
|
2097
2846
|
expiresAtMs,
|
|
2098
2847
|
lastSequence: 0,
|
|
2848
|
+
readID: randomOpaqueHandle("read"),
|
|
2099
2849
|
reading: false,
|
|
2850
|
+
acknowledging: false,
|
|
2851
|
+
completed: false,
|
|
2100
2852
|
});
|
|
2101
2853
|
publicResult.stream_handle = handle;
|
|
2102
2854
|
}
|
|
@@ -2115,13 +2867,13 @@ export class PluginSurfaceHost {
|
|
|
2115
2867
|
}
|
|
2116
2868
|
}
|
|
2117
2869
|
#callRPC(request, confirmationID, signal) {
|
|
2118
|
-
return this.#
|
|
2870
|
+
return this.#postMutationJSON("/_redevplugin/api/plugins/rpc", () => this.#rpcBody(request, confirmationID), signal);
|
|
2119
2871
|
}
|
|
2120
|
-
#
|
|
2121
|
-
return this.#
|
|
2872
|
+
#preparePluginMethodConfirmation(request, signal) {
|
|
2873
|
+
return this.#postMutationJSON("/_redevplugin/api/plugins/confirmations/prepare", () => this.#rpcBody(request), signal);
|
|
2122
2874
|
}
|
|
2123
2875
|
async #rejectConfirmation(confirmationID, signal) {
|
|
2124
|
-
const result = await this.#
|
|
2876
|
+
const result = await this.#postMutationJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/confirmations/reject`, () => ({
|
|
2125
2877
|
plugin_instance_id: this.bootstrap.pluginInstanceId,
|
|
2126
2878
|
bridge_channel_id: this.bridgeChannelId,
|
|
2127
2879
|
plugin_gateway_token: this.#gatewayToken,
|
|
@@ -2145,7 +2897,7 @@ export class PluginSurfaceHost {
|
|
|
2145
2897
|
return body;
|
|
2146
2898
|
}
|
|
2147
2899
|
#prepareSurface() {
|
|
2148
|
-
return this.#
|
|
2900
|
+
return this.#postMutationJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/prepare`, { asset_ticket: this.bootstrap.assetTicket });
|
|
2149
2901
|
}
|
|
2150
2902
|
async #mintBridgeToken(previousGatewayToken, direct = false) {
|
|
2151
2903
|
const handshake = this.#handshake();
|
|
@@ -2157,7 +2909,7 @@ export class PluginSurfaceHost {
|
|
|
2157
2909
|
handshake_transcript_sha256: transcript,
|
|
2158
2910
|
...(previousGatewayToken ? { previous_plugin_gateway_token: previousGatewayToken } : {}),
|
|
2159
2911
|
};
|
|
2160
|
-
return direct ? this.#
|
|
2912
|
+
return direct ? this.#fetchMutationJSON(path, body) : this.#postMutationJSON(path, body);
|
|
2161
2913
|
}
|
|
2162
2914
|
#handshake() {
|
|
2163
2915
|
return {
|
|
@@ -2168,13 +2920,19 @@ export class PluginSurfaceHost {
|
|
|
2168
2920
|
active_fingerprint: this.bootstrap.activeFingerprint,
|
|
2169
2921
|
bridge_nonce: this.bootstrap.bridgeNonce,
|
|
2170
2922
|
asset_session_nonce: this.bootstrap.assetSessionNonce,
|
|
2171
|
-
|
|
2923
|
+
management_revision: this.bootstrap.managementRevision,
|
|
2172
2924
|
revoke_epoch: this.bootstrap.revokeEpoch,
|
|
2173
2925
|
ui_protocol_version: pluginUIProtocolVersion,
|
|
2174
2926
|
};
|
|
2175
2927
|
}
|
|
2176
2928
|
async #postJSON(path, body, signal) {
|
|
2177
|
-
|
|
2929
|
+
return this.#requestJSON(path, body, false, signal);
|
|
2930
|
+
}
|
|
2931
|
+
async #postMutationJSON(path, body, signal) {
|
|
2932
|
+
return this.#requestJSON(path, body, true, signal);
|
|
2933
|
+
}
|
|
2934
|
+
async #requestJSON(path, body, mutation, signal) {
|
|
2935
|
+
if (this.#bridgeReady) {
|
|
2178
2936
|
if (Date.now() >= this.#leaseRenewAtMs)
|
|
2179
2937
|
await this.#startLeaseRenewal();
|
|
2180
2938
|
else if (this.#leaseRenewalPromise)
|
|
@@ -2183,7 +2941,9 @@ export class PluginSurfaceHost {
|
|
|
2183
2941
|
const requestBody = typeof body === "function" ? body() : body;
|
|
2184
2942
|
this.#activeTransportRequests += 1;
|
|
2185
2943
|
try {
|
|
2186
|
-
return
|
|
2944
|
+
return mutation
|
|
2945
|
+
? await this.#fetchMutationJSON(path, requestBody, signal)
|
|
2946
|
+
: await this.#fetchJSON(path, requestBody, signal);
|
|
2187
2947
|
}
|
|
2188
2948
|
finally {
|
|
2189
2949
|
this.#activeTransportRequests -= 1;
|
|
@@ -2197,6 +2957,9 @@ export class PluginSurfaceHost {
|
|
|
2197
2957
|
async #fetchJSON(path, body, signal) {
|
|
2198
2958
|
return this.#fetchJSONRequest(path, body, { signal });
|
|
2199
2959
|
}
|
|
2960
|
+
async #fetchMutationJSON(path, body, signal) {
|
|
2961
|
+
return this.#fetchJSONRequest(path, body, { signal, mutation: true });
|
|
2962
|
+
}
|
|
2200
2963
|
async #fetchJSONRequest(path, body, options = {}) {
|
|
2201
2964
|
const controller = new AbortController();
|
|
2202
2965
|
let timedOut = false;
|
|
@@ -2213,7 +2976,7 @@ export class PluginSurfaceHost {
|
|
|
2213
2976
|
timer = setTimeout(() => {
|
|
2214
2977
|
timedOut = true;
|
|
2215
2978
|
controller.abort();
|
|
2216
|
-
reject(new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}
|
|
2979
|
+
reject(new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}`, undefined, undefined, options.mutation ? "unknown" : undefined));
|
|
2217
2980
|
}, this.#requestTimeoutMs);
|
|
2218
2981
|
});
|
|
2219
2982
|
try {
|
|
@@ -2228,12 +2991,16 @@ export class PluginSurfaceHost {
|
|
|
2228
2991
|
}),
|
|
2229
2992
|
timeout,
|
|
2230
2993
|
]);
|
|
2231
|
-
return await
|
|
2994
|
+
return options.mutation ? await readMutationPlatformResponse(response) : await readPlatformResponse(response);
|
|
2232
2995
|
}
|
|
2233
2996
|
catch (error) {
|
|
2234
|
-
if (timedOut)
|
|
2235
|
-
throw new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}
|
|
2236
|
-
|
|
2997
|
+
if (timedOut) {
|
|
2998
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}`, undefined, undefined, options.mutation ? "unknown" : undefined);
|
|
2999
|
+
}
|
|
3000
|
+
if (error instanceof PluginBridgeError || error instanceof PluginPlatformRequestError || error instanceof PluginTransportError) {
|
|
3001
|
+
throw error;
|
|
3002
|
+
}
|
|
3003
|
+
throw new PluginTransportError(`Plugin surface request failed for POST ${path}`, error, options.mutation ? "unknown" : undefined);
|
|
2237
3004
|
}
|
|
2238
3005
|
finally {
|
|
2239
3006
|
if (timer)
|
|
@@ -2271,7 +3038,7 @@ export class PluginSurfaceHost {
|
|
|
2271
3038
|
}
|
|
2272
3039
|
#startLeaseRenewal() {
|
|
2273
3040
|
this.#assertActive();
|
|
2274
|
-
if (!this.#
|
|
3041
|
+
if (!this.#bridgeReady || !this.#gatewayToken) {
|
|
2275
3042
|
return Promise.reject(new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "Plugin surface lease cannot renew before readiness"));
|
|
2276
3043
|
}
|
|
2277
3044
|
if (this.#leaseRenewalPromise)
|
|
@@ -2305,28 +3072,21 @@ export class PluginSurfaceHost {
|
|
|
2305
3072
|
#revokeSurface(keepalive) {
|
|
2306
3073
|
if (this.#revokePromise)
|
|
2307
3074
|
return this.#revokePromise;
|
|
2308
|
-
this.#revokePromise = (
|
|
2309
|
-
await this.#fetchJSONRequest(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/dispose`, { bridge_nonce: this.bootstrap.bridgeNonce }, { keepalive, independentLifecycle: true });
|
|
2310
|
-
})();
|
|
3075
|
+
this.#revokePromise = revokeSurfaceBootstrap(this.#transport, this.bootstrap, this.#requestTimeoutMs, keepalive);
|
|
2311
3076
|
return this.#revokePromise;
|
|
2312
3077
|
}
|
|
2313
|
-
async #bestEffortRevokeSurface(keepalive) {
|
|
2314
|
-
try {
|
|
2315
|
-
await this.#revokeSurface(keepalive);
|
|
2316
|
-
}
|
|
2317
|
-
catch {
|
|
2318
|
-
// The server may already have revoked the surface after a failed prepare.
|
|
2319
|
-
}
|
|
2320
|
-
}
|
|
2321
3078
|
async #quiesceSurface() {
|
|
2322
|
-
|
|
2323
|
-
|
|
3079
|
+
const startedAt = performance.now();
|
|
3080
|
+
if (!this.#ready || !this.#port || this.#quiesce) {
|
|
3081
|
+
return { outcome: "not_ready", durationMs: performance.now() - startedAt };
|
|
3082
|
+
}
|
|
2324
3083
|
const quiesce = {
|
|
2325
3084
|
id: randomOpaqueHandle("quiesce"),
|
|
2326
3085
|
acknowledged: deferred(),
|
|
2327
3086
|
};
|
|
2328
3087
|
void quiesce.acknowledged.promise.catch(() => undefined);
|
|
2329
3088
|
this.#quiesce = quiesce;
|
|
3089
|
+
this.#ready = false;
|
|
2330
3090
|
try {
|
|
2331
3091
|
this.#postToRenderer({
|
|
2332
3092
|
type: "redevplugin.bridge.lifecycle",
|
|
@@ -2334,9 +3094,12 @@ export class PluginSurfaceHost {
|
|
|
2334
3094
|
quiesce_id: quiesce.id,
|
|
2335
3095
|
});
|
|
2336
3096
|
await withTimeout(quiesce.acknowledged.promise, Math.min(this.#requestTimeoutMs, maxSurfaceQuiesceMs), "Plugin surface quiesce timed out");
|
|
3097
|
+
return { outcome: "acknowledged", durationMs: performance.now() - startedAt };
|
|
2337
3098
|
}
|
|
2338
3099
|
catch {
|
|
2339
|
-
|
|
3100
|
+
const error = new PluginBridgeError("PLUGIN_SURFACE_QUIESCE_TIMEOUT", "Plugin surface quiesce timed out");
|
|
3101
|
+
this.#reportError(error);
|
|
3102
|
+
return { outcome: "timed_out", durationMs: performance.now() - startedAt };
|
|
2340
3103
|
}
|
|
2341
3104
|
finally {
|
|
2342
3105
|
if (this.#quiesce === quiesce)
|
|
@@ -2347,10 +3110,11 @@ export class PluginSurfaceHost {
|
|
|
2347
3110
|
if (this.#disposed)
|
|
2348
3111
|
return;
|
|
2349
3112
|
this.#ready = false;
|
|
3113
|
+
this.#bridgeReady = false;
|
|
2350
3114
|
this.#openSignals?.firstPaint.reject(error);
|
|
2351
3115
|
this.#openSignals?.workerReady.reject(error);
|
|
2352
3116
|
this.#reportError(error);
|
|
2353
|
-
const revoke = this.#
|
|
3117
|
+
const revoke = this.#revokeSurface(true);
|
|
2354
3118
|
this.#disposeLocal();
|
|
2355
3119
|
await revoke;
|
|
2356
3120
|
}
|
|
@@ -2362,16 +3126,29 @@ export class PluginSurfaceHost {
|
|
|
2362
3126
|
}
|
|
2363
3127
|
this.#postToRenderer(response);
|
|
2364
3128
|
}
|
|
2365
|
-
#postError(id, errorCode, error, details) {
|
|
3129
|
+
#postError(id, errorCode, error, details, mutationOutcome) {
|
|
2366
3130
|
const errorDetails = details === undefined ? undefined : normalizePluginJSONObject(details);
|
|
2367
|
-
|
|
3131
|
+
const response = removeUndefined({
|
|
2368
3132
|
type: "redevplugin.bridge.response",
|
|
2369
3133
|
id,
|
|
2370
3134
|
ok: false,
|
|
2371
3135
|
error_code: errorCode,
|
|
2372
3136
|
error,
|
|
2373
3137
|
error_details: errorDetails,
|
|
2374
|
-
|
|
3138
|
+
mutation_outcome: mutationOutcome,
|
|
3139
|
+
});
|
|
3140
|
+
if (!messageWithinLimit(response)) {
|
|
3141
|
+
this.#postToRenderer(removeUndefined({
|
|
3142
|
+
type: "redevplugin.bridge.response",
|
|
3143
|
+
id,
|
|
3144
|
+
ok: false,
|
|
3145
|
+
error_code: "PLUGIN_JSON_LIMIT_EXCEEDED",
|
|
3146
|
+
error: "Plugin error response exceeds the bridge message limit",
|
|
3147
|
+
mutation_outcome: mutationOutcome,
|
|
3148
|
+
}));
|
|
3149
|
+
return;
|
|
3150
|
+
}
|
|
3151
|
+
this.#postToRenderer(response);
|
|
2375
3152
|
}
|
|
2376
3153
|
#postToRenderer(message) {
|
|
2377
3154
|
if (!this.#port) {
|
|
@@ -2387,6 +3164,14 @@ export class PluginSurfaceHost {
|
|
|
2387
3164
|
// Observers cannot weaken revocation or local teardown invariants.
|
|
2388
3165
|
}
|
|
2389
3166
|
}
|
|
3167
|
+
#reportOpeningProgress(progress) {
|
|
3168
|
+
try {
|
|
3169
|
+
this.#onOpeningProgress?.(progress);
|
|
3170
|
+
}
|
|
3171
|
+
catch {
|
|
3172
|
+
// Observers cannot weaken opening, revocation, or local teardown invariants.
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
2390
3175
|
#assertReady() {
|
|
2391
3176
|
this.#assertActive();
|
|
2392
3177
|
if (!this.#ready || !this.#port) {
|
|
@@ -2399,6 +3184,418 @@ export class PluginSurfaceHost {
|
|
|
2399
3184
|
}
|
|
2400
3185
|
}
|
|
2401
3186
|
}
|
|
3187
|
+
export function createPreparedPluginSurfaceHost(options) {
|
|
3188
|
+
return createPluginSurfaceHostImplementation(options);
|
|
3189
|
+
}
|
|
3190
|
+
function createPluginSurfaceHostImplementation(options) {
|
|
3191
|
+
validateHostBootstrap(options.bootstrap);
|
|
3192
|
+
const transport = requireSurfaceTransportInternals(options.hostTransport);
|
|
3193
|
+
return new PluginSurfaceHostImplementation(options, transport, createPluginSurfaceFrame());
|
|
3194
|
+
}
|
|
3195
|
+
const surfaceSlotInternals = new WeakMap();
|
|
3196
|
+
class PluginSurfaceCleanupError extends Error {
|
|
3197
|
+
cause;
|
|
3198
|
+
constructor(cause) {
|
|
3199
|
+
super("Plugin surface cleanup failed", { cause });
|
|
3200
|
+
this.name = "PluginSurfaceCleanupError";
|
|
3201
|
+
this.cause = cause;
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
export function openPluginSurfaceInSlot(slot, request) {
|
|
3205
|
+
const internals = surfaceSlotInternals.get(slot);
|
|
3206
|
+
if (!internals)
|
|
3207
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin surface slot is invalid");
|
|
3208
|
+
return internals.open(request);
|
|
3209
|
+
}
|
|
3210
|
+
export function openPreparedPluginSurfaceInSlot(slot, options) {
|
|
3211
|
+
const internals = surfaceSlotInternals.get(slot);
|
|
3212
|
+
if (!internals)
|
|
3213
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin surface slot is invalid");
|
|
3214
|
+
return internals.openPrepared(options);
|
|
3215
|
+
}
|
|
3216
|
+
function createPluginSurfaceOpeningLease(request) {
|
|
3217
|
+
let unregister = () => undefined;
|
|
3218
|
+
let revokePromise;
|
|
3219
|
+
let removeAbortListener = () => undefined;
|
|
3220
|
+
let invalidationObserver = () => undefined;
|
|
3221
|
+
const lease = {
|
|
3222
|
+
options: Promise.resolve(undefined),
|
|
3223
|
+
pluginInstanceId: request.pluginInstanceId,
|
|
3224
|
+
signal: request.signal,
|
|
3225
|
+
abortError: request.abortError,
|
|
3226
|
+
owner: "opening",
|
|
3227
|
+
cancelled: false,
|
|
3228
|
+
adopt() {
|
|
3229
|
+
if (lease.owner !== "opening" || lease.cancelled) {
|
|
3230
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin surface opening was cancelled before adoption");
|
|
3231
|
+
}
|
|
3232
|
+
lease.owner = "host";
|
|
3233
|
+
unregister();
|
|
3234
|
+
removeAbortListener();
|
|
3235
|
+
},
|
|
3236
|
+
cancel() {
|
|
3237
|
+
lease.cancelled = true;
|
|
3238
|
+
if (lease.owner === "host")
|
|
3239
|
+
return Promise.resolve();
|
|
3240
|
+
return lease.revoke();
|
|
3241
|
+
},
|
|
3242
|
+
invalidate() {
|
|
3243
|
+
lease.cancelled = true;
|
|
3244
|
+
if (lease.owner === "host" || lease.owner === "invalidated")
|
|
3245
|
+
return;
|
|
3246
|
+
lease.owner = "invalidated";
|
|
3247
|
+
unregister();
|
|
3248
|
+
removeAbortListener();
|
|
3249
|
+
invalidationObserver();
|
|
3250
|
+
},
|
|
3251
|
+
observeInvalidation(observer) {
|
|
3252
|
+
invalidationObserver = observer;
|
|
3253
|
+
if (lease.owner === "invalidated")
|
|
3254
|
+
observer();
|
|
3255
|
+
},
|
|
3256
|
+
revoke() {
|
|
3257
|
+
if (lease.owner === "invalidated")
|
|
3258
|
+
return Promise.resolve();
|
|
3259
|
+
if (lease.owner === "host") {
|
|
3260
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface ownership was already adopted");
|
|
3261
|
+
}
|
|
3262
|
+
lease.cancelled = true;
|
|
3263
|
+
lease.owner = "revoked";
|
|
3264
|
+
unregister();
|
|
3265
|
+
removeAbortListener();
|
|
3266
|
+
revokePromise ??= lease.options.then(async (options) => {
|
|
3267
|
+
await revokeSurfaceBootstrap(requireSurfaceTransportInternals(options.hostTransport), options.bootstrap, normalizeTimeout(options.requestTimeoutMs), false);
|
|
3268
|
+
}, () => undefined);
|
|
3269
|
+
return revokePromise;
|
|
3270
|
+
},
|
|
3271
|
+
};
|
|
3272
|
+
unregister = registerPluginSurface(request.surfaceScope, request.pluginInstanceId, () => lease.cancel(), () => lease.invalidate());
|
|
3273
|
+
if (lease.owner === "invalidated") {
|
|
3274
|
+
lease.options = Promise.reject(new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin surface scope is invalidated"));
|
|
3275
|
+
}
|
|
3276
|
+
else {
|
|
3277
|
+
try {
|
|
3278
|
+
lease.options = Promise.resolve(request.open());
|
|
3279
|
+
}
|
|
3280
|
+
catch (error) {
|
|
3281
|
+
lease.options = Promise.reject(error);
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
void lease.options.catch(() => {
|
|
3285
|
+
if (lease.owner !== "opening")
|
|
3286
|
+
return;
|
|
3287
|
+
lease.owner = "revoked";
|
|
3288
|
+
unregister();
|
|
3289
|
+
removeAbortListener();
|
|
3290
|
+
});
|
|
3291
|
+
if (request.signal) {
|
|
3292
|
+
const onAbort = () => {
|
|
3293
|
+
void lease.cancel().catch(() => undefined);
|
|
3294
|
+
};
|
|
3295
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
3296
|
+
removeAbortListener = () => request.signal?.removeEventListener("abort", onAbort);
|
|
3297
|
+
if (request.signal.aborted)
|
|
3298
|
+
onAbort();
|
|
3299
|
+
}
|
|
3300
|
+
return lease;
|
|
3301
|
+
}
|
|
3302
|
+
export class PluginSurfaceSlot {
|
|
3303
|
+
element;
|
|
3304
|
+
#onStateChange;
|
|
3305
|
+
#onSurfaceClosed;
|
|
3306
|
+
#active;
|
|
3307
|
+
#opening;
|
|
3308
|
+
#transitionController;
|
|
3309
|
+
#tail = Promise.resolve();
|
|
3310
|
+
#disposed = false;
|
|
3311
|
+
#retired = new WeakMap();
|
|
3312
|
+
#disposePromise;
|
|
3313
|
+
static create(options) {
|
|
3314
|
+
if (!options.stage || typeof options.stage.append !== "function" || !options.stage.dataset) {
|
|
3315
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin surface slot requires an HTML stage");
|
|
3316
|
+
}
|
|
3317
|
+
return new PluginSurfaceSlot(options);
|
|
3318
|
+
}
|
|
3319
|
+
constructor(options) {
|
|
3320
|
+
this.element = options.stage;
|
|
3321
|
+
this.#onStateChange = options.onStateChange;
|
|
3322
|
+
this.#onSurfaceClosed = options.onSurfaceClosed;
|
|
3323
|
+
surfaceSlotInternals.set(this, {
|
|
3324
|
+
open: (request) => {
|
|
3325
|
+
this.#assertActive();
|
|
3326
|
+
return this.#openLease(createPluginSurfaceOpeningLease(request));
|
|
3327
|
+
},
|
|
3328
|
+
openPrepared: (preparedOptions) => this.#queueOpening(Promise.resolve(preparedOptions)),
|
|
3329
|
+
});
|
|
3330
|
+
this.#setState("empty");
|
|
3331
|
+
}
|
|
3332
|
+
#openLease(lease) {
|
|
3333
|
+
return this.#queueOpening(lease.options, lease);
|
|
3334
|
+
}
|
|
3335
|
+
#queueOpening(resolvedOptions, lease) {
|
|
3336
|
+
this.#assertActive();
|
|
3337
|
+
void resolvedOptions.catch(() => undefined);
|
|
3338
|
+
this.#transitionController?.abort();
|
|
3339
|
+
const controller = new AbortController();
|
|
3340
|
+
this.#transitionController = controller;
|
|
3341
|
+
this.#setState("opening");
|
|
3342
|
+
lease?.observeInvalidation(() => {
|
|
3343
|
+
if (this.#transitionController !== controller)
|
|
3344
|
+
return;
|
|
3345
|
+
controller.abort();
|
|
3346
|
+
this.#setState("empty");
|
|
3347
|
+
});
|
|
3348
|
+
const transition = this.#tail.then(() => this.#openSurface(resolvedOptions, controller, lease), async (error) => {
|
|
3349
|
+
await this.#revokeOpening(lease);
|
|
3350
|
+
throw error;
|
|
3351
|
+
});
|
|
3352
|
+
this.#tail = transition.then(() => undefined, (error) => error instanceof PluginSurfaceCleanupError ? Promise.reject(error) : undefined);
|
|
3353
|
+
void this.#tail.catch(() => undefined);
|
|
3354
|
+
return transition.catch((error) => {
|
|
3355
|
+
if (error instanceof PluginSurfaceCleanupError)
|
|
3356
|
+
throw error.cause;
|
|
3357
|
+
throw error;
|
|
3358
|
+
});
|
|
3359
|
+
}
|
|
3360
|
+
async #openSurface(options, controller, lease) {
|
|
3361
|
+
let resolvedOptions;
|
|
3362
|
+
try {
|
|
3363
|
+
resolvedOptions = await options;
|
|
3364
|
+
}
|
|
3365
|
+
catch (error) {
|
|
3366
|
+
if (!this.#disposed && this.#transitionController === controller && !this.#openingCancelled(controller, lease)) {
|
|
3367
|
+
this.#setState("error", toBridgeError(error, "PLUGIN_BRIDGE_HANDSHAKE_FAILED"));
|
|
3368
|
+
}
|
|
3369
|
+
else {
|
|
3370
|
+
this.#setCancelledState(controller);
|
|
3371
|
+
}
|
|
3372
|
+
throw error;
|
|
3373
|
+
}
|
|
3374
|
+
if (lease && resolvedOptions.bootstrap.pluginInstanceId !== lease.pluginInstanceId) {
|
|
3375
|
+
await this.#revokeOpening(lease);
|
|
3376
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface bootstrap identity does not match the opening lease");
|
|
3377
|
+
}
|
|
3378
|
+
if (this.#openingCancelled(controller, lease)) {
|
|
3379
|
+
await this.#revokeOpening(lease);
|
|
3380
|
+
this.#setCancelledState(controller);
|
|
3381
|
+
throw this.#openingCancellationError(lease);
|
|
3382
|
+
}
|
|
3383
|
+
const previous = this.#active;
|
|
3384
|
+
this.#active = undefined;
|
|
3385
|
+
if (previous) {
|
|
3386
|
+
try {
|
|
3387
|
+
await this.#awaitRetirement(previous);
|
|
3388
|
+
}
|
|
3389
|
+
catch (error) {
|
|
3390
|
+
await this.#revokeOpening(lease);
|
|
3391
|
+
throw error;
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
if (this.#openingCancelled(controller, lease)) {
|
|
3395
|
+
await this.#revokeOpening(lease);
|
|
3396
|
+
this.#setCancelledState(controller);
|
|
3397
|
+
throw this.#openingCancellationError(lease);
|
|
3398
|
+
}
|
|
3399
|
+
let host;
|
|
3400
|
+
try {
|
|
3401
|
+
host = createPluginSurfaceHostImplementation(resolvedOptions);
|
|
3402
|
+
}
|
|
3403
|
+
catch (error) {
|
|
3404
|
+
await this.#revokeOpening(lease);
|
|
3405
|
+
if (!this.#disposed && this.#transitionController === controller) {
|
|
3406
|
+
this.#setState("error", toBridgeError(error, "PLUGIN_BRIDGE_HANDSHAKE_FAILED"));
|
|
3407
|
+
}
|
|
3408
|
+
throw error;
|
|
3409
|
+
}
|
|
3410
|
+
host.observeLocalInvalidation(() => {
|
|
3411
|
+
if (this.#active === host)
|
|
3412
|
+
this.#active = undefined;
|
|
3413
|
+
if (this.#opening === host)
|
|
3414
|
+
this.#opening = undefined;
|
|
3415
|
+
if (this.#transitionController === controller)
|
|
3416
|
+
controller.abort();
|
|
3417
|
+
host.element.remove();
|
|
3418
|
+
if (!this.#disposed)
|
|
3419
|
+
this.#setState("empty");
|
|
3420
|
+
});
|
|
3421
|
+
this.#adoptOpening(lease);
|
|
3422
|
+
this.#opening = host;
|
|
3423
|
+
setSurfaceInteractive(host.element, false);
|
|
3424
|
+
this.element.append(host.element);
|
|
3425
|
+
try {
|
|
3426
|
+
await this.#openHostUntilCancelled(host, controller, lease);
|
|
3427
|
+
if (this.#openingCancelled(controller, lease) || this.#opening !== host) {
|
|
3428
|
+
await this.#awaitRetirement(host);
|
|
3429
|
+
this.#setCancelledState(controller);
|
|
3430
|
+
throw this.#openingCancellationError(lease);
|
|
3431
|
+
}
|
|
3432
|
+
this.#opening = undefined;
|
|
3433
|
+
this.#active = host;
|
|
3434
|
+
setSurfaceInteractive(host.element, true);
|
|
3435
|
+
this.#setState("ready");
|
|
3436
|
+
return host;
|
|
3437
|
+
}
|
|
3438
|
+
catch (error) {
|
|
3439
|
+
await this.#awaitRetirement(host);
|
|
3440
|
+
if (!this.#disposed && this.#transitionController === controller && !this.#openingCancelled(controller, lease)) {
|
|
3441
|
+
const bridgeError = toBridgeError(error, "PLUGIN_BRIDGE_HANDSHAKE_FAILED");
|
|
3442
|
+
this.#setState("error", bridgeError);
|
|
3443
|
+
}
|
|
3444
|
+
throw error;
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
async close() {
|
|
3448
|
+
this.#assertActive();
|
|
3449
|
+
this.#transitionController?.abort();
|
|
3450
|
+
this.#transitionController = undefined;
|
|
3451
|
+
this.#setState("empty");
|
|
3452
|
+
const closing = this.#tail.then(() => this.#closeCurrentSurface());
|
|
3453
|
+
this.#tail = closing.then(() => undefined, (error) => error instanceof PluginSurfaceCleanupError ? Promise.reject(error) : undefined);
|
|
3454
|
+
void this.#tail.catch(() => undefined);
|
|
3455
|
+
return this.#unwrapCleanupError(closing);
|
|
3456
|
+
}
|
|
3457
|
+
dispose() {
|
|
3458
|
+
if (this.#disposePromise)
|
|
3459
|
+
return this.#disposePromise;
|
|
3460
|
+
this.#disposed = true;
|
|
3461
|
+
this.#transitionController?.abort();
|
|
3462
|
+
this.#transitionController = undefined;
|
|
3463
|
+
this.#setState("disposed");
|
|
3464
|
+
const disposal = this.#tail.then(async () => {
|
|
3465
|
+
await this.#closeCurrentSurface();
|
|
3466
|
+
});
|
|
3467
|
+
this.#disposePromise = this.#unwrapCleanupError(disposal);
|
|
3468
|
+
void this.#disposePromise.catch(() => undefined);
|
|
3469
|
+
this.#tail = disposal.then(() => undefined, (error) => error instanceof PluginSurfaceCleanupError ? Promise.reject(error) : undefined);
|
|
3470
|
+
void this.#tail.catch(() => undefined);
|
|
3471
|
+
return this.#disposePromise;
|
|
3472
|
+
}
|
|
3473
|
+
async #closeCurrentSurface() {
|
|
3474
|
+
const hosts = new Set([this.#active, this.#opening].filter((host) => host !== undefined));
|
|
3475
|
+
this.#active = undefined;
|
|
3476
|
+
this.#opening = undefined;
|
|
3477
|
+
let result;
|
|
3478
|
+
const failures = [];
|
|
3479
|
+
for (const host of hosts) {
|
|
3480
|
+
try {
|
|
3481
|
+
const closed = await this.#retire(host);
|
|
3482
|
+
result ??= closed;
|
|
3483
|
+
}
|
|
3484
|
+
catch (error) {
|
|
3485
|
+
failures.push(error);
|
|
3486
|
+
}
|
|
3487
|
+
}
|
|
3488
|
+
if (failures.length === 1)
|
|
3489
|
+
throw failures[0];
|
|
3490
|
+
if (failures.length > 1)
|
|
3491
|
+
throw new AggregateError(failures, "Plugin surface slot cleanup failed");
|
|
3492
|
+
return result;
|
|
3493
|
+
}
|
|
3494
|
+
#retire(host) {
|
|
3495
|
+
const existing = this.#retired.get(host);
|
|
3496
|
+
if (existing)
|
|
3497
|
+
return existing;
|
|
3498
|
+
setSurfaceInteractive(host.element, false);
|
|
3499
|
+
const closing = Promise.resolve().then(async () => {
|
|
3500
|
+
try {
|
|
3501
|
+
const result = await host.close();
|
|
3502
|
+
try {
|
|
3503
|
+
this.#onSurfaceClosed?.(result);
|
|
3504
|
+
}
|
|
3505
|
+
catch {
|
|
3506
|
+
// Observers cannot turn a completed revocation into a cleanup failure.
|
|
3507
|
+
}
|
|
3508
|
+
return result;
|
|
3509
|
+
}
|
|
3510
|
+
finally {
|
|
3511
|
+
host.element.remove();
|
|
3512
|
+
}
|
|
3513
|
+
});
|
|
3514
|
+
this.#retired.set(host, closing);
|
|
3515
|
+
return closing;
|
|
3516
|
+
}
|
|
3517
|
+
async #awaitRetirement(host) {
|
|
3518
|
+
try {
|
|
3519
|
+
return await this.#retire(host);
|
|
3520
|
+
}
|
|
3521
|
+
catch (error) {
|
|
3522
|
+
throw new PluginSurfaceCleanupError(error);
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
async #openHostUntilCancelled(host, controller, lease) {
|
|
3526
|
+
const signals = [controller.signal, lease?.signal].filter((signal) => signal !== undefined);
|
|
3527
|
+
let cancel;
|
|
3528
|
+
const cancelled = new Promise((_resolve, reject) => {
|
|
3529
|
+
cancel = () => {
|
|
3530
|
+
void this.#awaitRetirement(host).then(() => reject(this.#openingCancellationError(lease)), reject);
|
|
3531
|
+
};
|
|
3532
|
+
for (const signal of signals)
|
|
3533
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
3534
|
+
if (signals.some((signal) => signal.aborted))
|
|
3535
|
+
cancel();
|
|
3536
|
+
});
|
|
3537
|
+
try {
|
|
3538
|
+
await Promise.race([host.open(), cancelled]);
|
|
3539
|
+
}
|
|
3540
|
+
finally {
|
|
3541
|
+
for (const signal of signals)
|
|
3542
|
+
signal.removeEventListener("abort", cancel);
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
#openingCancelled(controller, lease) {
|
|
3546
|
+
return this.#disposed || controller.signal.aborted || lease?.cancelled === true || lease?.signal?.aborted === true;
|
|
3547
|
+
}
|
|
3548
|
+
#openingCancellationError(lease) {
|
|
3549
|
+
if (lease?.signal?.aborted)
|
|
3550
|
+
return lease.abortError();
|
|
3551
|
+
return new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin surface opening was superseded");
|
|
3552
|
+
}
|
|
3553
|
+
#setCancelledState(controller) {
|
|
3554
|
+
if (!this.#disposed && this.#transitionController === controller)
|
|
3555
|
+
this.#setState("empty");
|
|
3556
|
+
}
|
|
3557
|
+
#adoptOpening(lease) {
|
|
3558
|
+
lease?.adopt();
|
|
3559
|
+
}
|
|
3560
|
+
async #revokeOpening(lease) {
|
|
3561
|
+
if (!lease || lease.owner === "host")
|
|
3562
|
+
return;
|
|
3563
|
+
try {
|
|
3564
|
+
await lease.revoke();
|
|
3565
|
+
}
|
|
3566
|
+
catch (error) {
|
|
3567
|
+
throw new PluginSurfaceCleanupError(error);
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
async #unwrapCleanupError(result) {
|
|
3571
|
+
try {
|
|
3572
|
+
return await result;
|
|
3573
|
+
}
|
|
3574
|
+
catch (error) {
|
|
3575
|
+
if (error instanceof PluginSurfaceCleanupError)
|
|
3576
|
+
throw error.cause;
|
|
3577
|
+
throw error;
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
#setState(state, error) {
|
|
3581
|
+
this.element.dataset.redevpluginSurfaceState = state;
|
|
3582
|
+
try {
|
|
3583
|
+
this.#onStateChange?.(state, error);
|
|
3584
|
+
}
|
|
3585
|
+
catch {
|
|
3586
|
+
// Observers cannot alter the slot lifecycle state machine.
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
#assertActive() {
|
|
3590
|
+
if (this.#disposed)
|
|
3591
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin surface slot is disposed");
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
function setSurfaceInteractive(element, interactive) {
|
|
3595
|
+
element.hidden = !interactive;
|
|
3596
|
+
element.inert = !interactive;
|
|
3597
|
+
element.setAttribute("aria-hidden", interactive ? "false" : "true");
|
|
3598
|
+
}
|
|
2402
3599
|
function claimOpaquePluginBridge() {
|
|
2403
3600
|
const value = globalThis[opaquePluginBridgeGlobalKey];
|
|
2404
3601
|
if (!isRecord(value) || typeof value.claim !== "function") {
|
|
@@ -2504,7 +3701,7 @@ function validateHostBootstrap(bootstrap) {
|
|
|
2504
3701
|
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface bootstrap is incomplete");
|
|
2505
3702
|
}
|
|
2506
3703
|
}
|
|
2507
|
-
if (!Number.isSafeInteger(bootstrap.
|
|
3704
|
+
if (!Number.isSafeInteger(bootstrap.managementRevision) || bootstrap.managementRevision < 1 ||
|
|
2508
3705
|
!Number.isSafeInteger(bootstrap.revokeEpoch) || bootstrap.revokeEpoch < 1) {
|
|
2509
3706
|
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface revision is invalid");
|
|
2510
3707
|
}
|
|
@@ -2513,7 +3710,7 @@ function validateSurfacePreparation(bootstrap, preparation) {
|
|
|
2513
3710
|
if (!isSurfacePreparationResult(preparation)) {
|
|
2514
3711
|
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface prepare returned an invalid opaque document");
|
|
2515
3712
|
}
|
|
2516
|
-
if (preparation.
|
|
3713
|
+
if (preparation.management_revision !== bootstrap.managementRevision || preparation.revoke_epoch !== bootstrap.revokeEpoch) {
|
|
2517
3714
|
throw new PluginBridgeError("PLUGIN_GATEWAY_TOKEN_INVALID", "Plugin surface state changed during prepare");
|
|
2518
3715
|
}
|
|
2519
3716
|
if (preparation.asset_session_nonce !== bootstrap.assetSessionNonce) {
|
|
@@ -2531,7 +3728,7 @@ function isSurfacePreparationResult(value) {
|
|
|
2531
3728
|
"asset_session_nonce",
|
|
2532
3729
|
"entry_path",
|
|
2533
3730
|
"entry_sha256",
|
|
2534
|
-
"
|
|
3731
|
+
"management_revision",
|
|
2535
3732
|
"revoke_epoch",
|
|
2536
3733
|
"issued_at",
|
|
2537
3734
|
"expires_at",
|
|
@@ -2545,7 +3742,7 @@ function isSurfacePreparationResult(value) {
|
|
|
2545
3742
|
typeof value.asset_session_nonce === "string" && value.asset_session_nonce.length > 0 &&
|
|
2546
3743
|
validPackagePath(value.entry_path) &&
|
|
2547
3744
|
validSHA256(value.entry_sha256) &&
|
|
2548
|
-
Number.isSafeInteger(value.
|
|
3745
|
+
Number.isSafeInteger(value.management_revision) && Number(value.management_revision) >= 1 &&
|
|
2549
3746
|
Number.isSafeInteger(value.revoke_epoch) && Number(value.revoke_epoch) >= 1 &&
|
|
2550
3747
|
Number.isFinite(issuedAt) && Number.isFinite(expiresAt) && expiresAt > issuedAt &&
|
|
2551
3748
|
isOpaqueSurfaceDocument(value.document);
|
|
@@ -2611,6 +3808,13 @@ function isStreamReadMessage(value) {
|
|
|
2611
3808
|
validBridgeRequestID(value.id, "stream") &&
|
|
2612
3809
|
validOpaqueHandle(value.stream_handle, "stream");
|
|
2613
3810
|
}
|
|
3811
|
+
function isStreamAcknowledgeMessage(value) {
|
|
3812
|
+
return hasExactKeys(value, ["type", "id", "stream_handle", "delivery_id"]) &&
|
|
3813
|
+
value.type === "redevplugin.bridge.stream.ack" &&
|
|
3814
|
+
validBridgeRequestID(value.id, "stream_ack") &&
|
|
3815
|
+
validOpaqueHandle(value.stream_handle, "stream") &&
|
|
3816
|
+
validDeliveryID(value.delivery_id);
|
|
3817
|
+
}
|
|
2614
3818
|
function isOperationCancelMessage(value) {
|
|
2615
3819
|
return hasAllowedKeys(value, ["type", "id", "operation_id", "reason"]) &&
|
|
2616
3820
|
value.type === "redevplugin.bridge.operation.cancel" &&
|
|
@@ -2641,7 +3845,9 @@ function isBridgeResponse(value) {
|
|
|
2641
3845
|
return Object.keys(value).every((key) => ["type", "id", "ok", "data"].includes(key));
|
|
2642
3846
|
if (value.ok !== false || typeof value.error_code !== "string" || !pluginBridgeErrorCodeSet.has(value.error_code) ||
|
|
2643
3847
|
typeof value.error !== "string" || value.error.length > 4096 ||
|
|
2644
|
-
!Object.keys(value).every((key) => ["type", "id", "ok", "error_code", "error", "error_details"].includes(key))
|
|
3848
|
+
!Object.keys(value).every((key) => ["type", "id", "ok", "error_code", "error", "error_details", "mutation_outcome"].includes(key)) ||
|
|
3849
|
+
(value.mutation_outcome !== undefined && value.mutation_outcome !== "committed" &&
|
|
3850
|
+
value.mutation_outcome !== "not_committed" && value.mutation_outcome !== "unknown")) {
|
|
2645
3851
|
return false;
|
|
2646
3852
|
}
|
|
2647
3853
|
if (value.error_code === "PLUGIN_CAPABILITY_ERROR")
|
|
@@ -2701,14 +3907,28 @@ function isLifecycleMessage(value) {
|
|
|
2701
3907
|
(value.quiesce_id === undefined || (value.event.type === "dispose" && validOpaqueHandle(value.quiesce_id, "quiesce")));
|
|
2702
3908
|
}
|
|
2703
3909
|
function isActionMessage(value) {
|
|
2704
|
-
return hasAllowedKeys(value, ["type", "action", "event", "value", "checked", "form_data"]) &&
|
|
3910
|
+
return hasAllowedKeys(value, ["type", "action", "event", "target_key", "edit_revision", "is_composing", "value", "checked", "form_data"]) &&
|
|
2705
3911
|
value.type === "redevplugin.ui.action" &&
|
|
2706
3912
|
validActionID(value.action) &&
|
|
2707
3913
|
(value.event === "click" || value.event === "input" || value.event === "change" || value.event === "submit" || value.event === "escape") &&
|
|
3914
|
+
validUIIdentifier(value.target_key) && Number.isSafeInteger(value.edit_revision) && Number(value.edit_revision) >= 0 &&
|
|
3915
|
+
typeof value.is_composing === "boolean" &&
|
|
2708
3916
|
(value.value == null || (typeof value.value === "string" && value.value.length <= 65536)) &&
|
|
2709
3917
|
(value.checked == null || typeof value.checked === "boolean") &&
|
|
2710
3918
|
(value.form_data == null || (isRecord(value.form_data) && Object.entries(value.form_data).every(([key, item]) => validActionID(key) && typeof item === "string" && item.length <= 65536)));
|
|
2711
3919
|
}
|
|
3920
|
+
function publicActionEvent(value) {
|
|
3921
|
+
return removeUndefined({
|
|
3922
|
+
action: value.action,
|
|
3923
|
+
event: value.event,
|
|
3924
|
+
targetKey: value.target_key,
|
|
3925
|
+
editRevision: value.edit_revision,
|
|
3926
|
+
isComposing: value.is_composing,
|
|
3927
|
+
value: value.value,
|
|
3928
|
+
checked: value.checked,
|
|
3929
|
+
form_data: value.form_data,
|
|
3930
|
+
});
|
|
3931
|
+
}
|
|
2712
3932
|
function isCanvasReadyCandidate(value) {
|
|
2713
3933
|
return isRecord(value) && value.type === "redevplugin.ui.canvas.ready" && typeof value.id === "string" && typeof value.canvas_id === "string";
|
|
2714
3934
|
}
|
|
@@ -2827,25 +4047,23 @@ function isGatewayTokenResult(value) {
|
|
|
2827
4047
|
typeof value.asset_session_id === "string" && value.asset_session_id.length > 0 &&
|
|
2828
4048
|
typeof value.issued_at === "string" && typeof value.expires_at === "string";
|
|
2829
4049
|
}
|
|
2830
|
-
function isStreamReadResult(value, expectedStreamID, previousSequence) {
|
|
4050
|
+
function isStreamReadResult(value, expectedStreamID, expectedReadID, previousSequence) {
|
|
2831
4051
|
if (!isRecord(value) || typeof value.done !== "boolean" || !Array.isArray(value.events))
|
|
2832
4052
|
return false;
|
|
4053
|
+
const hasDelivery = value.events.length > 0 || value.done;
|
|
2833
4054
|
const expectedKeys = value.done
|
|
2834
|
-
? ["done", "events", "terminal_status"]
|
|
2835
|
-
:
|
|
4055
|
+
? ["delivery_id", "done", "events", "read_id", "terminal_status"]
|
|
4056
|
+
: hasDelivery
|
|
4057
|
+
? ["delivery_id", "done", "events", "read_id"]
|
|
4058
|
+
: ["done", "events", "read_id"];
|
|
2836
4059
|
if (!hasExactKeys(value, expectedKeys))
|
|
2837
4060
|
return false;
|
|
4061
|
+
if (value.read_id !== expectedReadID || (hasDelivery && !validDeliveryID(value.delivery_id)))
|
|
4062
|
+
return false;
|
|
2838
4063
|
if (value.done) {
|
|
2839
4064
|
if (!validPluginStreamTerminalStatus(value.terminal_status))
|
|
2840
4065
|
return false;
|
|
2841
4066
|
}
|
|
2842
|
-
else {
|
|
2843
|
-
const expiresAt = Date.parse(String(value.next_stream_expires_at));
|
|
2844
|
-
if (typeof value.next_stream_ticket !== "string" || value.next_stream_ticket.length === 0 ||
|
|
2845
|
-
typeof value.next_stream_ticket_id !== "string" || value.next_stream_ticket_id.length === 0 ||
|
|
2846
|
-
!Number.isFinite(expiresAt) || expiresAt <= Date.now())
|
|
2847
|
-
return false;
|
|
2848
|
-
}
|
|
2849
4067
|
let terminal = false;
|
|
2850
4068
|
for (const event of value.events) {
|
|
2851
4069
|
if (!isStreamEvent(event) || event.stream_id !== expectedStreamID || event.sequence <= previousSequence || terminal)
|
|
@@ -2886,7 +4104,7 @@ const pluginMethodPattern = new RegExp("^[-A-Za-z0-9._:]{1,256}$");
|
|
|
2886
4104
|
const pluginActionPattern = new RegExp("^[-A-Za-z0-9._:]{1,128}$");
|
|
2887
4105
|
const pluginUIIdentifierPattern = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$");
|
|
2888
4106
|
const opaqueHandlePattern = new RegExp("^[-A-Za-z0-9_]{8,160}$");
|
|
2889
|
-
const bridgeRequestIDPattern = /^(rpc|stream|render|operation|canvas|asset)_([1-9][0-9]{0,15})$/;
|
|
4107
|
+
const bridgeRequestIDPattern = /^(rpc|stream|stream_ack|render|operation|canvas|asset)_([1-9][0-9]{0,15})$/;
|
|
2890
4108
|
function validBridgeRequestID(value, expectedKind) {
|
|
2891
4109
|
if (typeof value !== "string")
|
|
2892
4110
|
return false;
|
|
@@ -2907,6 +4125,9 @@ function validUIIdentifier(value) {
|
|
|
2907
4125
|
function validOpaqueHandle(value, prefix) {
|
|
2908
4126
|
return typeof value === "string" && value.startsWith(`${prefix}_`) && opaqueHandlePattern.test(value);
|
|
2909
4127
|
}
|
|
4128
|
+
function validDeliveryID(value) {
|
|
4129
|
+
return typeof value === "string" && /^delivery_[A-Za-z0-9_-]{8,128}$/.test(value);
|
|
4130
|
+
}
|
|
2910
4131
|
function validSHA256(value) {
|
|
2911
4132
|
return typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
|
|
2912
4133
|
}
|
|
@@ -2994,6 +4215,38 @@ function isPluginRiskEffect(value) {
|
|
|
2994
4215
|
function confirmationDecisionAccepted(decision) {
|
|
2995
4216
|
return typeof decision === "boolean" ? decision : decision.confirmed;
|
|
2996
4217
|
}
|
|
4218
|
+
function streamReadAbortedError() {
|
|
4219
|
+
return new PluginBridgeError("PLUGIN_STREAM_CANCELLED", "Plugin stream read was aborted");
|
|
4220
|
+
}
|
|
4221
|
+
function mutationOutcome(options, posted) {
|
|
4222
|
+
if (!options.mutation)
|
|
4223
|
+
return undefined;
|
|
4224
|
+
return posted ? "unknown" : "not_committed";
|
|
4225
|
+
}
|
|
4226
|
+
function requestCancelledError(options, posted) {
|
|
4227
|
+
if (options.cancellationKind === "stream")
|
|
4228
|
+
return streamReadAbortedError();
|
|
4229
|
+
return new PluginBridgeError("PLUGIN_BRIDGE_CANCELLED", "Plugin bridge request was cancelled", undefined, undefined, mutationOutcome(options, posted));
|
|
4230
|
+
}
|
|
4231
|
+
function abortableStreamRead(read, signal) {
|
|
4232
|
+
if (!signal)
|
|
4233
|
+
return read;
|
|
4234
|
+
if (signal.aborted)
|
|
4235
|
+
return Promise.reject(streamReadAbortedError());
|
|
4236
|
+
return new Promise((resolve, reject) => {
|
|
4237
|
+
let settled = false;
|
|
4238
|
+
const finish = (callback, value) => {
|
|
4239
|
+
if (settled)
|
|
4240
|
+
return;
|
|
4241
|
+
settled = true;
|
|
4242
|
+
signal.removeEventListener("abort", onAbort);
|
|
4243
|
+
callback(value);
|
|
4244
|
+
};
|
|
4245
|
+
const onAbort = () => finish(reject, streamReadAbortedError());
|
|
4246
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
4247
|
+
read.then((value) => finish(resolve, value), (error) => finish(reject, error));
|
|
4248
|
+
});
|
|
4249
|
+
}
|
|
2997
4250
|
function abortableConfirmationDecision(decision, signal) {
|
|
2998
4251
|
if (signal.aborted) {
|
|
2999
4252
|
return Promise.reject(new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin confirmation was aborted"));
|
|
@@ -3032,7 +4285,7 @@ function normalizePluginJSONObject(value) {
|
|
|
3032
4285
|
}
|
|
3033
4286
|
function normalizePluginJSONValue(value, depth = 0, state = { nodes: 0, seen: new Set() }) {
|
|
3034
4287
|
state.nodes += 1;
|
|
3035
|
-
if (state.nodes >
|
|
4288
|
+
if (state.nodes > maxPluginJSONStructuralNodes || depth > 64)
|
|
3036
4289
|
throw new TypeError("JSON value exceeds structural limits");
|
|
3037
4290
|
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
3038
4291
|
return value;
|
|
@@ -3119,15 +4372,25 @@ function removeUndefined(value) {
|
|
|
3119
4372
|
delete value[key];
|
|
3120
4373
|
return value;
|
|
3121
4374
|
}
|
|
3122
|
-
function toBridgeError(error,
|
|
4375
|
+
function toBridgeError(error, defaultCode) {
|
|
3123
4376
|
if (error instanceof PluginBridgeError)
|
|
3124
4377
|
return error;
|
|
4378
|
+
if (error instanceof PluginPlatformRequestError) {
|
|
4379
|
+
return new PluginBridgeError(error.errorCode, error.message, undefined, error.details, error.mutationOutcome);
|
|
4380
|
+
}
|
|
4381
|
+
if (error instanceof PluginTransportError) {
|
|
4382
|
+
return new PluginBridgeError(defaultCode, error.message, undefined, undefined, error.mutationOutcome);
|
|
4383
|
+
}
|
|
3125
4384
|
if (error instanceof Error)
|
|
3126
|
-
return new PluginBridgeError(
|
|
3127
|
-
return new PluginBridgeError(
|
|
4385
|
+
return new PluginBridgeError(defaultCode, error.message);
|
|
4386
|
+
return new PluginBridgeError(defaultCode, String(error));
|
|
3128
4387
|
}
|
|
3129
4388
|
function streamReadFailureInvalidatesCredential(error) {
|
|
3130
4389
|
if (!(error instanceof PluginBridgeError))
|
|
3131
4390
|
return true;
|
|
3132
4391
|
return streamCredentialInvalidatingErrorCodes.has(error.errorCode);
|
|
3133
4392
|
}
|
|
4393
|
+
function retryableStreamReadTransportFailure(error) {
|
|
4394
|
+
return error instanceof PluginTransportError ||
|
|
4395
|
+
(error instanceof PluginBridgeError && error.errorCode === "PLUGIN_BRIDGE_TIMEOUT");
|
|
4396
|
+
}
|