@floegence/redevplugin-ui 0.1.6 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/contracts.gen.d.ts +130 -0
- package/dist/contracts.gen.js +145 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +69 -0
- package/dist/http.d.ts +30 -0
- package/dist/http.js +45 -0
- package/dist/index.d.ts +1 -724
- package/dist/index.js +1 -875
- package/dist/local-import.d.ts +3 -1
- package/dist/local-import.js +13 -57
- package/dist/opaque-surface-policy.gen.d.ts +34 -0
- package/dist/opaque-surface-policy.gen.js +207 -0
- package/dist/platform.d.ts +466 -0
- package/dist/platform.js +142 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +1 -0
- package/dist/surface-scope.d.ts +8 -0
- package/dist/surface-scope.js +31 -0
- package/dist/surface.d.ts +328 -0
- package/dist/surface.js +2073 -0
- package/dist/trusted-parent.d.ts +7 -0
- package/dist/trusted-parent.js +5 -0
- package/package.json +10 -1
package/dist/surface.js
ADDED
|
@@ -0,0 +1,2073 @@
|
|
|
1
|
+
import { PluginBridgeError } from "./errors.js";
|
|
2
|
+
import { pluginUIProtocolVersion } from "./contracts.gen.js";
|
|
3
|
+
import { opaqueSurfaceAllowedTags, opaqueSurfaceGlobalAttributes, opaqueSurfaceRenderLimits, opaqueSurfaceSafeInputTypes, opaqueSurfaceTagAttributes, } from "./opaque-surface-policy.gen.js";
|
|
4
|
+
import { defaultFetch, hasAllowedKeys, hasExactKeys, isRecord, readHostEnvelope, } from "./http.js";
|
|
5
|
+
import { defaultPluginSurfaceScope, registerPluginSurface, } from "./surface-scope.js";
|
|
6
|
+
export const opaqueSurfaceDocumentSchemaVersion = "redevplugin.opaque_surface_document.v1";
|
|
7
|
+
export const pluginRiskPlanSchemaVersion = "redevplugin.capability.risk_plan.v1";
|
|
8
|
+
const opaquePluginBridgeGlobalKey = "__redevpluginWorkerBridgeV2";
|
|
9
|
+
const maxPendingPluginBridgeRequests = 256;
|
|
10
|
+
const maxPluginBridgeMessageBytes = 256 * 1024;
|
|
11
|
+
const maxRetainedPluginStreamHandles = 128;
|
|
12
|
+
const maxOpaqueSurfaceLazyAssets = 128;
|
|
13
|
+
const maxOpaqueSurfaceLazyBytes = 32 * 1024 * 1024;
|
|
14
|
+
const maxConcurrentAssetReads = 4;
|
|
15
|
+
export class PluginBridgeClient {
|
|
16
|
+
surfaceHandle;
|
|
17
|
+
timeoutMs;
|
|
18
|
+
#nextID = 1;
|
|
19
|
+
#port;
|
|
20
|
+
#pending = new Map();
|
|
21
|
+
#actionHandlers = new Map();
|
|
22
|
+
#lifecycleHandlers = new Set();
|
|
23
|
+
#ready = false;
|
|
24
|
+
#readyPromise;
|
|
25
|
+
#resolveReady;
|
|
26
|
+
#rejectReady;
|
|
27
|
+
#disposed = false;
|
|
28
|
+
#onMessage = (event) => {
|
|
29
|
+
this.#handleMessage(event);
|
|
30
|
+
};
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
const claimed = options.port
|
|
33
|
+
? { port: options.port, surfaceHandle: normalizeSurfaceHandle(options.surfaceHandle) }
|
|
34
|
+
: claimOpaquePluginBridge();
|
|
35
|
+
this.surfaceHandle = claimed.surfaceHandle;
|
|
36
|
+
this.timeoutMs = normalizeTimeout(options.timeoutMs);
|
|
37
|
+
this.#port = claimed.port;
|
|
38
|
+
this.#readyPromise = new Promise((resolve, reject) => {
|
|
39
|
+
this.#resolveReady = resolve;
|
|
40
|
+
this.#rejectReady = reject;
|
|
41
|
+
});
|
|
42
|
+
void this.#readyPromise.catch(() => undefined);
|
|
43
|
+
this.#port.addEventListener("message", this.#onMessage);
|
|
44
|
+
this.#port.start();
|
|
45
|
+
}
|
|
46
|
+
ready() {
|
|
47
|
+
this.#assertActive();
|
|
48
|
+
return this.#ready ? Promise.resolve() : this.#readyPromise;
|
|
49
|
+
}
|
|
50
|
+
call(method, params) {
|
|
51
|
+
this.#assertActive();
|
|
52
|
+
if (!validMethod(method)) {
|
|
53
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin bridge method and params are invalid");
|
|
54
|
+
}
|
|
55
|
+
let normalizedParams;
|
|
56
|
+
try {
|
|
57
|
+
normalizedParams = params === undefined ? undefined : normalizePluginJSONObject(params);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin bridge params must be canonical JSON");
|
|
61
|
+
}
|
|
62
|
+
const id = this.#requestID("rpc");
|
|
63
|
+
const request = normalizedParams === undefined
|
|
64
|
+
? { id, method }
|
|
65
|
+
: { id, method, params: normalizedParams };
|
|
66
|
+
return this.#request(id, {
|
|
67
|
+
type: "redevplugin.bridge.call",
|
|
68
|
+
request,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
readStream(streamHandle) {
|
|
72
|
+
this.#assertActive();
|
|
73
|
+
if (!validOpaqueHandle(streamHandle, "stream")) {
|
|
74
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin stream handle is invalid");
|
|
75
|
+
}
|
|
76
|
+
const id = this.#requestID("stream");
|
|
77
|
+
return this.#request(id, {
|
|
78
|
+
type: "redevplugin.bridge.stream.read",
|
|
79
|
+
id,
|
|
80
|
+
stream_handle: streamHandle,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
render(tree) {
|
|
84
|
+
this.#assertActive();
|
|
85
|
+
const id = this.#requestID("render");
|
|
86
|
+
return this.#request(id, {
|
|
87
|
+
type: "redevplugin.ui.render",
|
|
88
|
+
id,
|
|
89
|
+
tree,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
onAction(action, handler) {
|
|
93
|
+
this.#assertActive();
|
|
94
|
+
if (!validActionID(action) || typeof handler !== "function") {
|
|
95
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin action subscription is invalid");
|
|
96
|
+
}
|
|
97
|
+
const handlers = this.#actionHandlers.get(action) ?? new Set();
|
|
98
|
+
handlers.add(handler);
|
|
99
|
+
this.#actionHandlers.set(action, handlers);
|
|
100
|
+
return () => {
|
|
101
|
+
handlers.delete(handler);
|
|
102
|
+
if (handlers.size === 0)
|
|
103
|
+
this.#actionHandlers.delete(action);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
onLifecycle(handler) {
|
|
107
|
+
this.#assertActive();
|
|
108
|
+
this.#lifecycleHandlers.add(handler);
|
|
109
|
+
return () => {
|
|
110
|
+
this.#lifecycleHandlers.delete(handler);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
dispose() {
|
|
114
|
+
if (this.#disposed)
|
|
115
|
+
return;
|
|
116
|
+
this.#disposed = true;
|
|
117
|
+
const disposedError = new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin bridge client is disposed");
|
|
118
|
+
if (!this.#ready)
|
|
119
|
+
this.#rejectReady(disposedError);
|
|
120
|
+
this.#port.removeEventListener("message", this.#onMessage);
|
|
121
|
+
for (const [id, pending] of this.#pending) {
|
|
122
|
+
clearTimeout(pending.timer);
|
|
123
|
+
pending.reject(new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", `Plugin bridge request ${id} was disposed`));
|
|
124
|
+
}
|
|
125
|
+
this.#pending.clear();
|
|
126
|
+
this.#actionHandlers.clear();
|
|
127
|
+
this.#lifecycleHandlers.clear();
|
|
128
|
+
this.#port.close();
|
|
129
|
+
}
|
|
130
|
+
#request(id, message) {
|
|
131
|
+
let normalizedMessage;
|
|
132
|
+
try {
|
|
133
|
+
normalizedMessage = normalizePluginJSONObject(message);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin bridge request must be canonical JSON");
|
|
137
|
+
}
|
|
138
|
+
if (new TextEncoder().encode(JSON.stringify(normalizedMessage)).byteLength > maxPluginBridgeMessageBytes) {
|
|
139
|
+
throw new PluginBridgeError("PLUGIN_JSON_LIMIT_EXCEEDED", "Plugin bridge request exceeds the message size limit");
|
|
140
|
+
}
|
|
141
|
+
if (this.#pending.size >= maxPendingPluginBridgeRequests) {
|
|
142
|
+
throw new PluginBridgeError("PLUGIN_JSON_LIMIT_EXCEEDED", "Plugin bridge has too many pending requests");
|
|
143
|
+
}
|
|
144
|
+
const result = new Promise((resolve, reject) => {
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
if (!this.#pending.delete(id))
|
|
147
|
+
return;
|
|
148
|
+
try {
|
|
149
|
+
this.#port.postMessage({ type: "redevplugin.bridge.cancel", id });
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// The request is already locally cancelled when the port closes concurrently.
|
|
153
|
+
}
|
|
154
|
+
reject(new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin bridge request ${id} timed out`));
|
|
155
|
+
}, this.timeoutMs);
|
|
156
|
+
this.#pending.set(id, {
|
|
157
|
+
resolve: (value) => resolve(value),
|
|
158
|
+
reject,
|
|
159
|
+
timer,
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
try {
|
|
163
|
+
this.#port.postMessage(normalizedMessage);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
const pending = this.#pending.get(id);
|
|
167
|
+
if (pending) {
|
|
168
|
+
this.#pending.delete(id);
|
|
169
|
+
clearTimeout(pending.timer);
|
|
170
|
+
pending.reject(new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", `Plugin bridge request ${id} could not be posted`));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
#handleMessage(event) {
|
|
176
|
+
if (this.#disposed || !messageWithinLimit(event.data))
|
|
177
|
+
return;
|
|
178
|
+
const data = event.data;
|
|
179
|
+
if (isBridgeResponse(data)) {
|
|
180
|
+
const pending = this.#pending.get(data.id);
|
|
181
|
+
if (!pending)
|
|
182
|
+
return;
|
|
183
|
+
this.#pending.delete(data.id);
|
|
184
|
+
clearTimeout(pending.timer);
|
|
185
|
+
if (data.ok)
|
|
186
|
+
pending.resolve(data.data);
|
|
187
|
+
else
|
|
188
|
+
pending.reject(new PluginBridgeError(data.error_code, data.error));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (isLifecycleMessage(data)) {
|
|
192
|
+
if (data.event.type === "ready" && !this.#ready) {
|
|
193
|
+
this.#ready = true;
|
|
194
|
+
this.#resolveReady();
|
|
195
|
+
}
|
|
196
|
+
for (const handler of this.#lifecycleHandlers)
|
|
197
|
+
handler(data.event);
|
|
198
|
+
if (data.event.type === "dispose")
|
|
199
|
+
this.dispose();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (isActionMessage(data)) {
|
|
203
|
+
for (const handler of this.#actionHandlers.get(data.action) ?? [])
|
|
204
|
+
handler(data);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
#requestID(prefix) {
|
|
208
|
+
return `${prefix}_${this.#nextID++}`;
|
|
209
|
+
}
|
|
210
|
+
#assertActive() {
|
|
211
|
+
if (this.#disposed) {
|
|
212
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin bridge client is disposed");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
export const defaultPluginSurfaceReloadMax = 2;
|
|
217
|
+
export const defaultPluginSurfaceReloadWindowMs = 30_000;
|
|
218
|
+
export class PluginSurfaceReloadLimiter {
|
|
219
|
+
maxReloads;
|
|
220
|
+
windowMs;
|
|
221
|
+
#now;
|
|
222
|
+
#windowStartedAtMs;
|
|
223
|
+
#reloads = 0;
|
|
224
|
+
constructor(options = {}) {
|
|
225
|
+
this.maxReloads = normalizeReloadMax(options.maxReloads ?? defaultPluginSurfaceReloadMax);
|
|
226
|
+
this.windowMs = normalizeReloadWindow(options.windowMs ?? defaultPluginSurfaceReloadWindowMs);
|
|
227
|
+
this.#now = options.now ?? (() => Date.now());
|
|
228
|
+
}
|
|
229
|
+
recordCrash(nowMs = this.#now()) {
|
|
230
|
+
nowMs = normalizeNowMs(nowMs);
|
|
231
|
+
this.#ensureWindow(nowMs);
|
|
232
|
+
const windowStartedAtMs = this.#windowStartedAtMs ?? nowMs;
|
|
233
|
+
if (this.#reloads >= this.maxReloads) {
|
|
234
|
+
return {
|
|
235
|
+
allowed: false,
|
|
236
|
+
attempt: this.#reloads + 1,
|
|
237
|
+
remaining: 0,
|
|
238
|
+
windowStartedAtMs,
|
|
239
|
+
nextRetryAtMs: windowStartedAtMs + this.windowMs,
|
|
240
|
+
reason: "reload_limit_exceeded",
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
this.#reloads += 1;
|
|
244
|
+
return { allowed: true, attempt: this.#reloads, remaining: this.maxReloads - this.#reloads, windowStartedAtMs };
|
|
245
|
+
}
|
|
246
|
+
recordHealthyLoad() {
|
|
247
|
+
this.reset();
|
|
248
|
+
}
|
|
249
|
+
reset() {
|
|
250
|
+
this.#windowStartedAtMs = undefined;
|
|
251
|
+
this.#reloads = 0;
|
|
252
|
+
}
|
|
253
|
+
get state() {
|
|
254
|
+
const remaining = Math.max(0, this.maxReloads - this.#reloads);
|
|
255
|
+
return {
|
|
256
|
+
reloads: this.#reloads,
|
|
257
|
+
remaining,
|
|
258
|
+
windowStartedAtMs: this.#windowStartedAtMs,
|
|
259
|
+
nextRetryAtMs: remaining === 0 && this.#windowStartedAtMs !== undefined
|
|
260
|
+
? this.#windowStartedAtMs + this.windowMs
|
|
261
|
+
: undefined,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
#ensureWindow(nowMs) {
|
|
265
|
+
if (this.#windowStartedAtMs === undefined || nowMs < this.#windowStartedAtMs || nowMs >= this.#windowStartedAtMs + this.windowMs) {
|
|
266
|
+
this.#windowStartedAtMs = nowMs;
|
|
267
|
+
this.#reloads = 0;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
export function isPluginRiskPlan(plan) {
|
|
272
|
+
return isRecord(plan) &&
|
|
273
|
+
plan.schema_version === pluginRiskPlanSchemaVersion &&
|
|
274
|
+
typeof plan.summary === "string" &&
|
|
275
|
+
Array.isArray(plan.risk_flags) &&
|
|
276
|
+
plan.risk_flags.every(isPluginRiskFlag) &&
|
|
277
|
+
(plan.effect == null || isPluginRiskEffect(plan.effect)) &&
|
|
278
|
+
(plan.details == null || isRecord(plan.details));
|
|
279
|
+
}
|
|
280
|
+
export function decodePluginStreamText(event) {
|
|
281
|
+
if (!event.data)
|
|
282
|
+
return "";
|
|
283
|
+
if (typeof TextDecoder === "function" && typeof atob === "function") {
|
|
284
|
+
const binary = atob(event.data);
|
|
285
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
286
|
+
return new TextDecoder().decode(bytes);
|
|
287
|
+
}
|
|
288
|
+
const bufferLike = globalThis.Buffer;
|
|
289
|
+
if (bufferLike)
|
|
290
|
+
return bufferLike.from(event.data, "base64").toString("utf8");
|
|
291
|
+
throw new PluginBridgeError("PLUGIN_STREAM_FAILED", "No base64 decoder is available for plugin stream data");
|
|
292
|
+
}
|
|
293
|
+
export async function trustedParentBridgeHandshakeTranscriptSHA256(handshake, bridgeChannelID) {
|
|
294
|
+
const subtle = globalThis.crypto?.subtle;
|
|
295
|
+
if (!subtle) {
|
|
296
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_FAILED", "Web Crypto SHA-256 is unavailable for plugin bridge handshake");
|
|
297
|
+
}
|
|
298
|
+
const encoder = new TextEncoder();
|
|
299
|
+
const fields = [
|
|
300
|
+
"redevplugin.bridge.handshake.v2",
|
|
301
|
+
handshake.plugin_id,
|
|
302
|
+
handshake.surface_id,
|
|
303
|
+
handshake.surface_instance_id,
|
|
304
|
+
handshake.active_fingerprint,
|
|
305
|
+
handshake.bridge_nonce,
|
|
306
|
+
handshake.asset_session_nonce,
|
|
307
|
+
String(handshake.plugin_state_version),
|
|
308
|
+
String(handshake.revoke_epoch),
|
|
309
|
+
handshake.ui_protocol_version,
|
|
310
|
+
bridgeChannelID,
|
|
311
|
+
];
|
|
312
|
+
const chunks = [];
|
|
313
|
+
let totalBytes = 0;
|
|
314
|
+
for (const field of fields) {
|
|
315
|
+
const data = encoder.encode(field);
|
|
316
|
+
const prefix = encoder.encode(`${data.byteLength}:`);
|
|
317
|
+
const terminator = new Uint8Array([0]);
|
|
318
|
+
chunks.push(prefix, data, terminator);
|
|
319
|
+
totalBytes += prefix.byteLength + data.byteLength + terminator.byteLength;
|
|
320
|
+
}
|
|
321
|
+
const transcript = new Uint8Array(totalBytes);
|
|
322
|
+
let offset = 0;
|
|
323
|
+
for (const chunk of chunks) {
|
|
324
|
+
transcript.set(chunk, offset);
|
|
325
|
+
offset += chunk.byteLength;
|
|
326
|
+
}
|
|
327
|
+
const digest = await subtle.digest("SHA-256", transcript);
|
|
328
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
329
|
+
}
|
|
330
|
+
const surfaceTransportInternals = new WeakMap();
|
|
331
|
+
export function createReDevPluginSurfaceTransport(options = {}) {
|
|
332
|
+
const transport = Object.freeze({});
|
|
333
|
+
surfaceTransportInternals.set(transport, {
|
|
334
|
+
fetch: options.fetch ?? defaultFetch(),
|
|
335
|
+
apiBaseURL: normalizeSurfaceAPIBaseURL(options.apiBaseURL ?? ""),
|
|
336
|
+
});
|
|
337
|
+
return transport;
|
|
338
|
+
}
|
|
339
|
+
export function createOpaquePluginBootstrapHTML(options = {}) {
|
|
340
|
+
const scriptNonce = options.scriptNonce ?? randomOpaqueNonce();
|
|
341
|
+
if (!/^[A-Za-z0-9_-]{8,128}$/.test(scriptNonce)) {
|
|
342
|
+
throw new Error("scriptNonce must contain 8-128 URL-safe characters");
|
|
343
|
+
}
|
|
344
|
+
const csp = [
|
|
345
|
+
"default-src 'none'",
|
|
346
|
+
`script-src 'nonce-${scriptNonce}'`,
|
|
347
|
+
`style-src 'nonce-${scriptNonce}'`,
|
|
348
|
+
"img-src data: blob:",
|
|
349
|
+
"font-src data: blob:",
|
|
350
|
+
"media-src data: blob:",
|
|
351
|
+
"connect-src 'none'",
|
|
352
|
+
"frame-src 'none'",
|
|
353
|
+
"worker-src blob:",
|
|
354
|
+
"child-src blob:",
|
|
355
|
+
"form-action 'none'",
|
|
356
|
+
"base-uri 'none'",
|
|
357
|
+
"object-src 'none'",
|
|
358
|
+
"manifest-src 'none'",
|
|
359
|
+
].join("; ");
|
|
360
|
+
const bootstrapScript = `(() => {
|
|
361
|
+
"use strict";
|
|
362
|
+
const protocolVersion = ${JSON.stringify(pluginUIProtocolVersion)};
|
|
363
|
+
const documentSchema = ${JSON.stringify(opaqueSurfaceDocumentSchemaVersion)};
|
|
364
|
+
const workerGlobalKey = ${JSON.stringify(opaquePluginBridgeGlobalKey)};
|
|
365
|
+
const scriptNonce = ${JSON.stringify(scriptNonce)};
|
|
366
|
+
const maxMessageBytes = ${opaqueSurfaceRenderLimits.max_message_bytes};
|
|
367
|
+
const maxInFlightRequests = ${opaqueSurfaceRenderLimits.max_in_flight_requests};
|
|
368
|
+
const maxRendersPerSecond = ${opaqueSurfaceRenderLimits.max_renders_per_second};
|
|
369
|
+
const maxRenderDepth = ${opaqueSurfaceRenderLimits.max_render_depth};
|
|
370
|
+
const maxRenderNodes = ${opaqueSurfaceRenderLimits.max_render_nodes};
|
|
371
|
+
const maxAttributesPerElement = ${opaqueSurfaceRenderLimits.max_attributes_per_element};
|
|
372
|
+
const maxTextLength = ${opaqueSurfaceRenderLimits.max_text_length};
|
|
373
|
+
const maxAttributeValueLength = ${opaqueSurfaceRenderLimits.max_attribute_value_length};
|
|
374
|
+
const maxFormFields = ${opaqueSurfaceRenderLimits.max_form_fields};
|
|
375
|
+
const maxCriticalDocumentBytes = ${8 * 1024 * 1024};
|
|
376
|
+
const maxPrivateAssetBase64Length = ${Math.ceil((32 * 1024 * 1024) / 3) * 4};
|
|
377
|
+
const maxLazyAssetCount = ${maxOpaqueSurfaceLazyAssets};
|
|
378
|
+
const maxLazyAssetBytes = ${maxOpaqueSurfaceLazyBytes};
|
|
379
|
+
const maxConcurrentAssetReads = ${maxConcurrentAssetReads};
|
|
380
|
+
const allowedTags = new Set(${JSON.stringify(opaqueSurfaceAllowedTags)});
|
|
381
|
+
const globalAttributes = new Set(${JSON.stringify(opaqueSurfaceGlobalAttributes)});
|
|
382
|
+
const tagAttributes = Object.freeze(Object.fromEntries(
|
|
383
|
+
Object.entries(${JSON.stringify(opaqueSurfaceTagAttributes)}).map(([tag, attributes]) => [tag, new Set(attributes)])
|
|
384
|
+
));
|
|
385
|
+
const safeInputTypes = new Set(${JSON.stringify(opaqueSurfaceSafeInputTypes)});
|
|
386
|
+
const exactKeys = (value, keys) => {
|
|
387
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
388
|
+
const actual = Object.keys(value).sort();
|
|
389
|
+
const expected = [...keys].sort();
|
|
390
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
391
|
+
};
|
|
392
|
+
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
393
|
+
const canonicalJSON = (value, depth = 0, state = { nodes: 0 }, seen = new Set()) => {
|
|
394
|
+
state.nodes += 1;
|
|
395
|
+
if (state.nodes > 4096 || depth > 64) return false;
|
|
396
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
397
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
398
|
+
if (typeof value !== "object" || seen.has(value)) return false;
|
|
399
|
+
seen.add(value);
|
|
400
|
+
if (Array.isArray(value)) {
|
|
401
|
+
const valid = value.every((item) => canonicalJSON(item, depth + 1, state, seen));
|
|
402
|
+
seen.delete(value);
|
|
403
|
+
return valid;
|
|
404
|
+
}
|
|
405
|
+
const prototype = Object.getPrototypeOf(value);
|
|
406
|
+
if (prototype !== Object.prototype && prototype !== null) return false;
|
|
407
|
+
const keys = Reflect.ownKeys(value);
|
|
408
|
+
if (keys.some((key) => typeof key !== "string")) return false;
|
|
409
|
+
for (const key of keys) {
|
|
410
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
411
|
+
if (!descriptor || !descriptor.enumerable || !("value" in descriptor) || !canonicalJSON(descriptor.value, depth + 1, state, seen)) return false;
|
|
412
|
+
}
|
|
413
|
+
seen.delete(value);
|
|
414
|
+
return true;
|
|
415
|
+
};
|
|
416
|
+
const withinLimit = (value) => {
|
|
417
|
+
try { return canonicalJSON(value) && new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxMessageBytes; }
|
|
418
|
+
catch { return false; }
|
|
419
|
+
};
|
|
420
|
+
const validIdentifier = (value) => typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value);
|
|
421
|
+
const validOpaqueHandle = (value, prefix) => typeof value === "string" && value.startsWith(prefix + "_") && /^[A-Za-z0-9_-]{8,160}$/.test(value);
|
|
422
|
+
const validDigest = (value) => typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
|
|
423
|
+
const validPath = (value) => typeof value === "string" && value.length > 0 && value.length <= 512 && !value.startsWith("/") && !value.includes("\\\\") && !value.split("/").some((part) => !part || part === "." || part === "..");
|
|
424
|
+
const validAttribute = (tag, name, value) => {
|
|
425
|
+
const lower = name.toLowerCase();
|
|
426
|
+
if (lower.startsWith("on") || lower === "style" || lower === "src" || lower === "srcset" || lower === "href" || lower === "srcdoc" || lower === "action" || lower === "formaction") return false;
|
|
427
|
+
if (!globalAttributes.has(lower) && !lower.startsWith("aria-") && !(tagAttributes[tag] && tagAttributes[tag].has(lower))) return false;
|
|
428
|
+
if (!["string", "number", "boolean"].includes(typeof value)) return false;
|
|
429
|
+
if (lower === "data-redevplugin-action" && !validIdentifier(String(value))) return false;
|
|
430
|
+
if (lower === "data-redevplugin-asset-binding" && !validOpaqueHandle(String(value), "asset")) return false;
|
|
431
|
+
if (lower === "data-redevplugin-asset-attr" && !["src", "poster"].includes(String(value))) return false;
|
|
432
|
+
if (tag === "input" && lower === "type" && !safeInputTypes.has(String(value).trim().toLowerCase() || "text")) return false;
|
|
433
|
+
return String(value).length <= maxAttributeValueLength;
|
|
434
|
+
};
|
|
435
|
+
const validateDOMTree = (root) => {
|
|
436
|
+
let nodes = 0;
|
|
437
|
+
const walk = (node, depth) => {
|
|
438
|
+
nodes += 1;
|
|
439
|
+
if (nodes > maxRenderNodes || depth > maxRenderDepth) return false;
|
|
440
|
+
if (node.nodeType === Node.TEXT_NODE) return (node.nodeValue || "").length <= maxTextLength;
|
|
441
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return false;
|
|
442
|
+
const tag = node.tagName.toLowerCase();
|
|
443
|
+
if (!allowedTags.has(tag)) return false;
|
|
444
|
+
for (const attr of node.attributes) {
|
|
445
|
+
if (!validAttribute(tag, attr.name, attr.value)) return false;
|
|
446
|
+
}
|
|
447
|
+
for (const child of node.childNodes) if (!walk(child, depth + 1)) return false;
|
|
448
|
+
return true;
|
|
449
|
+
};
|
|
450
|
+
for (const child of root.childNodes) if (!walk(child, 1)) return false;
|
|
451
|
+
return true;
|
|
452
|
+
};
|
|
453
|
+
const validStyle = (value) => exactKeys(value, ["path", "sha256", "content"]) && validPath(value.path) && validDigest(value.sha256) && typeof value.content === "string" && value.content.length <= 2097152;
|
|
454
|
+
const validWorker = (value) => exactKeys(value, ["path", "sha256", "type", "content"]) && validPath(value.path) && validDigest(value.sha256) && value.type === "classic" && typeof value.content === "string" && value.content.length <= 4194304;
|
|
455
|
+
const validAsset = (value) => exactKeys(value, ["binding_id", "path", "sha256", "size", "content_type"]) && validOpaqueHandle(value.binding_id, "asset") && validPath(value.path) && validDigest(value.sha256) && Number.isSafeInteger(value.size) && value.size >= 0 && value.size <= maxLazyAssetBytes && typeof value.content_type === "string" && value.content_type.length > 0 && value.content_type.length <= 256;
|
|
456
|
+
const validDocument = (value) => isRecord(value) && Object.keys(value).every((key) => ["schema_version", "entry_path", "entry_sha256", "title", "language", "direction", "body_html", "styles", "worker", "assets", "critical_bytes"].includes(key)) && value.schema_version === documentSchema && validPath(value.entry_path) && validDigest(value.entry_sha256) && (value.title === undefined || (typeof value.title === "string" && value.title.length <= 256)) && (value.language === undefined || (typeof value.language === "string" && value.language.length <= 64)) && (value.direction === undefined || ["ltr", "rtl", "auto"].includes(value.direction)) && typeof value.body_html === "string" && value.body_html.length <= 4194304 && Array.isArray(value.styles) && value.styles.every(validStyle) && validWorker(value.worker) && Array.isArray(value.assets) && value.assets.length <= maxLazyAssetCount && value.assets.every(validAsset) && new Set(value.assets.map((asset) => asset.binding_id)).size === value.assets.length && new Set(value.assets.map((asset) => asset.path)).size === value.assets.length && value.assets.reduce((total, asset) => total + asset.size, 0) <= maxLazyAssetBytes && Number.isSafeInteger(value.critical_bytes) && value.critical_bytes >= 0 && value.critical_bytes <= maxCriticalDocumentBytes;
|
|
457
|
+
let accepted = false;
|
|
458
|
+
let initialized = false;
|
|
459
|
+
let parentPort;
|
|
460
|
+
let worker;
|
|
461
|
+
let workerControlPort;
|
|
462
|
+
let workerPort;
|
|
463
|
+
let frameGenerationID = "";
|
|
464
|
+
let surfaceHandle = "";
|
|
465
|
+
let currentDocument;
|
|
466
|
+
let workerReady = false;
|
|
467
|
+
const pendingWorkerRequests = new Set();
|
|
468
|
+
const requestSequence = { rpc: 0, stream: 0, render: 0 };
|
|
469
|
+
let renderWindowStartedAt = 0;
|
|
470
|
+
let renderCount = 0;
|
|
471
|
+
const pendingAssets = new Map();
|
|
472
|
+
const queuedAssets = [];
|
|
473
|
+
let activeAssetReads = 0;
|
|
474
|
+
let assetSequence = 0;
|
|
475
|
+
const blobURLs = new Set();
|
|
476
|
+
|
|
477
|
+
const sendParent = (message) => {
|
|
478
|
+
if (parentPort && withinLimit(message)) parentPort.postMessage(message);
|
|
479
|
+
};
|
|
480
|
+
const sendWorker = (message) => {
|
|
481
|
+
if (workerPort && withinLimit(message)) workerPort.postMessage(message);
|
|
482
|
+
};
|
|
483
|
+
const fail = (message) => {
|
|
484
|
+
document.documentElement.lang = "en";
|
|
485
|
+
document.body.replaceChildren();
|
|
486
|
+
const error = document.createElement("pre");
|
|
487
|
+
error.setAttribute("role", "alert");
|
|
488
|
+
error.textContent = "Plugin surface failed to initialize.";
|
|
489
|
+
document.body.append(error);
|
|
490
|
+
sendParent({ type: "redevplugin.surface.error", error: String(message).slice(0, 512) });
|
|
491
|
+
disposeRuntime();
|
|
492
|
+
};
|
|
493
|
+
const disposeRuntime = () => {
|
|
494
|
+
if (workerControlPort) {
|
|
495
|
+
workerControlPort.close();
|
|
496
|
+
workerControlPort = undefined;
|
|
497
|
+
}
|
|
498
|
+
if (workerPort) {
|
|
499
|
+
try { workerPort.postMessage({ type: "redevplugin.bridge.lifecycle", event: { type: "dispose" } }); } catch {}
|
|
500
|
+
workerPort.close();
|
|
501
|
+
workerPort = undefined;
|
|
502
|
+
}
|
|
503
|
+
if (worker) {
|
|
504
|
+
worker.terminate();
|
|
505
|
+
worker = undefined;
|
|
506
|
+
}
|
|
507
|
+
for (const url of blobURLs) URL.revokeObjectURL(url);
|
|
508
|
+
blobURLs.clear();
|
|
509
|
+
pendingWorkerRequests.clear();
|
|
510
|
+
pendingAssets.clear();
|
|
511
|
+
queuedAssets.length = 0;
|
|
512
|
+
activeAssetReads = 0;
|
|
513
|
+
};
|
|
514
|
+
const applyStaticDocument = (surfaceDocument) => {
|
|
515
|
+
document.title = typeof surfaceDocument.title === "string" ? surfaceDocument.title.slice(0, 256) : "Plugin";
|
|
516
|
+
document.documentElement.lang = typeof surfaceDocument.language === "string" ? surfaceDocument.language.slice(0, 64) : "en";
|
|
517
|
+
if (["ltr", "rtl", "auto"].includes(surfaceDocument.direction)) document.documentElement.dir = surfaceDocument.direction;
|
|
518
|
+
const template = document.createElement("template");
|
|
519
|
+
template.innerHTML = surfaceDocument.body_html;
|
|
520
|
+
if (!validateDOMTree(template.content)) throw new Error("static plugin document failed renderer validation");
|
|
521
|
+
document.body.replaceChildren(template.content.cloneNode(true));
|
|
522
|
+
for (const styleAsset of surfaceDocument.styles) {
|
|
523
|
+
const style = document.createElement("style");
|
|
524
|
+
style.setAttribute("nonce", scriptNonce);
|
|
525
|
+
style.dataset.redevpluginAsset = styleAsset.path;
|
|
526
|
+
style.textContent = styleAsset.content;
|
|
527
|
+
document.head.append(style);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
const buildWorkerNode = (value, state, depth) => {
|
|
531
|
+
state.nodes += 1;
|
|
532
|
+
if (state.nodes > maxRenderNodes || depth > maxRenderDepth) throw new Error("plugin render tree exceeds limits");
|
|
533
|
+
if (typeof value === "string") {
|
|
534
|
+
if (value.length > maxTextLength) throw new Error("plugin render text exceeds limits");
|
|
535
|
+
return document.createTextNode(value);
|
|
536
|
+
}
|
|
537
|
+
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");
|
|
538
|
+
const tag = value.tag.toLowerCase();
|
|
539
|
+
if (!allowedTags.has(tag)) throw new Error("plugin render tag is not allowed");
|
|
540
|
+
const element = document.createElement(tag);
|
|
541
|
+
if (value.attributes !== undefined) {
|
|
542
|
+
if (!isRecord(value.attributes) || Object.keys(value.attributes).length > maxAttributesPerElement) throw new Error("plugin render attributes are invalid");
|
|
543
|
+
for (const [name, attributeValue] of Object.entries(value.attributes)) {
|
|
544
|
+
if (!validAttribute(tag, name, attributeValue)) throw new Error("plugin render attribute is not allowed");
|
|
545
|
+
if (typeof attributeValue === "boolean") {
|
|
546
|
+
if (attributeValue) element.setAttribute(name, "");
|
|
547
|
+
} else {
|
|
548
|
+
element.setAttribute(name, String(attributeValue));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (value.children !== undefined) {
|
|
553
|
+
if (!Array.isArray(value.children)) throw new Error("plugin render children are invalid");
|
|
554
|
+
for (const child of value.children) element.append(buildWorkerNode(child, state, depth + 1));
|
|
555
|
+
}
|
|
556
|
+
return element;
|
|
557
|
+
};
|
|
558
|
+
const applyWorkerRender = (tree) => {
|
|
559
|
+
const values = Array.isArray(tree) ? tree : [tree];
|
|
560
|
+
const fragment = document.createDocumentFragment();
|
|
561
|
+
const state = { nodes: 0 };
|
|
562
|
+
for (const value of values) fragment.append(buildWorkerNode(value, state, 1));
|
|
563
|
+
document.body.replaceChildren(fragment);
|
|
564
|
+
};
|
|
565
|
+
const actionPayload = (event, element) => {
|
|
566
|
+
const payload = { type: "redevplugin.ui.action", action: element.getAttribute("data-redevplugin-action"), event: event.type };
|
|
567
|
+
const target = event.target;
|
|
568
|
+
if (target && typeof target.value === "string") payload.value = target.value.slice(0, maxTextLength);
|
|
569
|
+
if (target && typeof target.checked === "boolean") payload.checked = target.checked;
|
|
570
|
+
if (event.type === "submit" && event.target instanceof HTMLFormElement) {
|
|
571
|
+
const values = {};
|
|
572
|
+
let count = 0;
|
|
573
|
+
for (const [name, value] of new FormData(event.target)) {
|
|
574
|
+
if (typeof value !== "string" || !validIdentifier(name) || count >= maxFormFields) continue;
|
|
575
|
+
values[name] = value.slice(0, maxTextLength);
|
|
576
|
+
count += 1;
|
|
577
|
+
}
|
|
578
|
+
payload.form_data = values;
|
|
579
|
+
}
|
|
580
|
+
return payload;
|
|
581
|
+
};
|
|
582
|
+
const handleAction = (event) => {
|
|
583
|
+
if (!["click", "input", "change", "submit"].includes(event.type)) return;
|
|
584
|
+
if (event.type === "submit") event.preventDefault();
|
|
585
|
+
const origin = event.target instanceof Element ? event.target : null;
|
|
586
|
+
const element = origin ? origin.closest("[data-redevplugin-action]") : null;
|
|
587
|
+
if (!element || !document.body.contains(element)) return;
|
|
588
|
+
event.preventDefault();
|
|
589
|
+
const action = element.getAttribute("data-redevplugin-action");
|
|
590
|
+
if (!validIdentifier(action)) return;
|
|
591
|
+
sendWorker(actionPayload(event, element));
|
|
592
|
+
};
|
|
593
|
+
for (const eventType of ["click", "input", "change", "submit"]) document.addEventListener(eventType, handleAction, true);
|
|
594
|
+
const pumpAssets = () => {
|
|
595
|
+
while (activeAssetReads < maxConcurrentAssetReads && queuedAssets.length > 0) {
|
|
596
|
+
const asset = queuedAssets.shift();
|
|
597
|
+
const requestID = "asset_request_" + (++assetSequence);
|
|
598
|
+
pendingAssets.set(requestID, asset);
|
|
599
|
+
activeAssetReads += 1;
|
|
600
|
+
sendParent({ type: "redevplugin.surface.asset.read", request_id: requestID, binding_id: asset.binding_id, path: asset.path, sha256: asset.sha256 });
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
const loadAssets = () => {
|
|
604
|
+
queuedAssets.push(...currentDocument.assets);
|
|
605
|
+
pumpAssets();
|
|
606
|
+
};
|
|
607
|
+
const applyAsset = async (asset, contentBase64) => {
|
|
608
|
+
if (contentBase64.length !== Math.ceil(asset.size / 3) * 4) throw new Error("plugin asset encoded size mismatch");
|
|
609
|
+
const binary = atob(contentBase64);
|
|
610
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
611
|
+
if (bytes.byteLength !== asset.size) throw new Error("plugin asset size mismatch");
|
|
612
|
+
if (!crypto || !crypto.subtle) throw new Error("plugin asset SHA-256 verification is unavailable");
|
|
613
|
+
const digestBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
614
|
+
const actualDigest = "sha256:" + Array.from(digestBytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
615
|
+
if (actualDigest !== asset.sha256) throw new Error("plugin asset bytes failed SHA-256 verification");
|
|
616
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: asset.content_type }));
|
|
617
|
+
blobURLs.add(url);
|
|
618
|
+
document.documentElement.style.setProperty("--redevplugin-asset-" + asset.binding_id, 'url("' + url.replaceAll('"', '%22') + '")');
|
|
619
|
+
for (const element of document.querySelectorAll('[data-redevplugin-asset-binding="' + asset.binding_id + '"]')) {
|
|
620
|
+
const attribute = element.getAttribute("data-redevplugin-asset-attr");
|
|
621
|
+
if (attribute === "src" || attribute === "poster") element.setAttribute(attribute, url);
|
|
622
|
+
element.removeAttribute("data-redevplugin-asset-binding");
|
|
623
|
+
element.removeAttribute("data-redevplugin-asset-attr");
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
const workerRuntime = () => [
|
|
627
|
+
'{',
|
|
628
|
+
'"use strict";',
|
|
629
|
+
'const __rpTimer = setTimeout(() => close(), 30000);',
|
|
630
|
+
'const __rpListener = (event) => {',
|
|
631
|
+
' const value = event.data;',
|
|
632
|
+
' if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join(",") !== "port_roles,surface_handle,type,ui_protocol_version" || value.type !== "redevplugin.worker.initialize" || value.ui_protocol_version !== ' + JSON.stringify(protocolVersion) + ' || typeof value.surface_handle !== "string" || !Array.isArray(value.port_roles) || value.port_roles.join(",") !== "runtime_control,plugin_bridge" || !event.ports || event.ports.length !== 2) return;',
|
|
633
|
+
' clearTimeout(__rpTimer); removeEventListener("message", __rpListener);',
|
|
634
|
+
' const __rpSurfaceHandle = value.surface_handle;',
|
|
635
|
+
' const __rpControlPort = event.ports[0];',
|
|
636
|
+
' const __rpPort = event.ports[1];',
|
|
637
|
+
'const __rpControlPost = __rpControlPort.postMessage.bind(__rpControlPort);',
|
|
638
|
+
'const __rpControlStart = __rpControlPort.start.bind(__rpControlPort);',
|
|
639
|
+
'const __rpControlClose = __rpControlPort.close.bind(__rpControlPort);',
|
|
640
|
+
'const __rpGetPrototypeOf = Object.getPrototypeOf;',
|
|
641
|
+
'const __rpGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;',
|
|
642
|
+
'const __rpDefineProperty = Object.defineProperty;',
|
|
643
|
+
'const __rpHasOwn = Object.prototype.hasOwnProperty;',
|
|
644
|
+
'const __rpBlocked = () => { throw new TypeError("API is unavailable in the ReDevPlugin worker sandbox"); };',
|
|
645
|
+
'const __rpSealDescriptor = (owner, name, value) => {',
|
|
646
|
+
' const descriptor = __rpGetOwnPropertyDescriptor(owner, name);',
|
|
647
|
+
' if (!descriptor) return;',
|
|
648
|
+
' if (descriptor.configurable) __rpDefineProperty(owner, name, { configurable: false, enumerable: Boolean(descriptor.enumerable), writable: false, value });',
|
|
649
|
+
' else if (Object.prototype.hasOwnProperty.call(descriptor, "value") && descriptor.writable) __rpDefineProperty(owner, name, { writable: false, value });',
|
|
650
|
+
' else if (!Object.prototype.hasOwnProperty.call(descriptor, "value") || descriptor.value !== value) throw new TypeError("Unable to seal worker API " + name);',
|
|
651
|
+
' const sealed = __rpGetOwnPropertyDescriptor(owner, name);',
|
|
652
|
+
' if (!sealed || !Object.prototype.hasOwnProperty.call(sealed, "value") || sealed.configurable || sealed.writable || sealed.value !== value) throw new TypeError("Worker API remained available: " + name);',
|
|
653
|
+
'};',
|
|
654
|
+
'const __rpSealChain = (target, name, value) => {',
|
|
655
|
+
' let current = target;',
|
|
656
|
+
' let found = false;',
|
|
657
|
+
' while (current) {',
|
|
658
|
+
' if (__rpHasOwn.call(current, name)) { found = true; __rpSealDescriptor(current, name, value); }',
|
|
659
|
+
' current = __rpGetPrototypeOf(current);',
|
|
660
|
+
' }',
|
|
661
|
+
' if (!found || !__rpHasOwn.call(target, name)) __rpDefineProperty(target, name, { configurable: false, enumerable: false, writable: false, value });',
|
|
662
|
+
' __rpSealDescriptor(target, name, value);',
|
|
663
|
+
'};',
|
|
664
|
+
'const __rpSealMessagePortMethod = (name) => {',
|
|
665
|
+
' let current = __rpGetPrototypeOf(__rpControlPort);',
|
|
666
|
+
' let found = false;',
|
|
667
|
+
' while (current) {',
|
|
668
|
+
' const descriptor = __rpGetOwnPropertyDescriptor(current, name);',
|
|
669
|
+
' if (descriptor && __rpHasOwn.call(descriptor, "value") && typeof descriptor.value === "function") { found = true; __rpSealDescriptor(current, name, descriptor.value); }',
|
|
670
|
+
' current = __rpGetPrototypeOf(current);',
|
|
671
|
+
' }',
|
|
672
|
+
' if (!found) throw new TypeError("MessagePort method is unavailable: " + name);',
|
|
673
|
+
'};',
|
|
674
|
+
'try {',
|
|
675
|
+
' for (const name of ["postMessage", "start", "close", "addEventListener", "removeEventListener"]) __rpSealMessagePortMethod(name);',
|
|
676
|
+
' for (const [name, value] of Object.entries({fetch:__rpBlocked,XMLHttpRequest:undefined,WebSocket:undefined,EventSource:undefined,WebTransport:undefined,Worker:undefined,SharedWorker:undefined,indexedDB:undefined,caches:undefined,RTCPeerConnection:undefined,webkitRTCPeerConnection:undefined,BroadcastChannel:undefined,importScripts:undefined,postMessage:undefined,eval:undefined,Function:undefined,Blob:undefined,File:undefined,FileReader:undefined,FileReaderSync:undefined})) __rpSealChain(globalThis, name, value);',
|
|
677
|
+
' for (const [name, value] of Object.entries({storage:undefined,sendBeacon:undefined,serviceWorker:undefined})) __rpSealChain(navigator, name, value);',
|
|
678
|
+
' const __rpFunctionPrototypes = [__rpGetPrototypeOf(function() {}), __rpGetPrototypeOf(async function() {}), __rpGetPrototypeOf(function*() {}), __rpGetPrototypeOf(async function*() {})];',
|
|
679
|
+
' for (const prototype of __rpFunctionPrototypes) __rpSealDescriptor(prototype, "constructor", undefined);',
|
|
680
|
+
'} catch (error) {',
|
|
681
|
+
' try { __rpControlPost({ type: "redevplugin.worker.error", error: String(error && error.message || error).slice(0, 512) }); } catch {}',
|
|
682
|
+
' try { __rpControlClose(); } catch {}',
|
|
683
|
+
' try { __rpPort.close(); } catch {}',
|
|
684
|
+
' close();',
|
|
685
|
+
' return;',
|
|
686
|
+
'}',
|
|
687
|
+
'const __rpVerifyDynamicImportBlocked = async () => {',
|
|
688
|
+
' const specifier = "data:text/javascript,export default 1";',
|
|
689
|
+
' try {',
|
|
690
|
+
' await import(specifier);',
|
|
691
|
+
' return false;',
|
|
692
|
+
' } catch {',
|
|
693
|
+
' return true;',
|
|
694
|
+
' }',
|
|
695
|
+
'};',
|
|
696
|
+
'let __rpClaimed = false;',
|
|
697
|
+
'const __rpBridge = Object.freeze({ claim() { if (__rpClaimed) return undefined; __rpClaimed = true; return Object.freeze({ surfaceHandle: __rpSurfaceHandle, port: __rpPort }); } });',
|
|
698
|
+
'Object.defineProperty(globalThis, ' + JSON.stringify(workerGlobalKey) + ', { configurable: false, enumerable: false, writable: false, value: __rpBridge });',
|
|
699
|
+
'__rpControlStart();',
|
|
700
|
+
'__rpPort.start();',
|
|
701
|
+
'let __rpFailed = false;',
|
|
702
|
+
'const __rpReportFailure = (error) => { if (__rpFailed) return; __rpFailed = true; __rpControlPost({ type: "redevplugin.worker.error", error: String(error && error.message || error).slice(0, 512) }); };',
|
|
703
|
+
'addEventListener("error", (event) => __rpReportFailure(event.error || event.message || "plugin worker failed"), { capture: true });',
|
|
704
|
+
'addEventListener("unhandledrejection", (event) => __rpReportFailure(event.reason || "plugin worker rejected"), { capture: true });',
|
|
705
|
+
'void (async () => {',
|
|
706
|
+
' try {',
|
|
707
|
+
' if (!await __rpVerifyDynamicImportBlocked()) throw new TypeError("Dynamic import escaped the ReDevPlugin worker sandbox");',
|
|
708
|
+
' __redevpluginPluginMain();',
|
|
709
|
+
' setTimeout(() => { if (!__rpFailed) __rpControlPost({ type: "redevplugin.worker.ready" }); }, 0);',
|
|
710
|
+
' } catch (error) {',
|
|
711
|
+
' __rpReportFailure(error);',
|
|
712
|
+
' }',
|
|
713
|
+
'})();',
|
|
714
|
+
'};',
|
|
715
|
+
'addEventListener("message", __rpListener);',
|
|
716
|
+
'}'
|
|
717
|
+
].join("\\n");
|
|
718
|
+
const startWorker = (surfaceDocument) => {
|
|
719
|
+
const source = 'const __redevpluginPluginMain = () => {\\n"use strict";\\n' + surfaceDocument.worker.content + '\\n};\\n' + workerRuntime();
|
|
720
|
+
const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
|
|
721
|
+
blobURLs.add(url);
|
|
722
|
+
worker = new Worker(url, { name: "redevplugin-surface" });
|
|
723
|
+
worker.addEventListener("error", (event) => fail(event.message || "plugin worker failed to load"));
|
|
724
|
+
worker.addEventListener("messageerror", () => fail("plugin worker message could not be decoded"));
|
|
725
|
+
const controlChannel = new MessageChannel();
|
|
726
|
+
const pluginChannel = new MessageChannel();
|
|
727
|
+
workerControlPort = controlChannel.port1;
|
|
728
|
+
workerControlPort.addEventListener("message", onWorkerControlMessage);
|
|
729
|
+
workerControlPort.start();
|
|
730
|
+
workerPort = pluginChannel.port1;
|
|
731
|
+
workerPort.addEventListener("message", onWorkerMessage);
|
|
732
|
+
workerPort.start();
|
|
733
|
+
worker.postMessage({ type: "redevplugin.worker.initialize", surface_handle: surfaceHandle, ui_protocol_version: protocolVersion, port_roles: ["runtime_control", "plugin_bridge"] }, [controlChannel.port2, pluginChannel.port2]);
|
|
734
|
+
};
|
|
735
|
+
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));
|
|
736
|
+
const requestID = (value, expectedKind) => {
|
|
737
|
+
if (typeof value !== "string") return undefined;
|
|
738
|
+
const match = /^(rpc|stream|render)_([1-9][0-9]{0,15})$/.exec(value);
|
|
739
|
+
if (!match || match[1] !== expectedKind) return undefined;
|
|
740
|
+
const sequence = Number(match[2]);
|
|
741
|
+
return Number.isSafeInteger(sequence) ? { kind: match[1], sequence } : undefined;
|
|
742
|
+
};
|
|
743
|
+
const acceptWorkerRequest = (id, kind) => {
|
|
744
|
+
const parsed = requestID(id, kind);
|
|
745
|
+
if (!parsed || parsed.sequence <= requestSequence[kind] || pendingWorkerRequests.size >= maxInFlightRequests) return false;
|
|
746
|
+
requestSequence[kind] = parsed.sequence;
|
|
747
|
+
pendingWorkerRequests.add(id);
|
|
748
|
+
return true;
|
|
749
|
+
};
|
|
750
|
+
const completeWorkerRequest = (id) => pendingWorkerRequests.delete(id);
|
|
751
|
+
const renderRateAllowed = () => {
|
|
752
|
+
const now = performance.now();
|
|
753
|
+
if (now - renderWindowStartedAt >= 1000) { renderWindowStartedAt = now; renderCount = 0; }
|
|
754
|
+
if (renderCount >= maxRendersPerSecond) return false;
|
|
755
|
+
renderCount += 1;
|
|
756
|
+
return true;
|
|
757
|
+
};
|
|
758
|
+
const rejectWorkerRequest = (id, error) => sendWorker({ type: "redevplugin.bridge.response", id, ok: false, error_code: "PLUGIN_INVALID_REQUEST", error });
|
|
759
|
+
const onWorkerControlMessage = (event) => {
|
|
760
|
+
if (!withinLimit(event.data)) return;
|
|
761
|
+
const message = event.data;
|
|
762
|
+
if (exactKeys(message, ["type"]) && message.type === "redevplugin.worker.ready") {
|
|
763
|
+
if (workerReady) return;
|
|
764
|
+
workerReady = true;
|
|
765
|
+
sendParent({ type: "redevplugin.surface.worker_ready" });
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (exactKeys(message, ["type", "error"]) && message.type === "redevplugin.worker.error" && typeof message.error === "string") {
|
|
769
|
+
fail(message.error);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
const onWorkerMessage = (event) => {
|
|
774
|
+
if (!withinLimit(event.data)) return;
|
|
775
|
+
const message = event.data;
|
|
776
|
+
if (validCall(message)) {
|
|
777
|
+
if (!acceptWorkerRequest(message.request.id, "rpc")) return rejectWorkerRequest(message.request.id, "duplicate, replayed, or excessive plugin request");
|
|
778
|
+
sendParent(message);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (exactKeys(message, ["type", "id", "stream_handle"]) && message.type === "redevplugin.bridge.stream.read" && typeof message.id === "string" && validOpaqueHandle(message.stream_handle, "stream")) {
|
|
782
|
+
if (!acceptWorkerRequest(message.id, "stream")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
|
|
783
|
+
sendParent(message);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (exactKeys(message, ["type", "id", "tree"]) && message.type === "redevplugin.ui.render" && typeof message.id === "string") {
|
|
787
|
+
if (!acceptWorkerRequest(message.id, "render")) return rejectWorkerRequest(message.id, "duplicate, replayed, or excessive plugin request");
|
|
788
|
+
if (!renderRateAllowed()) {
|
|
789
|
+
completeWorkerRequest(message.id);
|
|
790
|
+
return rejectWorkerRequest(message.id, "plugin render rate limit exceeded");
|
|
791
|
+
}
|
|
792
|
+
try {
|
|
793
|
+
applyWorkerRender(message.tree);
|
|
794
|
+
completeWorkerRequest(message.id);
|
|
795
|
+
sendWorker({ type: "redevplugin.bridge.response", id: message.id, ok: true });
|
|
796
|
+
} catch (error) {
|
|
797
|
+
completeWorkerRequest(message.id);
|
|
798
|
+
sendWorker({ type: "redevplugin.bridge.response", id: message.id, ok: false, error_code: "PLUGIN_CONTRACT_MISMATCH", error: String(error && error.message || error).slice(0, 512) });
|
|
799
|
+
}
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (exactKeys(message, ["type", "id"]) && message.type === "redevplugin.bridge.cancel" && typeof message.id === "string") {
|
|
803
|
+
if (!pendingWorkerRequests.has(message.id)) return rejectWorkerRequest(message.id, "plugin request is not pending");
|
|
804
|
+
completeWorkerRequest(message.id);
|
|
805
|
+
sendParent(message);
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
const onParentMessage = async (event) => {
|
|
809
|
+
const message = event.data;
|
|
810
|
+
if (!initialized) {
|
|
811
|
+
if (!exactKeys(message, ["type", "frame_generation_id", "surface_handle", "document"]) || message.type !== "redevplugin.surface.initialize" || message.frame_generation_id !== frameGenerationID || !validOpaqueHandle(message.surface_handle, "surface") || !validDocument(message.document)) return fail("invalid private initialize message");
|
|
812
|
+
initialized = true;
|
|
813
|
+
surfaceHandle = message.surface_handle;
|
|
814
|
+
currentDocument = message.document;
|
|
815
|
+
try { applyStaticDocument(currentDocument); startWorker(currentDocument); }
|
|
816
|
+
catch (error) { return fail(error); }
|
|
817
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
818
|
+
sendParent({ type: "redevplugin.surface.first_paint" });
|
|
819
|
+
loadAssets();
|
|
820
|
+
}));
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (message && message.type === "redevplugin.bridge.response" && typeof message.id === "string" && pendingWorkerRequests.has(message.id) && withinLimit(message)) {
|
|
824
|
+
completeWorkerRequest(message.id);
|
|
825
|
+
sendWorker(message);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (message && message.type === "redevplugin.bridge.lifecycle" && isRecord(message.event) && ["ready", "visible", "hidden", "dispose"].includes(message.event.type)) {
|
|
829
|
+
sendWorker(message);
|
|
830
|
+
if (message.event.type === "dispose") disposeRuntime();
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (exactKeys(message, ["type", "request_id", "binding_id", "ok", "path", "sha256", "content_type", "content_base64"]) && message.type === "redevplugin.surface.asset.response" && typeof message.content_base64 === "string" && message.content_base64.length <= maxPrivateAssetBase64Length) {
|
|
834
|
+
const asset = pendingAssets.get(message.request_id);
|
|
835
|
+
pendingAssets.delete(message.request_id);
|
|
836
|
+
if (asset) activeAssetReads = Math.max(0, activeAssetReads - 1);
|
|
837
|
+
if (!asset || message.ok !== true || message.binding_id !== asset.binding_id || message.path !== asset.path || message.sha256 !== asset.sha256 || message.content_type !== asset.content_type || typeof message.content_base64 !== "string") {
|
|
838
|
+
fail("plugin asset response did not match the prepared document");
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
try {
|
|
842
|
+
await applyAsset(asset, message.content_base64);
|
|
843
|
+
} catch {
|
|
844
|
+
fail("plugin asset response failed renderer validation");
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
pumpAssets();
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
const onWindowMessage = (event) => {
|
|
851
|
+
if (accepted || event.source !== parent || !event.ports || event.ports.length !== 1) return;
|
|
852
|
+
const message = event.data;
|
|
853
|
+
if (!exactKeys(message, ["type", "frame_generation_id", "ui_protocol_version"]) || message.type !== "redevplugin.surface.port" || message.ui_protocol_version !== protocolVersion || !validOpaqueHandle(message.frame_generation_id, "frame")) return;
|
|
854
|
+
accepted = true;
|
|
855
|
+
frameGenerationID = message.frame_generation_id;
|
|
856
|
+
removeEventListener("message", onWindowMessage);
|
|
857
|
+
parentPort = event.ports[0];
|
|
858
|
+
parentPort.addEventListener("message", onParentMessage);
|
|
859
|
+
parentPort.start();
|
|
860
|
+
parentPort.postMessage({ type: "redevplugin.surface.port_ack", frame_generation_id: frameGenerationID });
|
|
861
|
+
};
|
|
862
|
+
addEventListener("message", onWindowMessage);
|
|
863
|
+
})();`;
|
|
864
|
+
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>`;
|
|
865
|
+
}
|
|
866
|
+
const pluginSurfaceHostConstructorToken = Symbol("redevplugin.surface-host.constructor");
|
|
867
|
+
export class PluginSurfaceHost {
|
|
868
|
+
element;
|
|
869
|
+
bootstrap;
|
|
870
|
+
bridgeChannelId;
|
|
871
|
+
frameGenerationId;
|
|
872
|
+
surfaceHandle;
|
|
873
|
+
#iframe;
|
|
874
|
+
#transport;
|
|
875
|
+
#createMessageChannel;
|
|
876
|
+
#loadTimeoutMs;
|
|
877
|
+
#requestTimeoutMs;
|
|
878
|
+
#leaseRenewalLeadMs;
|
|
879
|
+
#reloadLimiter;
|
|
880
|
+
#confirm;
|
|
881
|
+
#onOpeningProgress;
|
|
882
|
+
#onError;
|
|
883
|
+
#abortController = new AbortController();
|
|
884
|
+
#gatewayToken;
|
|
885
|
+
#leaseExpiresAtMs = 0;
|
|
886
|
+
#leaseRenewAtMs = 0;
|
|
887
|
+
#leaseRenewalTimer;
|
|
888
|
+
#leaseRenewalPromise;
|
|
889
|
+
#assetSession;
|
|
890
|
+
#assetSessionID;
|
|
891
|
+
#document;
|
|
892
|
+
#assets = new Map();
|
|
893
|
+
#streamCredentials = new Map();
|
|
894
|
+
#pendingRequestControllers = new Map();
|
|
895
|
+
#activeTransportRequests = 0;
|
|
896
|
+
#activeAssetReads = 0;
|
|
897
|
+
#transportIdle;
|
|
898
|
+
#port;
|
|
899
|
+
#openSignals;
|
|
900
|
+
#initialFrameLoad;
|
|
901
|
+
#frameLoaded = false;
|
|
902
|
+
#revokePromise;
|
|
903
|
+
#unregisterSurfaceScope;
|
|
904
|
+
#opened = false;
|
|
905
|
+
#ready = false;
|
|
906
|
+
#disposed = false;
|
|
907
|
+
#onPortMessage = (event) => {
|
|
908
|
+
void this.#handlePortMessage(event);
|
|
909
|
+
};
|
|
910
|
+
#onFrameLoad = () => {
|
|
911
|
+
if (this.#disposed || !this.#opened)
|
|
912
|
+
return;
|
|
913
|
+
if (!this.#frameLoaded) {
|
|
914
|
+
this.#frameLoaded = true;
|
|
915
|
+
this.#initialFrameLoad?.resolve();
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
const decision = this.#reloadLimiter.recordCrash();
|
|
919
|
+
const error = new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_FAILED", `Plugin iframe reloaded unexpectedly (attempt ${decision.attempt})`);
|
|
920
|
+
void this.#failSurface(error);
|
|
921
|
+
};
|
|
922
|
+
static create(options) {
|
|
923
|
+
validateHostBootstrap(options.bootstrap);
|
|
924
|
+
const transport = surfaceTransportInternals.get(options.hostTransport);
|
|
925
|
+
if (!transport) {
|
|
926
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface host transport is invalid");
|
|
927
|
+
}
|
|
928
|
+
return new PluginSurfaceHost(pluginSurfaceHostConstructorToken, options, transport, createPluginSurfaceFrame());
|
|
929
|
+
}
|
|
930
|
+
constructor(constructorToken, options, transport, iframe) {
|
|
931
|
+
if (constructorToken !== pluginSurfaceHostConstructorToken) {
|
|
932
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface hosts must be created through PluginSurfaceHost.create()");
|
|
933
|
+
}
|
|
934
|
+
this.element = iframe;
|
|
935
|
+
this.bootstrap = options.bootstrap;
|
|
936
|
+
this.bridgeChannelId = options.bridgeChannelId ?? randomOpaqueHandle("bridge");
|
|
937
|
+
this.frameGenerationId = randomOpaqueHandle("frame");
|
|
938
|
+
this.surfaceHandle = randomOpaqueHandle("surface");
|
|
939
|
+
this.#iframe = iframe;
|
|
940
|
+
this.#transport = transport;
|
|
941
|
+
this.#createMessageChannel = captureMessageChannelFactory();
|
|
942
|
+
this.#loadTimeoutMs = normalizeTimeout(options.loadTimeoutMs);
|
|
943
|
+
this.#requestTimeoutMs = normalizeTimeout(options.requestTimeoutMs);
|
|
944
|
+
this.#leaseRenewalLeadMs = normalizeLeaseRenewalLead(options.leaseRenewalLeadMs);
|
|
945
|
+
this.#reloadLimiter = options.reloadLimiter ?? new PluginSurfaceReloadLimiter();
|
|
946
|
+
this.#confirm = options.confirm;
|
|
947
|
+
this.#onOpeningProgress = options.onOpeningProgress;
|
|
948
|
+
this.#onError = options.onError;
|
|
949
|
+
hardenPluginSurfaceFrame(this.#iframe);
|
|
950
|
+
this.#unregisterSurfaceScope = registerPluginSurface(options.surfaceScope ?? defaultPluginSurfaceScope, this.bootstrap.pluginInstanceId, () => this.#disposeLocal());
|
|
951
|
+
}
|
|
952
|
+
async open() {
|
|
953
|
+
this.#assertActive();
|
|
954
|
+
if (this.#opened) {
|
|
955
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_FAILED", "Plugin surface host is already open");
|
|
956
|
+
}
|
|
957
|
+
this.#opened = true;
|
|
958
|
+
const startedAt = Date.now();
|
|
959
|
+
const progressTimer = setTimeout(() => {
|
|
960
|
+
if (!this.#ready && !this.#disposed) {
|
|
961
|
+
this.#onOpeningProgress?.({
|
|
962
|
+
phase: "opening",
|
|
963
|
+
elapsedMs: Math.max(openingProgressDelayMs, Date.now() - startedAt),
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
}, openingProgressDelayMs);
|
|
967
|
+
const signals = { portAcknowledged: deferred(), firstPaint: deferred(), workerReady: deferred() };
|
|
968
|
+
void signals.portAcknowledged.promise.catch(() => undefined);
|
|
969
|
+
void signals.firstPaint.promise.catch(() => undefined);
|
|
970
|
+
void signals.workerReady.promise.catch(() => undefined);
|
|
971
|
+
this.#openSignals = signals;
|
|
972
|
+
const scriptNonce = randomOpaqueNonce();
|
|
973
|
+
this.#frameLoaded = false;
|
|
974
|
+
const initialFrameLoad = deferred();
|
|
975
|
+
this.#initialFrameLoad = initialFrameLoad;
|
|
976
|
+
this.#iframe.addEventListener("load", this.#onFrameLoad);
|
|
977
|
+
hardenPluginSurfaceFrame(this.#iframe);
|
|
978
|
+
this.#iframe.srcdoc = createOpaquePluginBootstrapHTML({ scriptNonce });
|
|
979
|
+
try {
|
|
980
|
+
await withTimeout(this.#completeOpening(signals, initialFrameLoad.promise), this.#loadTimeoutMs, "Plugin surface opening timed out");
|
|
981
|
+
}
|
|
982
|
+
catch (error) {
|
|
983
|
+
const bridgeError = toBridgeError(error, "PLUGIN_BRIDGE_HANDSHAKE_FAILED");
|
|
984
|
+
if (bridgeError.errorCode === "PLUGIN_BRIDGE_TIMEOUT")
|
|
985
|
+
this.#reloadLimiter.recordCrash();
|
|
986
|
+
this.#reportError(bridgeError);
|
|
987
|
+
const revoke = this.#bestEffortRevokeSurface(false);
|
|
988
|
+
this.#disposeLocal();
|
|
989
|
+
await revoke;
|
|
990
|
+
throw bridgeError;
|
|
991
|
+
}
|
|
992
|
+
finally {
|
|
993
|
+
clearTimeout(progressTimer);
|
|
994
|
+
this.#openSignals = undefined;
|
|
995
|
+
this.#initialFrameLoad = undefined;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
async #completeOpening(signals, initialFrameLoad) {
|
|
999
|
+
const [preparation] = await Promise.all([this.#prepareSurface(), initialFrameLoad]);
|
|
1000
|
+
this.#assertActive();
|
|
1001
|
+
validateSurfacePreparation(this.bootstrap, preparation);
|
|
1002
|
+
this.#assetSession = preparation.asset_session;
|
|
1003
|
+
this.#assetSessionID = preparation.asset_session_id;
|
|
1004
|
+
this.#document = preparation.document;
|
|
1005
|
+
this.#assets = new Map(preparation.document.assets.map((asset) => [asset.binding_id, asset]));
|
|
1006
|
+
const channel = this.#createMessageChannel();
|
|
1007
|
+
this.#port = channel.port1;
|
|
1008
|
+
this.#port.addEventListener("message", this.#onPortMessage);
|
|
1009
|
+
this.#port.start();
|
|
1010
|
+
const iframeWindow = this.#iframe.contentWindow;
|
|
1011
|
+
if (!iframeWindow) {
|
|
1012
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_FAILED", "Plugin iframe window is unavailable");
|
|
1013
|
+
}
|
|
1014
|
+
iframeWindow.postMessage({
|
|
1015
|
+
type: "redevplugin.surface.port",
|
|
1016
|
+
frame_generation_id: this.frameGenerationId,
|
|
1017
|
+
ui_protocol_version: pluginUIProtocolVersion,
|
|
1018
|
+
}, "*", [channel.port2]);
|
|
1019
|
+
await signals.portAcknowledged.promise;
|
|
1020
|
+
this.#assertActive();
|
|
1021
|
+
const token = await this.#mintBridgeToken();
|
|
1022
|
+
this.#assertActive();
|
|
1023
|
+
this.#applyLease(token);
|
|
1024
|
+
this.#postToRenderer({
|
|
1025
|
+
type: "redevplugin.surface.initialize",
|
|
1026
|
+
frame_generation_id: this.frameGenerationId,
|
|
1027
|
+
surface_handle: this.surfaceHandle,
|
|
1028
|
+
document: preparation.document,
|
|
1029
|
+
});
|
|
1030
|
+
await Promise.all([signals.firstPaint.promise, signals.workerReady.promise]);
|
|
1031
|
+
this.#assertActive();
|
|
1032
|
+
this.#ready = true;
|
|
1033
|
+
this.#scheduleLeaseRenewal();
|
|
1034
|
+
this.#reloadLimiter.recordHealthyLoad();
|
|
1035
|
+
this.#postToRenderer({ type: "redevplugin.bridge.lifecycle", event: { type: "ready" } });
|
|
1036
|
+
}
|
|
1037
|
+
sendLifecycle(event) {
|
|
1038
|
+
this.#assertReady();
|
|
1039
|
+
this.#postToRenderer({ type: "redevplugin.bridge.lifecycle", event });
|
|
1040
|
+
}
|
|
1041
|
+
async close() {
|
|
1042
|
+
if (this.#disposed)
|
|
1043
|
+
return;
|
|
1044
|
+
const revoke = this.#revokeSurface(false);
|
|
1045
|
+
this.#disposeLocal();
|
|
1046
|
+
try {
|
|
1047
|
+
await revoke;
|
|
1048
|
+
}
|
|
1049
|
+
catch (error) {
|
|
1050
|
+
throw toBridgeError(error, "PLUGIN_BRIDGE_DISPOSED");
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
dispose() {
|
|
1054
|
+
if (this.#disposed)
|
|
1055
|
+
return;
|
|
1056
|
+
void this.#bestEffortRevokeSurface(true);
|
|
1057
|
+
this.#disposeLocal();
|
|
1058
|
+
}
|
|
1059
|
+
#disposeLocal() {
|
|
1060
|
+
if (this.#disposed)
|
|
1061
|
+
return;
|
|
1062
|
+
this.#disposed = true;
|
|
1063
|
+
this.#unregisterSurfaceScope?.();
|
|
1064
|
+
this.#unregisterSurfaceScope = undefined;
|
|
1065
|
+
this.#ready = false;
|
|
1066
|
+
if (this.#leaseRenewalTimer)
|
|
1067
|
+
clearTimeout(this.#leaseRenewalTimer);
|
|
1068
|
+
this.#leaseRenewalTimer = undefined;
|
|
1069
|
+
this.#iframe.removeEventListener("load", this.#onFrameLoad);
|
|
1070
|
+
this.#abortController.abort();
|
|
1071
|
+
for (const controller of this.#pendingRequestControllers.values())
|
|
1072
|
+
controller.abort();
|
|
1073
|
+
this.#pendingRequestControllers.clear();
|
|
1074
|
+
const disposedError = new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin surface host was disposed");
|
|
1075
|
+
this.#openSignals?.firstPaint.reject(disposedError);
|
|
1076
|
+
this.#openSignals?.workerReady.reject(disposedError);
|
|
1077
|
+
this.#openSignals?.portAcknowledged.reject(disposedError);
|
|
1078
|
+
this.#initialFrameLoad?.reject(disposedError);
|
|
1079
|
+
this.#gatewayToken = undefined;
|
|
1080
|
+
this.#leaseExpiresAtMs = 0;
|
|
1081
|
+
this.#assetSession = undefined;
|
|
1082
|
+
this.#assetSessionID = undefined;
|
|
1083
|
+
this.#document = undefined;
|
|
1084
|
+
this.#assets.clear();
|
|
1085
|
+
this.#streamCredentials.clear();
|
|
1086
|
+
if (this.#port) {
|
|
1087
|
+
try {
|
|
1088
|
+
this.#port.postMessage({ type: "redevplugin.bridge.lifecycle", event: { type: "dispose" } });
|
|
1089
|
+
}
|
|
1090
|
+
catch {
|
|
1091
|
+
// The sandbox may already have closed its end of the channel.
|
|
1092
|
+
}
|
|
1093
|
+
this.#port.removeEventListener("message", this.#onPortMessage);
|
|
1094
|
+
this.#port.close();
|
|
1095
|
+
this.#port = undefined;
|
|
1096
|
+
}
|
|
1097
|
+
this.#iframe.srcdoc = "";
|
|
1098
|
+
}
|
|
1099
|
+
async #handlePortMessage(event) {
|
|
1100
|
+
if (this.#disposed || !messageWithinLimit(event.data))
|
|
1101
|
+
return;
|
|
1102
|
+
const data = event.data;
|
|
1103
|
+
try {
|
|
1104
|
+
if (hasExactKeys(data, ["type"]) && data.type === "redevplugin.surface.first_paint") {
|
|
1105
|
+
this.#openSignals?.firstPaint.resolve();
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
if (hasExactKeys(data, ["type", "frame_generation_id"]) &&
|
|
1109
|
+
data.type === "redevplugin.surface.port_ack" && data.frame_generation_id === this.frameGenerationId) {
|
|
1110
|
+
this.#openSignals?.portAcknowledged.resolve();
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (hasExactKeys(data, ["type"]) && data.type === "redevplugin.surface.worker_ready") {
|
|
1114
|
+
this.#openSignals?.workerReady.resolve();
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
if (hasExactKeys(data, ["type", "error"]) && data.type === "redevplugin.surface.error" && typeof data.error === "string") {
|
|
1118
|
+
const error = new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_FAILED", data.error);
|
|
1119
|
+
await this.#failSurface(error);
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
if (isBridgeCancelMessage(data)) {
|
|
1123
|
+
this.#pendingRequestControllers.get(data.id)?.abort();
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
if (isBridgeCallMessage(data)) {
|
|
1127
|
+
await this.#handleCall(data.request);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (isStreamReadMessage(data)) {
|
|
1131
|
+
await this.#handleStreamRead(data.id, data.stream_handle);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
if (isAssetReadMessage(data)) {
|
|
1135
|
+
await this.#handleAssetRead(data);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
catch (error) {
|
|
1139
|
+
await this.#failSurface(toBridgeError(error, "PLUGIN_CONTRACT_MISMATCH"));
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
async #handleCall(request) {
|
|
1143
|
+
if (!this.#ready || !this.#gatewayToken) {
|
|
1144
|
+
this.#postError(request.id, "PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "Plugin bridge call arrived before the surface became ready");
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const controller = this.#registerPendingRequest(request.id);
|
|
1148
|
+
try {
|
|
1149
|
+
const result = await this.#callRPC(request, undefined, controller.signal);
|
|
1150
|
+
if (!controller.signal.aborted && !this.#disposed)
|
|
1151
|
+
this.#postResponse(request.id, this.#publicMethodResult(result));
|
|
1152
|
+
}
|
|
1153
|
+
catch (error) {
|
|
1154
|
+
if (controller.signal.aborted || this.#disposed)
|
|
1155
|
+
return;
|
|
1156
|
+
const bridgeError = toBridgeError(error, "PLUGIN_PERMISSION_DENIED");
|
|
1157
|
+
if (bridgeError.errorCode === "PLUGIN_CONFIRMATION_REQUIRED") {
|
|
1158
|
+
await this.#handleConfirmationRequired(request, bridgeError, controller.signal);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
this.#postError(request.id, bridgeError.errorCode, bridgeError.message);
|
|
1162
|
+
}
|
|
1163
|
+
finally {
|
|
1164
|
+
this.#pendingRequestControllers.delete(request.id);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
async #handleConfirmationRequired(request, originalError, signal) {
|
|
1168
|
+
if (!this.#confirm) {
|
|
1169
|
+
this.#postError(request.id, originalError.errorCode, originalError.message);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
try {
|
|
1173
|
+
const confirmation = await this.#prepareConfirmation(request, signal);
|
|
1174
|
+
const decision = await abortableConfirmationDecision(this.#confirm({
|
|
1175
|
+
requestId: request.id,
|
|
1176
|
+
method: request.method,
|
|
1177
|
+
params: request.params,
|
|
1178
|
+
requestHash: confirmation.request_hash,
|
|
1179
|
+
planHash: confirmation.plan_hash,
|
|
1180
|
+
plan: confirmation.plan,
|
|
1181
|
+
confirmationTokenId: confirmation.confirmation_token_id,
|
|
1182
|
+
signal,
|
|
1183
|
+
}), signal);
|
|
1184
|
+
if (signal.aborted || this.#disposed)
|
|
1185
|
+
return;
|
|
1186
|
+
if (!confirmationDecisionAccepted(decision)) {
|
|
1187
|
+
this.#postError(request.id, "PLUGIN_CONFIRMATION_REJECTED", "Plugin method confirmation was rejected");
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
const result = await this.#callRPC(request, confirmation.confirmation_id, signal);
|
|
1191
|
+
if (!signal.aborted && !this.#disposed)
|
|
1192
|
+
this.#postResponse(request.id, this.#publicMethodResult(result));
|
|
1193
|
+
}
|
|
1194
|
+
catch (error) {
|
|
1195
|
+
if (signal.aborted || this.#disposed)
|
|
1196
|
+
return;
|
|
1197
|
+
const bridgeError = toBridgeError(error, "PLUGIN_PERMISSION_DENIED");
|
|
1198
|
+
this.#postError(request.id, bridgeError.errorCode, bridgeError.message);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
async #handleStreamRead(id, streamHandle) {
|
|
1202
|
+
const credential = this.#streamCredentials.get(streamHandle);
|
|
1203
|
+
this.#streamCredentials.delete(streamHandle);
|
|
1204
|
+
if (!credential) {
|
|
1205
|
+
this.#postError(id, "PLUGIN_STREAM_TICKET_INVALID", "Plugin stream handle is invalid or already consumed");
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (credential.expiresAtMs <= Date.now()) {
|
|
1209
|
+
this.#postError(id, "PLUGIN_STREAM_TICKET_INVALID", "Plugin stream handle is expired");
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const controller = this.#registerPendingRequest(id);
|
|
1213
|
+
try {
|
|
1214
|
+
const result = await this.#postJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/streams/read`, { stream_id: credential.streamID, stream_ticket: credential.streamTicket }, controller.signal);
|
|
1215
|
+
if (!isStreamReadResult(result, credential.streamID)) {
|
|
1216
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin stream endpoint returned an invalid response");
|
|
1217
|
+
}
|
|
1218
|
+
if (!controller.signal.aborted && !this.#disposed)
|
|
1219
|
+
this.#postResponse(id, result.events);
|
|
1220
|
+
}
|
|
1221
|
+
catch (error) {
|
|
1222
|
+
if (controller.signal.aborted || this.#disposed)
|
|
1223
|
+
return;
|
|
1224
|
+
const bridgeError = toBridgeError(error, "PLUGIN_RUNTIME_UNAVAILABLE");
|
|
1225
|
+
this.#postError(id, bridgeError.errorCode, bridgeError.message);
|
|
1226
|
+
}
|
|
1227
|
+
finally {
|
|
1228
|
+
this.#pendingRequestControllers.delete(id);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
async #handleAssetRead(message) {
|
|
1232
|
+
if (this.#activeAssetReads >= maxConcurrentAssetReads) {
|
|
1233
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin asset reads exceed the concurrency limit");
|
|
1234
|
+
}
|
|
1235
|
+
const asset = this.#assets.get(message.binding_id);
|
|
1236
|
+
if (!asset || asset.path !== message.path || asset.sha256 !== message.sha256 || !this.#assetSession || !this.#assetSessionID) {
|
|
1237
|
+
throw new PluginBridgeError("PLUGIN_ASSET_SESSION_INVALID", "Plugin asset request did not match the prepared surface document");
|
|
1238
|
+
}
|
|
1239
|
+
this.#activeAssetReads += 1;
|
|
1240
|
+
try {
|
|
1241
|
+
const result = await this.#postJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/assets/read`, {
|
|
1242
|
+
asset_session: this.#assetSession,
|
|
1243
|
+
asset_session_id: this.#assetSessionID,
|
|
1244
|
+
binding_id: asset.binding_id,
|
|
1245
|
+
});
|
|
1246
|
+
if (!isAssetReadResult(result) || result.path !== asset.path || result.sha256 !== asset.sha256 || result.content_type !== asset.content_type) {
|
|
1247
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin asset endpoint returned mismatched content");
|
|
1248
|
+
}
|
|
1249
|
+
this.#postToRenderer({
|
|
1250
|
+
type: "redevplugin.surface.asset.response",
|
|
1251
|
+
request_id: message.request_id,
|
|
1252
|
+
binding_id: asset.binding_id,
|
|
1253
|
+
ok: true,
|
|
1254
|
+
path: result.path,
|
|
1255
|
+
sha256: result.sha256,
|
|
1256
|
+
content_type: result.content_type,
|
|
1257
|
+
content_base64: result.content_base64,
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
finally {
|
|
1261
|
+
this.#activeAssetReads -= 1;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
#registerPendingRequest(id) {
|
|
1265
|
+
if (this.#pendingRequestControllers.has(id) || this.#pendingRequestControllers.size >= maxPendingPluginBridgeRequests) {
|
|
1266
|
+
throw new PluginBridgeError("PLUGIN_INVALID_REQUEST", "Plugin request is duplicated or exceeds the pending request limit");
|
|
1267
|
+
}
|
|
1268
|
+
const controller = new AbortController();
|
|
1269
|
+
this.#pendingRequestControllers.set(id, controller);
|
|
1270
|
+
return controller;
|
|
1271
|
+
}
|
|
1272
|
+
#publicMethodResult(result) {
|
|
1273
|
+
const publicResult = {
|
|
1274
|
+
data: result.data,
|
|
1275
|
+
operation_id: result.operation_id,
|
|
1276
|
+
confirmation_required: result.confirmation_required,
|
|
1277
|
+
confirmation_token_id: result.confirmation_token_id,
|
|
1278
|
+
request_hash: result.request_hash,
|
|
1279
|
+
};
|
|
1280
|
+
if (result.stream_id || result.stream_ticket || result.stream_ticket_id || result.stream_expires_at) {
|
|
1281
|
+
if (!result.stream_id || !result.stream_ticket || !result.stream_ticket_id || !result.stream_expires_at) {
|
|
1282
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin RPC returned incomplete stream credentials");
|
|
1283
|
+
}
|
|
1284
|
+
const expiresAtMs = Date.parse(result.stream_expires_at);
|
|
1285
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
|
|
1286
|
+
throw new PluginBridgeError("PLUGIN_STREAM_TICKET_INVALID", "Plugin RPC returned an expired stream ticket");
|
|
1287
|
+
}
|
|
1288
|
+
this.#pruneExpiredStreamCredentials();
|
|
1289
|
+
if (this.#streamCredentials.size >= maxRetainedPluginStreamHandles) {
|
|
1290
|
+
throw new PluginBridgeError("PLUGIN_JSON_LIMIT_EXCEEDED", "Plugin surface retained too many unread stream handles");
|
|
1291
|
+
}
|
|
1292
|
+
const handle = randomOpaqueHandle("stream");
|
|
1293
|
+
this.#streamCredentials.set(handle, { streamID: result.stream_id, streamTicket: result.stream_ticket, expiresAtMs });
|
|
1294
|
+
publicResult.stream_handle = handle;
|
|
1295
|
+
}
|
|
1296
|
+
return removeUndefined(publicResult);
|
|
1297
|
+
}
|
|
1298
|
+
#pruneExpiredStreamCredentials(now = Date.now()) {
|
|
1299
|
+
for (const [handle, credential] of this.#streamCredentials) {
|
|
1300
|
+
if (credential.expiresAtMs <= now)
|
|
1301
|
+
this.#streamCredentials.delete(handle);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
#callRPC(request, confirmationID, signal) {
|
|
1305
|
+
return this.#postJSON("/_redevplugin/api/plugins/rpc", this.#rpcBody(request, confirmationID), signal);
|
|
1306
|
+
}
|
|
1307
|
+
#prepareConfirmation(request, signal) {
|
|
1308
|
+
return this.#postJSON("/_redevplugin/api/plugins/confirm", this.#rpcBody(request), signal);
|
|
1309
|
+
}
|
|
1310
|
+
#rpcBody(request, confirmationID) {
|
|
1311
|
+
const body = {
|
|
1312
|
+
plugin_instance_id: this.bootstrap.pluginInstanceId,
|
|
1313
|
+
surface_instance_id: this.bootstrap.surfaceInstanceId,
|
|
1314
|
+
bridge_channel_id: this.bridgeChannelId,
|
|
1315
|
+
plugin_gateway_token: this.#gatewayToken,
|
|
1316
|
+
method: request.method,
|
|
1317
|
+
params: request.params,
|
|
1318
|
+
};
|
|
1319
|
+
if (confirmationID)
|
|
1320
|
+
body.confirmation_id = confirmationID;
|
|
1321
|
+
return body;
|
|
1322
|
+
}
|
|
1323
|
+
#prepareSurface() {
|
|
1324
|
+
return this.#postJSON(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/prepare`, { asset_ticket: this.bootstrap.assetTicket });
|
|
1325
|
+
}
|
|
1326
|
+
async #mintBridgeToken(previousGatewayToken, direct = false) {
|
|
1327
|
+
const handshake = this.#handshake();
|
|
1328
|
+
const transcript = await trustedParentBridgeHandshakeTranscriptSHA256(handshake, this.bridgeChannelId);
|
|
1329
|
+
const path = `/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/bridge-token`;
|
|
1330
|
+
const body = {
|
|
1331
|
+
bridge_channel_id: this.bridgeChannelId,
|
|
1332
|
+
handshake,
|
|
1333
|
+
handshake_transcript_sha256: transcript,
|
|
1334
|
+
...(previousGatewayToken ? { previous_plugin_gateway_token: previousGatewayToken } : {}),
|
|
1335
|
+
};
|
|
1336
|
+
return direct ? this.#fetchJSON(path, body) : this.#postJSON(path, body);
|
|
1337
|
+
}
|
|
1338
|
+
#handshake() {
|
|
1339
|
+
return {
|
|
1340
|
+
type: "redevplugin.bridge.handshake",
|
|
1341
|
+
plugin_id: this.bootstrap.pluginId,
|
|
1342
|
+
surface_id: this.bootstrap.surfaceId,
|
|
1343
|
+
surface_instance_id: this.bootstrap.surfaceInstanceId,
|
|
1344
|
+
active_fingerprint: this.bootstrap.activeFingerprint,
|
|
1345
|
+
bridge_nonce: this.bootstrap.bridgeNonce,
|
|
1346
|
+
asset_session_nonce: this.bootstrap.assetSessionNonce,
|
|
1347
|
+
plugin_state_version: this.bootstrap.pluginStateVersion,
|
|
1348
|
+
revoke_epoch: this.bootstrap.revokeEpoch,
|
|
1349
|
+
ui_protocol_version: pluginUIProtocolVersion,
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
async #postJSON(path, body, signal) {
|
|
1353
|
+
if (this.#ready) {
|
|
1354
|
+
if (Date.now() >= this.#leaseRenewAtMs)
|
|
1355
|
+
await this.#startLeaseRenewal();
|
|
1356
|
+
else if (this.#leaseRenewalPromise)
|
|
1357
|
+
await this.#leaseRenewalPromise;
|
|
1358
|
+
}
|
|
1359
|
+
this.#activeTransportRequests += 1;
|
|
1360
|
+
try {
|
|
1361
|
+
return await this.#fetchJSON(path, body, signal);
|
|
1362
|
+
}
|
|
1363
|
+
finally {
|
|
1364
|
+
this.#activeTransportRequests -= 1;
|
|
1365
|
+
if (this.#activeTransportRequests === 0) {
|
|
1366
|
+
const idle = this.#transportIdle;
|
|
1367
|
+
this.#transportIdle = undefined;
|
|
1368
|
+
idle?.resolve();
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
async #fetchJSON(path, body, signal) {
|
|
1373
|
+
return this.#fetchJSONRequest(path, body, { signal });
|
|
1374
|
+
}
|
|
1375
|
+
async #fetchJSONRequest(path, body, options = {}) {
|
|
1376
|
+
const controller = new AbortController();
|
|
1377
|
+
let timedOut = false;
|
|
1378
|
+
const abort = () => controller.abort();
|
|
1379
|
+
if ((!options.independentLifecycle && this.#abortController.signal.aborted) || options.signal?.aborted)
|
|
1380
|
+
controller.abort();
|
|
1381
|
+
else {
|
|
1382
|
+
if (!options.independentLifecycle)
|
|
1383
|
+
this.#abortController.signal.addEventListener("abort", abort, { once: true });
|
|
1384
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
1385
|
+
}
|
|
1386
|
+
let timer;
|
|
1387
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
1388
|
+
timer = setTimeout(() => {
|
|
1389
|
+
timedOut = true;
|
|
1390
|
+
controller.abort();
|
|
1391
|
+
reject(new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}`));
|
|
1392
|
+
}, this.#requestTimeoutMs);
|
|
1393
|
+
});
|
|
1394
|
+
try {
|
|
1395
|
+
const response = await Promise.race([
|
|
1396
|
+
this.#transport.fetch(this.#transport.apiBaseURL + path, {
|
|
1397
|
+
method: "POST",
|
|
1398
|
+
headers: { "Accept": "application/json", "Content-Type": "application/json" },
|
|
1399
|
+
body: JSON.stringify(body),
|
|
1400
|
+
credentials: "same-origin",
|
|
1401
|
+
signal: controller.signal,
|
|
1402
|
+
keepalive: options.keepalive,
|
|
1403
|
+
}),
|
|
1404
|
+
timeout,
|
|
1405
|
+
]);
|
|
1406
|
+
return await readHostEnvelope(response, "PLUGIN_PERMISSION_DENIED");
|
|
1407
|
+
}
|
|
1408
|
+
catch (error) {
|
|
1409
|
+
if (timedOut)
|
|
1410
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", `Plugin surface request timed out: ${path}`);
|
|
1411
|
+
throw error;
|
|
1412
|
+
}
|
|
1413
|
+
finally {
|
|
1414
|
+
if (timer)
|
|
1415
|
+
clearTimeout(timer);
|
|
1416
|
+
if (!options.independentLifecycle)
|
|
1417
|
+
this.#abortController.signal.removeEventListener("abort", abort);
|
|
1418
|
+
options.signal?.removeEventListener("abort", abort);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
#applyLease(result) {
|
|
1422
|
+
if (!isGatewayTokenResult(result)) {
|
|
1423
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin gateway token endpoint returned an invalid lease");
|
|
1424
|
+
}
|
|
1425
|
+
const issuedAtMs = Date.parse(result.issued_at);
|
|
1426
|
+
const expiresAtMs = Date.parse(result.expires_at);
|
|
1427
|
+
if (!Number.isFinite(issuedAtMs) || !Number.isFinite(expiresAtMs) || expiresAtMs <= issuedAtMs || expiresAtMs <= Date.now()) {
|
|
1428
|
+
throw new PluginBridgeError("PLUGIN_GATEWAY_TOKEN_INVALID", "Plugin surface lease is already expired or malformed");
|
|
1429
|
+
}
|
|
1430
|
+
this.#gatewayToken = result.plugin_gateway_token;
|
|
1431
|
+
this.#assetSession = result.asset_session;
|
|
1432
|
+
this.#assetSessionID = result.asset_session_id;
|
|
1433
|
+
this.#leaseExpiresAtMs = expiresAtMs;
|
|
1434
|
+
const boundedLead = Math.min(this.#leaseRenewalLeadMs, Math.max(1, Math.floor((expiresAtMs - issuedAtMs) / 2)));
|
|
1435
|
+
this.#leaseRenewAtMs = expiresAtMs - boundedLead;
|
|
1436
|
+
}
|
|
1437
|
+
#scheduleLeaseRenewal() {
|
|
1438
|
+
if (this.#leaseRenewalTimer)
|
|
1439
|
+
clearTimeout(this.#leaseRenewalTimer);
|
|
1440
|
+
if (this.#disposed || this.#leaseRenewAtMs <= 0)
|
|
1441
|
+
return;
|
|
1442
|
+
this.#leaseRenewalTimer = setTimeout(() => {
|
|
1443
|
+
this.#leaseRenewalTimer = undefined;
|
|
1444
|
+
void this.#startLeaseRenewal().catch(() => undefined);
|
|
1445
|
+
}, Math.max(0, this.#leaseRenewAtMs - Date.now()));
|
|
1446
|
+
}
|
|
1447
|
+
#startLeaseRenewal() {
|
|
1448
|
+
this.#assertActive();
|
|
1449
|
+
if (!this.#ready || !this.#gatewayToken) {
|
|
1450
|
+
return Promise.reject(new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "Plugin surface lease cannot renew before readiness"));
|
|
1451
|
+
}
|
|
1452
|
+
if (this.#leaseRenewalPromise)
|
|
1453
|
+
return this.#leaseRenewalPromise;
|
|
1454
|
+
const previousGatewayToken = this.#gatewayToken;
|
|
1455
|
+
const renewal = (async () => {
|
|
1456
|
+
await this.#waitForTransportIdle();
|
|
1457
|
+
this.#assertActive();
|
|
1458
|
+
const result = await this.#mintBridgeToken(previousGatewayToken, true);
|
|
1459
|
+
this.#assertActive();
|
|
1460
|
+
this.#applyLease(result);
|
|
1461
|
+
this.#scheduleLeaseRenewal();
|
|
1462
|
+
})();
|
|
1463
|
+
this.#leaseRenewalPromise = renewal
|
|
1464
|
+
.catch(async (error) => {
|
|
1465
|
+
const bridgeError = toBridgeError(error, "PLUGIN_GATEWAY_TOKEN_INVALID");
|
|
1466
|
+
await this.#failSurface(bridgeError);
|
|
1467
|
+
throw bridgeError;
|
|
1468
|
+
})
|
|
1469
|
+
.finally(() => {
|
|
1470
|
+
this.#leaseRenewalPromise = undefined;
|
|
1471
|
+
});
|
|
1472
|
+
return this.#leaseRenewalPromise;
|
|
1473
|
+
}
|
|
1474
|
+
async #waitForTransportIdle() {
|
|
1475
|
+
if (this.#activeTransportRequests === 0)
|
|
1476
|
+
return;
|
|
1477
|
+
this.#transportIdle ??= deferred();
|
|
1478
|
+
await this.#transportIdle.promise;
|
|
1479
|
+
}
|
|
1480
|
+
#revokeSurface(keepalive) {
|
|
1481
|
+
if (this.#revokePromise)
|
|
1482
|
+
return this.#revokePromise;
|
|
1483
|
+
this.#revokePromise = (async () => {
|
|
1484
|
+
await this.#fetchJSONRequest(`/_redevplugin/api/plugins/surfaces/${encodeURIComponent(this.bootstrap.surfaceInstanceId)}/dispose`, { bridge_nonce: this.bootstrap.bridgeNonce }, { keepalive, independentLifecycle: true });
|
|
1485
|
+
})();
|
|
1486
|
+
return this.#revokePromise;
|
|
1487
|
+
}
|
|
1488
|
+
async #bestEffortRevokeSurface(keepalive) {
|
|
1489
|
+
try {
|
|
1490
|
+
await this.#revokeSurface(keepalive);
|
|
1491
|
+
}
|
|
1492
|
+
catch {
|
|
1493
|
+
// The server may already have revoked the surface after a failed prepare.
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
async #failSurface(error) {
|
|
1497
|
+
if (this.#disposed)
|
|
1498
|
+
return;
|
|
1499
|
+
this.#ready = false;
|
|
1500
|
+
this.#openSignals?.firstPaint.reject(error);
|
|
1501
|
+
this.#openSignals?.workerReady.reject(error);
|
|
1502
|
+
this.#reportError(error);
|
|
1503
|
+
const revoke = this.#bestEffortRevokeSurface(true);
|
|
1504
|
+
this.#disposeLocal();
|
|
1505
|
+
await revoke;
|
|
1506
|
+
}
|
|
1507
|
+
#postResponse(id, data) {
|
|
1508
|
+
this.#postToRenderer({ type: "redevplugin.bridge.response", id, ok: true, data });
|
|
1509
|
+
}
|
|
1510
|
+
#postError(id, errorCode, error) {
|
|
1511
|
+
this.#postToRenderer({ type: "redevplugin.bridge.response", id, ok: false, error_code: errorCode, error });
|
|
1512
|
+
}
|
|
1513
|
+
#postToRenderer(message) {
|
|
1514
|
+
if (!this.#port) {
|
|
1515
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "Plugin surface port is not ready");
|
|
1516
|
+
}
|
|
1517
|
+
this.#port.postMessage(message);
|
|
1518
|
+
}
|
|
1519
|
+
#reportError(error) {
|
|
1520
|
+
try {
|
|
1521
|
+
this.#onError?.(error);
|
|
1522
|
+
}
|
|
1523
|
+
catch {
|
|
1524
|
+
// Observers cannot weaken revocation or local teardown invariants.
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
#assertReady() {
|
|
1528
|
+
this.#assertActive();
|
|
1529
|
+
if (!this.#ready || !this.#port) {
|
|
1530
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "Plugin surface is not ready");
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
#assertActive() {
|
|
1534
|
+
if (this.#disposed) {
|
|
1535
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin surface host is disposed");
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
function claimOpaquePluginBridge() {
|
|
1540
|
+
const value = globalThis[opaquePluginBridgeGlobalKey];
|
|
1541
|
+
if (!isRecord(value) || typeof value.claim !== "function") {
|
|
1542
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "Opaque plugin worker bridge is unavailable");
|
|
1543
|
+
}
|
|
1544
|
+
const claimed = value.claim();
|
|
1545
|
+
if (!claimed || !isMessagePortLike(claimed.port) || !validOpaqueHandle(claimed.surfaceHandle, "surface")) {
|
|
1546
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "Opaque plugin worker bridge was already claimed");
|
|
1547
|
+
}
|
|
1548
|
+
return claimed;
|
|
1549
|
+
}
|
|
1550
|
+
function normalizeSurfaceHandle(value) {
|
|
1551
|
+
if (!validOpaqueHandle(value, "surface")) {
|
|
1552
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_REQUIRED", "A valid surface handle is required with an explicit bridge port");
|
|
1553
|
+
}
|
|
1554
|
+
return value;
|
|
1555
|
+
}
|
|
1556
|
+
function captureMessageChannelFactory() {
|
|
1557
|
+
const Channel = globalThis.MessageChannel;
|
|
1558
|
+
if (!Channel) {
|
|
1559
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_FAILED", "MessageChannel is unavailable");
|
|
1560
|
+
}
|
|
1561
|
+
return () => new Channel();
|
|
1562
|
+
}
|
|
1563
|
+
function createPluginSurfaceFrame() {
|
|
1564
|
+
const ownerDocument = globalThis.document;
|
|
1565
|
+
if (!ownerDocument || typeof ownerDocument.createElement !== "function") {
|
|
1566
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_FAILED", "Plugin surface host requires a browser document");
|
|
1567
|
+
}
|
|
1568
|
+
return ownerDocument.createElement("iframe");
|
|
1569
|
+
}
|
|
1570
|
+
function hardenPluginSurfaceFrame(frame) {
|
|
1571
|
+
frame.setAttribute("src", "about:blank");
|
|
1572
|
+
frame.setAttribute("sandbox", "allow-scripts");
|
|
1573
|
+
frame.setAttribute("allow", deniedPluginPermissionsPolicy);
|
|
1574
|
+
frame.setAttribute("referrerpolicy", "no-referrer");
|
|
1575
|
+
if ("credentialless" in frame)
|
|
1576
|
+
frame.credentialless = true;
|
|
1577
|
+
}
|
|
1578
|
+
const deniedPluginPermissionsPolicy = [
|
|
1579
|
+
"accelerometer 'none'",
|
|
1580
|
+
"autoplay 'none'",
|
|
1581
|
+
"bluetooth 'none'",
|
|
1582
|
+
"camera 'none'",
|
|
1583
|
+
"clipboard-read 'none'",
|
|
1584
|
+
"clipboard-write 'none'",
|
|
1585
|
+
"display-capture 'none'",
|
|
1586
|
+
"encrypted-media 'none'",
|
|
1587
|
+
"fullscreen 'none'",
|
|
1588
|
+
"gamepad 'none'",
|
|
1589
|
+
"geolocation 'none'",
|
|
1590
|
+
"gyroscope 'none'",
|
|
1591
|
+
"hid 'none'",
|
|
1592
|
+
"magnetometer 'none'",
|
|
1593
|
+
"microphone 'none'",
|
|
1594
|
+
"midi 'none'",
|
|
1595
|
+
"payment 'none'",
|
|
1596
|
+
"picture-in-picture 'none'",
|
|
1597
|
+
"publickey-credentials-get 'none'",
|
|
1598
|
+
"screen-wake-lock 'none'",
|
|
1599
|
+
"serial 'none'",
|
|
1600
|
+
"usb 'none'",
|
|
1601
|
+
"xr-spatial-tracking 'none'",
|
|
1602
|
+
].join("; ");
|
|
1603
|
+
const openingProgressDelayMs = 300;
|
|
1604
|
+
function randomOpaqueNonce() {
|
|
1605
|
+
const cryptoLike = globalThis.crypto;
|
|
1606
|
+
if (!cryptoLike?.getRandomValues) {
|
|
1607
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_FAILED", "Web Crypto random values are unavailable");
|
|
1608
|
+
}
|
|
1609
|
+
const bytes = new Uint8Array(24);
|
|
1610
|
+
cryptoLike.getRandomValues(bytes);
|
|
1611
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1612
|
+
}
|
|
1613
|
+
function randomOpaqueHandle(prefix) {
|
|
1614
|
+
const cryptoLike = globalThis.crypto;
|
|
1615
|
+
if (!cryptoLike?.getRandomValues) {
|
|
1616
|
+
throw new PluginBridgeError("PLUGIN_BRIDGE_HANDSHAKE_FAILED", "Web Crypto random values are unavailable");
|
|
1617
|
+
}
|
|
1618
|
+
const bytes = new Uint8Array(18);
|
|
1619
|
+
cryptoLike.getRandomValues(bytes);
|
|
1620
|
+
return `${prefix}_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
1621
|
+
}
|
|
1622
|
+
function escapeHTMLAttribute(value) {
|
|
1623
|
+
return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
|
1624
|
+
}
|
|
1625
|
+
function validateHostBootstrap(bootstrap) {
|
|
1626
|
+
for (const value of [
|
|
1627
|
+
bootstrap.pluginId,
|
|
1628
|
+
bootstrap.pluginInstanceId,
|
|
1629
|
+
bootstrap.pluginVersion,
|
|
1630
|
+
bootstrap.surfaceId,
|
|
1631
|
+
bootstrap.surfaceInstanceId,
|
|
1632
|
+
bootstrap.activeFingerprint,
|
|
1633
|
+
bootstrap.bridgeNonce,
|
|
1634
|
+
bootstrap.entryPath,
|
|
1635
|
+
bootstrap.entrySHA256,
|
|
1636
|
+
bootstrap.assetTicket,
|
|
1637
|
+
bootstrap.assetSessionNonce,
|
|
1638
|
+
bootstrap.runtimeGenerationId,
|
|
1639
|
+
]) {
|
|
1640
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
1641
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface bootstrap is incomplete");
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
if (!Number.isSafeInteger(bootstrap.pluginStateVersion) || bootstrap.pluginStateVersion < 1 ||
|
|
1645
|
+
!Number.isSafeInteger(bootstrap.revokeEpoch) || bootstrap.revokeEpoch < 1) {
|
|
1646
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface revision is invalid");
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
function validateSurfacePreparation(bootstrap, preparation) {
|
|
1650
|
+
if (!isSurfacePreparationResult(preparation)) {
|
|
1651
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface prepare returned an invalid opaque document");
|
|
1652
|
+
}
|
|
1653
|
+
if (preparation.plugin_state_version !== bootstrap.pluginStateVersion || preparation.revoke_epoch !== bootstrap.revokeEpoch) {
|
|
1654
|
+
throw new PluginBridgeError("PLUGIN_GATEWAY_TOKEN_INVALID", "Plugin surface state changed during prepare");
|
|
1655
|
+
}
|
|
1656
|
+
if (preparation.asset_session_nonce !== bootstrap.assetSessionNonce) {
|
|
1657
|
+
throw new PluginBridgeError("PLUGIN_ASSET_SESSION_INVALID", "Plugin asset session nonce changed during prepare");
|
|
1658
|
+
}
|
|
1659
|
+
if (preparation.entry_path !== bootstrap.entryPath || preparation.entry_sha256 !== bootstrap.entrySHA256 ||
|
|
1660
|
+
preparation.document.entry_path !== bootstrap.entryPath || preparation.document.entry_sha256 !== bootstrap.entrySHA256) {
|
|
1661
|
+
throw new PluginBridgeError("PLUGIN_ASSET_SESSION_INVALID", "Plugin surface entry changed during prepare");
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
function isSurfacePreparationResult(value) {
|
|
1665
|
+
if (!hasExactKeys(value, [
|
|
1666
|
+
"asset_session",
|
|
1667
|
+
"asset_session_id",
|
|
1668
|
+
"asset_session_nonce",
|
|
1669
|
+
"entry_path",
|
|
1670
|
+
"entry_sha256",
|
|
1671
|
+
"plugin_state_version",
|
|
1672
|
+
"revoke_epoch",
|
|
1673
|
+
"issued_at",
|
|
1674
|
+
"expires_at",
|
|
1675
|
+
"document",
|
|
1676
|
+
]))
|
|
1677
|
+
return false;
|
|
1678
|
+
const issuedAt = Date.parse(String(value.issued_at));
|
|
1679
|
+
const expiresAt = Date.parse(String(value.expires_at));
|
|
1680
|
+
return typeof value.asset_session === "string" && value.asset_session.length > 0 &&
|
|
1681
|
+
typeof value.asset_session_id === "string" && value.asset_session_id.length > 0 &&
|
|
1682
|
+
typeof value.asset_session_nonce === "string" && value.asset_session_nonce.length > 0 &&
|
|
1683
|
+
validPackagePath(value.entry_path) &&
|
|
1684
|
+
validSHA256(value.entry_sha256) &&
|
|
1685
|
+
Number.isSafeInteger(value.plugin_state_version) && Number(value.plugin_state_version) >= 1 &&
|
|
1686
|
+
Number.isSafeInteger(value.revoke_epoch) && Number(value.revoke_epoch) >= 1 &&
|
|
1687
|
+
Number.isFinite(issuedAt) && Number.isFinite(expiresAt) && expiresAt > issuedAt &&
|
|
1688
|
+
isOpaqueSurfaceDocument(value.document);
|
|
1689
|
+
}
|
|
1690
|
+
function isOpaqueSurfaceDocument(value) {
|
|
1691
|
+
if (!hasAllowedKeys(value, [
|
|
1692
|
+
"schema_version",
|
|
1693
|
+
"entry_path",
|
|
1694
|
+
"entry_sha256",
|
|
1695
|
+
"title",
|
|
1696
|
+
"language",
|
|
1697
|
+
"direction",
|
|
1698
|
+
"body_html",
|
|
1699
|
+
"styles",
|
|
1700
|
+
"worker",
|
|
1701
|
+
"assets",
|
|
1702
|
+
"critical_bytes",
|
|
1703
|
+
]))
|
|
1704
|
+
return false;
|
|
1705
|
+
if (!Array.isArray(value.assets) || value.assets.length > maxOpaqueSurfaceLazyAssets)
|
|
1706
|
+
return false;
|
|
1707
|
+
let lazyBytes = 0;
|
|
1708
|
+
for (const asset of value.assets) {
|
|
1709
|
+
if (!hasExactKeys(asset, ["binding_id", "path", "sha256", "size", "content_type"]) ||
|
|
1710
|
+
!validOpaqueHandle(asset.binding_id, "asset") || !validPackagePath(asset.path) || !validSHA256(asset.sha256) ||
|
|
1711
|
+
!Number.isSafeInteger(asset.size) || Number(asset.size) < 0 || Number(asset.size) > maxOpaqueSurfaceLazyBytes ||
|
|
1712
|
+
typeof asset.content_type !== "string" || asset.content_type.length < 1 || asset.content_type.length > 256) {
|
|
1713
|
+
return false;
|
|
1714
|
+
}
|
|
1715
|
+
lazyBytes += Number(asset.size);
|
|
1716
|
+
if (!Number.isSafeInteger(lazyBytes) || lazyBytes > maxOpaqueSurfaceLazyBytes)
|
|
1717
|
+
return false;
|
|
1718
|
+
}
|
|
1719
|
+
return value.schema_version === opaqueSurfaceDocumentSchemaVersion &&
|
|
1720
|
+
validPackagePath(value.entry_path) &&
|
|
1721
|
+
validSHA256(value.entry_sha256) &&
|
|
1722
|
+
(value.title === undefined || (typeof value.title === "string" && value.title.length <= 256)) &&
|
|
1723
|
+
(value.language === undefined || (typeof value.language === "string" && value.language.length <= 64)) &&
|
|
1724
|
+
(value.direction === undefined || value.direction === "ltr" || value.direction === "rtl" || value.direction === "auto") &&
|
|
1725
|
+
typeof value.body_html === "string" && value.body_html.length <= 4 * 1024 * 1024 &&
|
|
1726
|
+
Array.isArray(value.styles) && value.styles.every((style) => hasExactKeys(style, ["path", "sha256", "content"]) && validPackagePath(style.path) && validSHA256(style.sha256) && typeof style.content === "string" && style.content.length <= 2 * 1024 * 1024) &&
|
|
1727
|
+
hasExactKeys(value.worker, ["path", "sha256", "type", "content"]) && validPackagePath(value.worker.path) && validSHA256(value.worker.sha256) && value.worker.type === "classic" && typeof value.worker.content === "string" && value.worker.content.length <= 4 * 1024 * 1024 &&
|
|
1728
|
+
new Set(value.assets.map((asset) => asset.binding_id)).size === value.assets.length &&
|
|
1729
|
+
new Set(value.assets.map((asset) => asset.path)).size === value.assets.length &&
|
|
1730
|
+
Number.isSafeInteger(value.critical_bytes) && Number(value.critical_bytes) >= 0 && Number(value.critical_bytes) <= 8 * 1024 * 1024;
|
|
1731
|
+
}
|
|
1732
|
+
function isBridgeCallMessage(value) {
|
|
1733
|
+
return hasExactKeys(value, ["type", "request"]) &&
|
|
1734
|
+
value.type === "redevplugin.bridge.call" &&
|
|
1735
|
+
hasAllowedKeys(value.request, ["id", "method", "params"]) &&
|
|
1736
|
+
validBridgeRequestID(value.request.id, "rpc") &&
|
|
1737
|
+
validMethod(value.request.method) &&
|
|
1738
|
+
validRPCParams(value.request.params);
|
|
1739
|
+
}
|
|
1740
|
+
function isStreamReadMessage(value) {
|
|
1741
|
+
return hasExactKeys(value, ["type", "id", "stream_handle"]) &&
|
|
1742
|
+
value.type === "redevplugin.bridge.stream.read" &&
|
|
1743
|
+
validBridgeRequestID(value.id, "stream") &&
|
|
1744
|
+
validOpaqueHandle(value.stream_handle, "stream");
|
|
1745
|
+
}
|
|
1746
|
+
function isBridgeCancelMessage(value) {
|
|
1747
|
+
return hasExactKeys(value, ["type", "id"]) &&
|
|
1748
|
+
value.type === "redevplugin.bridge.cancel" &&
|
|
1749
|
+
validBridgeRequestID(value.id);
|
|
1750
|
+
}
|
|
1751
|
+
function isAssetReadMessage(value) {
|
|
1752
|
+
return hasExactKeys(value, ["type", "request_id", "binding_id", "path", "sha256"]) &&
|
|
1753
|
+
value.type === "redevplugin.surface.asset.read" &&
|
|
1754
|
+
typeof value.request_id === "string" && value.request_id.length > 0 && value.request_id.length <= 128 &&
|
|
1755
|
+
validOpaqueHandle(value.binding_id, "asset") &&
|
|
1756
|
+
validPackagePath(value.path) &&
|
|
1757
|
+
validSHA256(value.sha256);
|
|
1758
|
+
}
|
|
1759
|
+
function isBridgeResponse(value) {
|
|
1760
|
+
if (!isRecord(value) || value.type !== "redevplugin.bridge.response" || typeof value.id !== "string")
|
|
1761
|
+
return false;
|
|
1762
|
+
if (value.ok === true)
|
|
1763
|
+
return Object.keys(value).every((key) => ["type", "id", "ok", "data"].includes(key));
|
|
1764
|
+
return value.ok === false && typeof value.error_code === "string" && typeof value.error === "string" &&
|
|
1765
|
+
Object.keys(value).every((key) => ["type", "id", "ok", "error_code", "error"].includes(key));
|
|
1766
|
+
}
|
|
1767
|
+
function isLifecycleMessage(value) {
|
|
1768
|
+
return hasExactKeys(value, ["type", "event"]) &&
|
|
1769
|
+
value.type === "redevplugin.bridge.lifecycle" &&
|
|
1770
|
+
hasExactKeys(value.event, ["type"]) &&
|
|
1771
|
+
(value.event.type === "ready" || value.event.type === "visible" || value.event.type === "hidden" || value.event.type === "dispose");
|
|
1772
|
+
}
|
|
1773
|
+
function isActionMessage(value) {
|
|
1774
|
+
return hasAllowedKeys(value, ["type", "action", "event", "value", "checked", "form_data"]) &&
|
|
1775
|
+
value.type === "redevplugin.ui.action" &&
|
|
1776
|
+
validActionID(value.action) &&
|
|
1777
|
+
(value.event === "click" || value.event === "input" || value.event === "change" || value.event === "submit") &&
|
|
1778
|
+
(value.value == null || (typeof value.value === "string" && value.value.length <= 65536)) &&
|
|
1779
|
+
(value.checked == null || typeof value.checked === "boolean") &&
|
|
1780
|
+
(value.form_data == null || (isRecord(value.form_data) && Object.entries(value.form_data).every(([key, item]) => validActionID(key) && typeof item === "string" && item.length <= 65536)));
|
|
1781
|
+
}
|
|
1782
|
+
function isMessagePortLike(value) {
|
|
1783
|
+
return isRecord(value) &&
|
|
1784
|
+
typeof value.postMessage === "function" &&
|
|
1785
|
+
typeof value.addEventListener === "function" &&
|
|
1786
|
+
typeof value.removeEventListener === "function" &&
|
|
1787
|
+
typeof value.start === "function" &&
|
|
1788
|
+
typeof value.close === "function";
|
|
1789
|
+
}
|
|
1790
|
+
function isAssetReadResult(value) {
|
|
1791
|
+
return hasExactKeys(value, ["path", "sha256", "content_type", "content_base64"]) &&
|
|
1792
|
+
validPackagePath(value.path) && validSHA256(value.sha256) &&
|
|
1793
|
+
typeof value.content_type === "string" && value.content_type.length > 0 && value.content_type.length <= 256 &&
|
|
1794
|
+
typeof value.content_base64 === "string";
|
|
1795
|
+
}
|
|
1796
|
+
function isGatewayTokenResult(value) {
|
|
1797
|
+
return hasExactKeys(value, ["plugin_gateway_token", "plugin_gateway_token_id", "asset_session", "asset_session_id", "issued_at", "expires_at"]) &&
|
|
1798
|
+
typeof value.plugin_gateway_token === "string" && value.plugin_gateway_token.length > 0 &&
|
|
1799
|
+
typeof value.plugin_gateway_token_id === "string" && value.plugin_gateway_token_id.length > 0 &&
|
|
1800
|
+
typeof value.asset_session === "string" && value.asset_session.length > 0 &&
|
|
1801
|
+
typeof value.asset_session_id === "string" && value.asset_session_id.length > 0 &&
|
|
1802
|
+
typeof value.issued_at === "string" && typeof value.expires_at === "string";
|
|
1803
|
+
}
|
|
1804
|
+
function isStreamReadResult(value, expectedStreamID) {
|
|
1805
|
+
if (!hasExactKeys(value, ["events"]) || !Array.isArray(value.events))
|
|
1806
|
+
return false;
|
|
1807
|
+
let previousSequence = 0;
|
|
1808
|
+
let terminal = false;
|
|
1809
|
+
for (const event of value.events) {
|
|
1810
|
+
if (!isStreamEvent(event) || event.stream_id !== expectedStreamID || event.sequence <= previousSequence || terminal)
|
|
1811
|
+
return false;
|
|
1812
|
+
previousSequence = event.sequence;
|
|
1813
|
+
terminal = event.kind === "end" || event.kind === "error";
|
|
1814
|
+
}
|
|
1815
|
+
return true;
|
|
1816
|
+
}
|
|
1817
|
+
function isStreamEvent(value) {
|
|
1818
|
+
return hasAllowedKeys(value, ["stream_id", "sequence", "kind", "data", "error", "at"]) &&
|
|
1819
|
+
typeof value.stream_id === "string" &&
|
|
1820
|
+
Number.isSafeInteger(value.sequence) && Number(value.sequence) > 0 &&
|
|
1821
|
+
typeof value.kind === "string" && value.kind.length > 0 &&
|
|
1822
|
+
(value.data == null || typeof value.data === "string") &&
|
|
1823
|
+
(value.error == null || typeof value.error === "string") &&
|
|
1824
|
+
typeof value.at === "string";
|
|
1825
|
+
}
|
|
1826
|
+
function validRPCParams(value) {
|
|
1827
|
+
if (value === undefined)
|
|
1828
|
+
return true;
|
|
1829
|
+
try {
|
|
1830
|
+
normalizePluginJSONObject(value);
|
|
1831
|
+
return true;
|
|
1832
|
+
}
|
|
1833
|
+
catch {
|
|
1834
|
+
return false;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
const pluginMethodPattern = new RegExp("^[-A-Za-z0-9._:]{1,256}$");
|
|
1838
|
+
const pluginActionPattern = new RegExp("^[-A-Za-z0-9._:]{1,128}$");
|
|
1839
|
+
const opaqueHandlePattern = new RegExp("^[-A-Za-z0-9_]{8,160}$");
|
|
1840
|
+
const bridgeRequestIDPattern = /^(rpc|stream|render)_([1-9][0-9]{0,15})$/;
|
|
1841
|
+
function validBridgeRequestID(value, expectedKind) {
|
|
1842
|
+
if (typeof value !== "string")
|
|
1843
|
+
return false;
|
|
1844
|
+
const match = bridgeRequestIDPattern.exec(value);
|
|
1845
|
+
if (!match || (expectedKind && match[1] !== expectedKind))
|
|
1846
|
+
return false;
|
|
1847
|
+
return Number.isSafeInteger(Number(match[2]));
|
|
1848
|
+
}
|
|
1849
|
+
function validMethod(value) {
|
|
1850
|
+
return typeof value === "string" && pluginMethodPattern.test(value);
|
|
1851
|
+
}
|
|
1852
|
+
function validActionID(value) {
|
|
1853
|
+
return typeof value === "string" && pluginActionPattern.test(value);
|
|
1854
|
+
}
|
|
1855
|
+
function validOpaqueHandle(value, prefix) {
|
|
1856
|
+
return typeof value === "string" && value.startsWith(`${prefix}_`) && opaqueHandlePattern.test(value);
|
|
1857
|
+
}
|
|
1858
|
+
function validSHA256(value) {
|
|
1859
|
+
return typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
|
|
1860
|
+
}
|
|
1861
|
+
function validPackagePath(value) {
|
|
1862
|
+
return typeof value === "string" && value.length > 0 && value.length <= 512 && !value.startsWith("/") && !value.includes("\\") &&
|
|
1863
|
+
value.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
1864
|
+
}
|
|
1865
|
+
function normalizeSurfaceAPIBaseURL(value) {
|
|
1866
|
+
const invalid = () => {
|
|
1867
|
+
throw new PluginBridgeError("PLUGIN_CONTRACT_MISMATCH", "Plugin surface API base URL must be a path-only same-origin URL");
|
|
1868
|
+
};
|
|
1869
|
+
if (typeof value !== "string" || value !== value.trim() || /[\u0000-\u001f\u007f\\]/.test(value) || value.includes("?") || value.includes("#")) {
|
|
1870
|
+
return invalid();
|
|
1871
|
+
}
|
|
1872
|
+
if (value === "")
|
|
1873
|
+
return "";
|
|
1874
|
+
if (hasEncodedPathSeparatorOrDot(value) || /(?:^|\/)\.{1,2}(?:\/|$)/.test(value))
|
|
1875
|
+
return invalid();
|
|
1876
|
+
let origin = "";
|
|
1877
|
+
let pathname;
|
|
1878
|
+
if (value.startsWith("/")) {
|
|
1879
|
+
if (value.startsWith("//"))
|
|
1880
|
+
return invalid();
|
|
1881
|
+
pathname = value;
|
|
1882
|
+
}
|
|
1883
|
+
else {
|
|
1884
|
+
const currentOrigin = globalThis.location?.origin;
|
|
1885
|
+
if (typeof currentOrigin !== "string" || currentOrigin === "" || currentOrigin === "null")
|
|
1886
|
+
return invalid();
|
|
1887
|
+
let parsed;
|
|
1888
|
+
try {
|
|
1889
|
+
parsed = new URL(value);
|
|
1890
|
+
}
|
|
1891
|
+
catch {
|
|
1892
|
+
return invalid();
|
|
1893
|
+
}
|
|
1894
|
+
if (parsed.origin !== currentOrigin || parsed.username !== "" || parsed.password !== "" || parsed.search !== "" || parsed.hash !== "") {
|
|
1895
|
+
return invalid();
|
|
1896
|
+
}
|
|
1897
|
+
origin = parsed.origin;
|
|
1898
|
+
pathname = parsed.pathname;
|
|
1899
|
+
}
|
|
1900
|
+
const segments = pathname.split("/");
|
|
1901
|
+
if (!pathname.startsWith("/") || segments.slice(1, -1).some((segment) => segment === "" || segment === "." || segment === "..") ||
|
|
1902
|
+
(segments.at(-1) !== "" && (segments.at(-1) === "." || segments.at(-1) === ".."))) {
|
|
1903
|
+
return invalid();
|
|
1904
|
+
}
|
|
1905
|
+
const normalizedPath = pathname === "/" ? "" : pathname.replace(/\/$/, "");
|
|
1906
|
+
return origin + normalizedPath;
|
|
1907
|
+
}
|
|
1908
|
+
function hasEncodedPathSeparatorOrDot(value) {
|
|
1909
|
+
let decoded = value;
|
|
1910
|
+
for (let pass = 0; pass < 4; pass += 1) {
|
|
1911
|
+
if (/%(?:2e|2f|5c)/i.test(decoded))
|
|
1912
|
+
return true;
|
|
1913
|
+
try {
|
|
1914
|
+
const next = decodeURIComponent(decoded);
|
|
1915
|
+
if (next === decoded)
|
|
1916
|
+
return false;
|
|
1917
|
+
decoded = next;
|
|
1918
|
+
}
|
|
1919
|
+
catch {
|
|
1920
|
+
return true;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
return /%(?:2e|2f|5c)/i.test(decoded);
|
|
1924
|
+
}
|
|
1925
|
+
function isPluginRiskFlag(value) {
|
|
1926
|
+
return isRecord(value) &&
|
|
1927
|
+
typeof value.id === "string" &&
|
|
1928
|
+
isPluginRiskSeverity(value.severity) &&
|
|
1929
|
+
typeof value.summary === "string" &&
|
|
1930
|
+
(value.description == null || typeof value.description === "string") &&
|
|
1931
|
+
(value.requires_confirmation == null || typeof value.requires_confirmation === "boolean") &&
|
|
1932
|
+
(value.requires_admin == null || typeof value.requires_admin === "boolean") &&
|
|
1933
|
+
(value.data_loss_risk == null || typeof value.data_loss_risk === "boolean") &&
|
|
1934
|
+
(value.destructive == null || typeof value.destructive === "boolean");
|
|
1935
|
+
}
|
|
1936
|
+
function isPluginRiskSeverity(value) {
|
|
1937
|
+
return value === "info" || value === "low" || value === "medium" || value === "high" || value === "critical";
|
|
1938
|
+
}
|
|
1939
|
+
function isPluginRiskEffect(value) {
|
|
1940
|
+
return value === "read" || value === "write" || value === "execute" || value === "delete" || value === "admin";
|
|
1941
|
+
}
|
|
1942
|
+
function confirmationDecisionAccepted(decision) {
|
|
1943
|
+
return typeof decision === "boolean" ? decision : decision.confirmed;
|
|
1944
|
+
}
|
|
1945
|
+
function abortableConfirmationDecision(decision, signal) {
|
|
1946
|
+
if (signal.aborted) {
|
|
1947
|
+
return Promise.reject(new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin confirmation was aborted"));
|
|
1948
|
+
}
|
|
1949
|
+
return new Promise((resolve, reject) => {
|
|
1950
|
+
let settled = false;
|
|
1951
|
+
const finish = (callback, value) => {
|
|
1952
|
+
if (settled)
|
|
1953
|
+
return;
|
|
1954
|
+
settled = true;
|
|
1955
|
+
signal.removeEventListener("abort", onAbort);
|
|
1956
|
+
callback(value);
|
|
1957
|
+
};
|
|
1958
|
+
const onAbort = () => {
|
|
1959
|
+
finish(reject, new PluginBridgeError("PLUGIN_BRIDGE_DISPOSED", "Plugin confirmation was aborted"));
|
|
1960
|
+
};
|
|
1961
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1962
|
+
Promise.resolve(decision).then((value) => finish(resolve, value), (error) => finish(reject, error));
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
function messageWithinLimit(value) {
|
|
1966
|
+
try {
|
|
1967
|
+
const normalized = normalizePluginJSONValue(value);
|
|
1968
|
+
return new TextEncoder().encode(JSON.stringify(normalized)).byteLength <= maxPluginBridgeMessageBytes;
|
|
1969
|
+
}
|
|
1970
|
+
catch {
|
|
1971
|
+
return false;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
function normalizePluginJSONObject(value) {
|
|
1975
|
+
const normalized = normalizePluginJSONValue(value);
|
|
1976
|
+
if (normalized === null || Array.isArray(normalized) || typeof normalized !== "object") {
|
|
1977
|
+
throw new TypeError("value must be a JSON object");
|
|
1978
|
+
}
|
|
1979
|
+
return normalized;
|
|
1980
|
+
}
|
|
1981
|
+
function normalizePluginJSONValue(value, depth = 0, state = { nodes: 0, seen: new Set() }) {
|
|
1982
|
+
state.nodes += 1;
|
|
1983
|
+
if (state.nodes > 4096 || depth > 64)
|
|
1984
|
+
throw new TypeError("JSON value exceeds structural limits");
|
|
1985
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
1986
|
+
return value;
|
|
1987
|
+
if (typeof value === "number") {
|
|
1988
|
+
if (!Number.isFinite(value))
|
|
1989
|
+
throw new TypeError("JSON numbers must be finite");
|
|
1990
|
+
return value;
|
|
1991
|
+
}
|
|
1992
|
+
if (typeof value !== "object" || state.seen.has(value))
|
|
1993
|
+
throw new TypeError("value is not canonical JSON");
|
|
1994
|
+
state.seen.add(value);
|
|
1995
|
+
try {
|
|
1996
|
+
if (Array.isArray(value)) {
|
|
1997
|
+
return value.map((item) => normalizePluginJSONValue(item, depth + 1, state));
|
|
1998
|
+
}
|
|
1999
|
+
const prototype = Object.getPrototypeOf(value);
|
|
2000
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
2001
|
+
throw new TypeError("JSON objects must be plain records");
|
|
2002
|
+
const result = {};
|
|
2003
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
2004
|
+
if (typeof key !== "string")
|
|
2005
|
+
throw new TypeError("JSON object keys must be strings");
|
|
2006
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
2007
|
+
if (!descriptor?.enumerable || !("value" in descriptor))
|
|
2008
|
+
throw new TypeError("JSON object fields must be enumerable data properties");
|
|
2009
|
+
result[key] = normalizePluginJSONValue(descriptor.value, depth + 1, state);
|
|
2010
|
+
}
|
|
2011
|
+
return result;
|
|
2012
|
+
}
|
|
2013
|
+
finally {
|
|
2014
|
+
state.seen.delete(value);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
function normalizeTimeout(timeoutMs) {
|
|
2018
|
+
if (timeoutMs == null)
|
|
2019
|
+
return 30_000;
|
|
2020
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
2021
|
+
throw new Error("timeoutMs must be a positive finite number");
|
|
2022
|
+
return timeoutMs;
|
|
2023
|
+
}
|
|
2024
|
+
function normalizeLeaseRenewalLead(leadMs) {
|
|
2025
|
+
if (leadMs == null)
|
|
2026
|
+
return 60_000;
|
|
2027
|
+
if (!Number.isFinite(leadMs) || leadMs <= 0)
|
|
2028
|
+
throw new Error("leaseRenewalLeadMs must be a positive finite number");
|
|
2029
|
+
return leadMs;
|
|
2030
|
+
}
|
|
2031
|
+
function normalizeReloadMax(maxReloads) {
|
|
2032
|
+
if (!Number.isInteger(maxReloads) || maxReloads < 0)
|
|
2033
|
+
throw new Error("maxReloads must be a non-negative integer");
|
|
2034
|
+
return maxReloads;
|
|
2035
|
+
}
|
|
2036
|
+
function normalizeReloadWindow(windowMs) {
|
|
2037
|
+
if (!Number.isFinite(windowMs) || windowMs <= 0)
|
|
2038
|
+
throw new Error("windowMs must be a positive finite number");
|
|
2039
|
+
return windowMs;
|
|
2040
|
+
}
|
|
2041
|
+
function normalizeNowMs(nowMs) {
|
|
2042
|
+
if (!Number.isFinite(nowMs))
|
|
2043
|
+
throw new Error("nowMs must be a finite number");
|
|
2044
|
+
return nowMs;
|
|
2045
|
+
}
|
|
2046
|
+
function deferred() {
|
|
2047
|
+
let resolve;
|
|
2048
|
+
let reject;
|
|
2049
|
+
const promise = new Promise((innerResolve, innerReject) => {
|
|
2050
|
+
resolve = innerResolve;
|
|
2051
|
+
reject = innerReject;
|
|
2052
|
+
});
|
|
2053
|
+
return { promise, resolve, reject };
|
|
2054
|
+
}
|
|
2055
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
2056
|
+
return new Promise((resolve, reject) => {
|
|
2057
|
+
const timer = setTimeout(() => reject(new PluginBridgeError("PLUGIN_BRIDGE_TIMEOUT", message)), timeoutMs);
|
|
2058
|
+
promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
function removeUndefined(value) {
|
|
2062
|
+
for (const key of Object.keys(value))
|
|
2063
|
+
if (value[key] === undefined)
|
|
2064
|
+
delete value[key];
|
|
2065
|
+
return value;
|
|
2066
|
+
}
|
|
2067
|
+
function toBridgeError(error, fallbackCode) {
|
|
2068
|
+
if (error instanceof PluginBridgeError)
|
|
2069
|
+
return error;
|
|
2070
|
+
if (error instanceof Error)
|
|
2071
|
+
return new PluginBridgeError(fallbackCode, error.message);
|
|
2072
|
+
return new PluginBridgeError(fallbackCode, String(error));
|
|
2073
|
+
}
|