@mearl/client 2.20.6 → 2.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -2
- package/dist/cli.js +10 -5
- package/dist/commands.d.ts +14 -5
- package/dist/commands.js +21 -2
- package/dist/generated-browser-action-protocol.d.ts +11 -1
- package/dist/generated-browser-action-protocol.js +3 -0
- package/dist/generated-plugin-diagnostics.d.ts +110 -0
- package/dist/generated-plugin-diagnostics.js +1 -0
- package/dist/generated-plugin-execution.d.ts +27 -0
- package/dist/generated-plugin-execution.js +1 -0
- package/dist/generated-plugin-models.d.ts +64 -0
- package/dist/generated-plugin-models.js +1 -0
- package/dist/generated-plugin-pages.d.ts +76 -0
- package/dist/generated-plugin-pages.js +59 -0
- package/dist/generated-plugin-recovery.d.ts +32 -0
- package/dist/generated-plugin-recovery.js +1 -0
- package/dist/generated-plugin-state.d.ts +50 -0
- package/dist/generated-plugin-state.js +1 -0
- package/dist/generated-plugin-tasks.d.ts +74 -0
- package/dist/generated-plugin-tasks.js +7 -0
- package/dist/generated-toolbox-icon.d.ts +10 -0
- package/dist/generated-toolbox-icon.js +37 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +6 -0
- package/dist/plugin-task-host.d.ts +1 -0
- package/dist/plugin-task-host.js +14 -0
- package/dist/plugin-worker.d.ts +1 -0
- package/dist/plugin-worker.js +17 -0
- package/dist/plugin.d.ts +57 -4
- package/dist/plugin.js +296 -15
- package/dist/pluginCliOutput.d.ts +2 -0
- package/dist/pluginCliOutput.js +7 -0
- package/dist/pluginCommandWorker.d.ts +2 -0
- package/dist/pluginCommandWorker.js +174 -0
- package/dist/pluginDiagnostics.d.ts +26 -0
- package/dist/pluginDiagnostics.js +309 -0
- package/dist/pluginExecution.d.ts +21 -0
- package/dist/pluginExecution.js +43 -0
- package/dist/pluginExecutor.d.ts +29 -0
- package/dist/pluginExecutor.js +223 -0
- package/dist/pluginExecutorProtocol.d.ts +55 -0
- package/dist/pluginExecutorProtocol.js +47 -0
- package/dist/pluginExecutorSupervisor.d.ts +4 -0
- package/dist/pluginExecutorSupervisor.js +63 -0
- package/dist/pluginHost.d.ts +20 -0
- package/dist/pluginHost.js +53 -0
- package/dist/pluginHostConnection.d.ts +35 -0
- package/dist/pluginHostConnection.js +257 -0
- package/dist/pluginHostFiles.d.ts +1 -0
- package/dist/pluginHostFiles.js +21 -0
- package/dist/pluginHostPaths.d.ts +11 -0
- package/dist/pluginHostPaths.js +22 -0
- package/dist/pluginHostServer.d.ts +7 -0
- package/dist/pluginHostServer.js +252 -0
- package/dist/pluginModelCatalog.d.ts +14 -0
- package/dist/pluginModelCatalog.js +51 -0
- package/dist/pluginModelDefinition.d.ts +6 -0
- package/dist/pluginModelDefinition.js +75 -0
- package/dist/pluginModelSessionWorker.d.ts +23 -0
- package/dist/pluginModelSessionWorker.js +60 -0
- package/dist/pluginModelSessions.d.ts +16 -0
- package/dist/pluginModelSessions.js +234 -0
- package/dist/pluginModels.d.ts +17 -0
- package/dist/pluginModels.js +47 -0
- package/dist/pluginPageBundle.js +74 -17
- package/dist/pluginPageDefinition.d.ts +3 -0
- package/dist/pluginPageDefinition.js +62 -0
- package/dist/pluginPageManager.d.ts +57 -0
- package/dist/pluginPageManager.js +877 -0
- package/dist/pluginPages.d.ts +21 -0
- package/dist/pluginPages.js +46 -0
- package/dist/pluginProcessTree.d.ts +2 -0
- package/dist/pluginProcessTree.js +31 -0
- package/dist/pluginRecoveryDefinition.d.ts +3 -0
- package/dist/pluginRecoveryDefinition.js +56 -0
- package/dist/pluginRegistry.d.ts +2 -3
- package/dist/pluginRegistry.js +14 -13
- package/dist/pluginRevision.d.ts +14 -0
- package/dist/pluginRevision.js +227 -0
- package/dist/pluginRuntimeCleanup.d.ts +2 -0
- package/dist/pluginRuntimeCleanup.js +55 -0
- package/dist/pluginState.d.ts +26 -0
- package/dist/pluginState.js +28 -0
- package/dist/pluginStateDefinition.d.ts +4 -0
- package/dist/pluginStateDefinition.js +68 -0
- package/dist/pluginStateManager.d.ts +24 -0
- package/dist/pluginStateManager.js +132 -0
- package/dist/pluginStateStore.d.ts +57 -0
- package/dist/pluginStateStore.js +396 -0
- package/dist/pluginTaskManager.d.ts +92 -0
- package/dist/pluginTaskManager.js +1080 -0
- package/dist/pluginTaskProtocol.d.ts +47 -0
- package/dist/pluginTaskProtocol.js +1 -0
- package/dist/pluginTaskStore.d.ts +53 -0
- package/dist/pluginTaskStore.js +319 -0
- package/dist/pluginTaskWire.d.ts +4 -0
- package/dist/pluginTaskWire.js +42 -0
- package/dist/pluginTasks.d.ts +51 -0
- package/dist/pluginTasks.js +181 -0
- package/dist/pythonPlugin.d.ts +13 -2
- package/dist/pythonPlugin.js +235 -30
- package/dist/socket.js +5 -2
- package/dist/unified.d.ts +1 -0
- package/dist/unified.js +81 -0
- package/package.json +7 -3
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
import { diagnosticBinding } from './pluginDiagnostics.js';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { isAbsolute, join } from 'node:path';
|
|
6
|
+
import { capturePluginRevision } from './pluginRevision.js';
|
|
7
|
+
import { findPluginRegistration } from './plugin.js';
|
|
8
|
+
import { compilePluginPageSchema } from './pluginPageDefinition.js';
|
|
9
|
+
import { UnifiedClient, decodeBrowserTarget } from './unified.js';
|
|
10
|
+
import { pluginHostPaths } from './pluginHostPaths.js';
|
|
11
|
+
import { serializePluginExecutionError, pluginExecutionError } from './pluginExecutorProtocol.js';
|
|
12
|
+
import { isPluginTaskActive } from './generated-plugin-tasks.js';
|
|
13
|
+
import { isPluginPageInstanceId, matchesPluginPageURL, isPluginPageInvalidation, isPluginPageUnavailable, pluginPageUnavailable, PLUGIN_PAGE_LEASE_MS, } from './generated-plugin-pages.js';
|
|
14
|
+
function failure(code, message) {
|
|
15
|
+
return Object.assign(new Error(message), { code });
|
|
16
|
+
}
|
|
17
|
+
function sameTarget(a, b) {
|
|
18
|
+
return (a.browserSessionId === b.browserSessionId &&
|
|
19
|
+
a.tabId === b.tabId &&
|
|
20
|
+
a.frameId === b.frameId &&
|
|
21
|
+
a.documentId === b.documentId);
|
|
22
|
+
}
|
|
23
|
+
/** The browser owns document identity; this service owns revisions and event execution. */
|
|
24
|
+
export class PluginPageManager {
|
|
25
|
+
tasks;
|
|
26
|
+
states;
|
|
27
|
+
releaseModels;
|
|
28
|
+
instances = new Map();
|
|
29
|
+
changes = new EventEmitter().setMaxListeners(0);
|
|
30
|
+
heartbeat;
|
|
31
|
+
reconciling = new Set();
|
|
32
|
+
closed = false;
|
|
33
|
+
describing = new Set();
|
|
34
|
+
constructor(tasks, states, releaseModels) {
|
|
35
|
+
this.tasks = tasks;
|
|
36
|
+
this.states = states;
|
|
37
|
+
this.releaseModels = releaseModels;
|
|
38
|
+
this.heartbeat = setInterval(() => {
|
|
39
|
+
void this.reconcile();
|
|
40
|
+
}, 10000);
|
|
41
|
+
this.heartbeat.unref();
|
|
42
|
+
}
|
|
43
|
+
async resolveStateTarget(plugin, instanceId, bridge, options) {
|
|
44
|
+
const record = this.own(plugin, instanceId, bridge);
|
|
45
|
+
await this.bind(record, bridge, options);
|
|
46
|
+
await this.validate(record);
|
|
47
|
+
return {
|
|
48
|
+
target: { ...record.value.target },
|
|
49
|
+
assertActive: () => {
|
|
50
|
+
if (this.closed ||
|
|
51
|
+
record.value.state !== 'active' ||
|
|
52
|
+
this.instances.get(record.value.instanceId) !== record)
|
|
53
|
+
throw failure('PLUGIN_STATE_SCOPE_ENDED', 'Plugin page state scope has ended');
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
diagnosticSnapshot() {
|
|
58
|
+
return [...this.instances.values()].map(record => ({
|
|
59
|
+
plugin: record.value.plugin,
|
|
60
|
+
installationId: record.value.installationId,
|
|
61
|
+
revision: record.value.revision,
|
|
62
|
+
host: { hostId: record.value.host.hostId, epoch: record.value.host.epoch },
|
|
63
|
+
binding: diagnosticBinding(record.value),
|
|
64
|
+
observation: 'service-snapshot',
|
|
65
|
+
state: record.value.state,
|
|
66
|
+
lastReceived: record.lastReceived,
|
|
67
|
+
queued: record.deliveries.filter(delivery => delivery.state === 'queued').length,
|
|
68
|
+
running: record.running.size,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
async reconcile() {
|
|
72
|
+
if (this.closed)
|
|
73
|
+
return;
|
|
74
|
+
await Promise.all([...this.instances.values()].map(async (record) => {
|
|
75
|
+
const id = record.value.instanceId;
|
|
76
|
+
if (!record.ready || record.value.state !== 'active' || this.reconciling.has(id))
|
|
77
|
+
return;
|
|
78
|
+
this.reconciling.add(id);
|
|
79
|
+
try {
|
|
80
|
+
await this.validate(record);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (!isPluginPageUnavailable(error))
|
|
84
|
+
await this.invalidate(record, error);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
this.reconciling.delete(id);
|
|
88
|
+
}
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
bounded(promise, milliseconds) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const timer = setTimeout(() => reject(failure('PLUGIN_PAGE_TIMEOUT', 'Plugin page browser request timed out')), milliseconds);
|
|
94
|
+
promise.then(resolve, reject).finally(() => clearTimeout(timer));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
async invoke(record, data, timeoutSec) {
|
|
98
|
+
const payload = {
|
|
99
|
+
...data,
|
|
100
|
+
tabId: record.value.target.tabId,
|
|
101
|
+
instanceId: record.value.instanceId,
|
|
102
|
+
...(timeoutSec !== undefined ? { timeoutSec } : {}),
|
|
103
|
+
};
|
|
104
|
+
return record.client
|
|
105
|
+
? record.client.invoke('page_runtime', payload, {
|
|
106
|
+
...record.options,
|
|
107
|
+
...(timeoutSec !== undefined ? { timeoutSec } : {}),
|
|
108
|
+
})
|
|
109
|
+
: record.bridge.invoke('page_runtime', payload, {
|
|
110
|
+
...record.options,
|
|
111
|
+
...(timeoutSec !== undefined ? { timeoutSec } : {}),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
unavailable(record, error) {
|
|
115
|
+
if (isPluginPageInvalidation(error))
|
|
116
|
+
return error;
|
|
117
|
+
record.unavailable = true;
|
|
118
|
+
if (Date.now() - record.lastVerifiedAt >= PLUGIN_PAGE_LEASE_MS)
|
|
119
|
+
return failure('PLUGIN_PAGE_INVALIDATED', 'Plugin page host recovery lease expired');
|
|
120
|
+
return pluginPageUnavailable(error);
|
|
121
|
+
}
|
|
122
|
+
async validate(record) {
|
|
123
|
+
if (this.closed || record.value.state !== 'active')
|
|
124
|
+
throw failure('PLUGIN_PAGE_INVALIDATED', 'Plugin page instance is no longer active');
|
|
125
|
+
const registration = findPluginRegistration(record.value.plugin);
|
|
126
|
+
let installation;
|
|
127
|
+
try {
|
|
128
|
+
installation = JSON.parse(readFileSync(join(pluginHostPaths().installations, `${record.value.plugin}.json`), 'utf8'));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* A removed installation invalidates its instances. */
|
|
132
|
+
}
|
|
133
|
+
if (!registration || installation?.installationId !== record.value.installationId)
|
|
134
|
+
throw failure('PLUGIN_PAGE_INVALIDATED', 'Plugin installation was removed or replaced');
|
|
135
|
+
const bridge = record.bridge;
|
|
136
|
+
let current;
|
|
137
|
+
try {
|
|
138
|
+
current = await this.bounded(this.invoke(record, { operation: 'get' }), 8000);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (!record.client &&
|
|
142
|
+
record.bridge !== bridge &&
|
|
143
|
+
!this.closed &&
|
|
144
|
+
record.value.state === 'active')
|
|
145
|
+
return this.validate(record);
|
|
146
|
+
throw this.unavailable(record, error);
|
|
147
|
+
}
|
|
148
|
+
if (this.closed || record.value.state !== 'active')
|
|
149
|
+
throw failure('PLUGIN_PAGE_INVALIDATED', 'Plugin page instance ended during validation');
|
|
150
|
+
if (current.state !== 'active' ||
|
|
151
|
+
!sameTarget(current.target, record.value.target) ||
|
|
152
|
+
!matchesPluginPageURL(record.runtime.matches, current.target.url))
|
|
153
|
+
throw failure('PLUGIN_PAGE_INVALIDATED', 'Plugin page document changed');
|
|
154
|
+
record.value.target.url = current.target.url;
|
|
155
|
+
record.lastVerifiedAt = Date.now();
|
|
156
|
+
const recovered = record.unavailable;
|
|
157
|
+
record.unavailable = false;
|
|
158
|
+
if (recovered)
|
|
159
|
+
queueMicrotask(() => this.drain(record));
|
|
160
|
+
}
|
|
161
|
+
own(plugin, instanceId, bridge) {
|
|
162
|
+
if (bridge.scope.plugin && bridge.scope.plugin !== plugin)
|
|
163
|
+
throw failure('PLUGIN_PAGE_NOT_FOUND', 'Plugin page instance was not found');
|
|
164
|
+
if (!isPluginPageInstanceId(instanceId))
|
|
165
|
+
throw failure('PLUGIN_PAGE_NOT_FOUND', 'Invalid plugin page instance ID');
|
|
166
|
+
const record = this.instances.get(instanceId);
|
|
167
|
+
if (!record || record.value.plugin !== plugin)
|
|
168
|
+
throw failure('PLUGIN_PAGE_NOT_FOUND', 'Plugin page instance was not found');
|
|
169
|
+
return record;
|
|
170
|
+
}
|
|
171
|
+
async bind(record, bridge, options) {
|
|
172
|
+
if (bridge.scope.bridge !== 'caller' ||
|
|
173
|
+
(!record.client && record.bridge.id === bridge.id) ||
|
|
174
|
+
record.value.state !== 'active')
|
|
175
|
+
return;
|
|
176
|
+
let current;
|
|
177
|
+
try {
|
|
178
|
+
current = await this.bounded(bridge.invoke('page_runtime', {
|
|
179
|
+
operation: 'get',
|
|
180
|
+
tabId: record.value.target.tabId,
|
|
181
|
+
instanceId: record.value.instanceId,
|
|
182
|
+
}, options), 8000);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
throw this.unavailable(record, error);
|
|
186
|
+
}
|
|
187
|
+
if (this.closed || record.value.state !== 'active')
|
|
188
|
+
throw failure('PLUGIN_PAGE_INVALIDATED', 'Plugin page instance ended while binding');
|
|
189
|
+
if (!sameTarget(current.target, record.value.target))
|
|
190
|
+
throw failure('PLUGIN_PAGE_TARGET_CHANGED', 'Plugin page belongs to a different browser document');
|
|
191
|
+
if (record.client)
|
|
192
|
+
return;
|
|
193
|
+
record.bridge = bridge;
|
|
194
|
+
record.options = options;
|
|
195
|
+
}
|
|
196
|
+
append(record, event) {
|
|
197
|
+
record.history.push({ ...event, sequence: ++record.value.sequence, time: Date.now() });
|
|
198
|
+
while (record.history.length > 256 ||
|
|
199
|
+
Buffer.byteLength(JSON.stringify(record.history)) > 384 * 1024)
|
|
200
|
+
record.history.shift();
|
|
201
|
+
this.changes.emit(record.value.instanceId);
|
|
202
|
+
}
|
|
203
|
+
async waitForChange(record, cursor, waitMs, signal) {
|
|
204
|
+
if (!waitMs || cursor !== record.value.sequence || record.value.state !== 'active')
|
|
205
|
+
return;
|
|
206
|
+
await new Promise((resolve, reject) => {
|
|
207
|
+
const finish = () => {
|
|
208
|
+
cleanup();
|
|
209
|
+
resolve();
|
|
210
|
+
};
|
|
211
|
+
const abort = () => {
|
|
212
|
+
cleanup();
|
|
213
|
+
reject(signal?.reason ?? new Error('Plugin page subscription closed'));
|
|
214
|
+
};
|
|
215
|
+
const timer = setTimeout(finish, waitMs);
|
|
216
|
+
const cleanup = () => {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
this.changes.off(record.value.instanceId, finish);
|
|
219
|
+
signal?.removeEventListener('abort', abort);
|
|
220
|
+
};
|
|
221
|
+
this.changes.once(record.value.instanceId, finish);
|
|
222
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
223
|
+
if (signal?.aborted)
|
|
224
|
+
abort();
|
|
225
|
+
else if (record.value.sequence !== cursor || record.value.state !== 'active')
|
|
226
|
+
finish();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
async invalidate(record, error, state = 'invalidated') {
|
|
230
|
+
if (record.value.state !== 'active')
|
|
231
|
+
return;
|
|
232
|
+
record.value.state = state;
|
|
233
|
+
record.value.error = serializePluginExecutionError(error);
|
|
234
|
+
this.append(record, { type: 'invalidated', error: record.value.error });
|
|
235
|
+
for (const taskId of record.taskIds)
|
|
236
|
+
this.tasks.cancel(taskId, { access: 'public', source: 'runtime', bridge: 'service' });
|
|
237
|
+
for (const delivery of record.deliveries) {
|
|
238
|
+
if (delivery.taskId)
|
|
239
|
+
this.tasks.cancel(delivery.taskId, {
|
|
240
|
+
access: 'public',
|
|
241
|
+
source: 'runtime',
|
|
242
|
+
bridge: 'service',
|
|
243
|
+
});
|
|
244
|
+
if (['queued', 'running'].includes(delivery.state))
|
|
245
|
+
delivery.state = 'invalidated';
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
this.states.store.discardScope(record.value.installationId, `instance:${record.value.instanceId}`);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
console.error('Plugin instance cache cleanup failed:', error);
|
|
252
|
+
}
|
|
253
|
+
record.release();
|
|
254
|
+
try {
|
|
255
|
+
await this.releaseModels?.(record.value.instanceId);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
try {
|
|
259
|
+
await this.bounded(this.invoke(record, { operation: 'detach' }), 2000);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
/* Browser lease expiry disposes unreachable instances. */
|
|
263
|
+
}
|
|
264
|
+
record.client?.disconnect();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async call(plugin, input, bridge, options = {}, execution = {
|
|
268
|
+
cwd: process.cwd(),
|
|
269
|
+
environment: Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined)),
|
|
270
|
+
}) {
|
|
271
|
+
if (this.closed)
|
|
272
|
+
throw failure('PLUGIN_PAGE_HOST_STOPPING', 'Plugin page service is stopping');
|
|
273
|
+
execution.signal?.throwIfAborted();
|
|
274
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
275
|
+
throw new Error('Invalid plugin page request');
|
|
276
|
+
if (!/^[a-z][a-z0-9-]*$/.test(plugin) ||
|
|
277
|
+
(bridge.scope.plugin && bridge.scope.plugin !== plugin))
|
|
278
|
+
throw new Error('Invalid plugin page scope');
|
|
279
|
+
const operation = input.operation;
|
|
280
|
+
if (execution.expectedTarget && operation === 'activation' && input.enabled !== undefined)
|
|
281
|
+
throw failure('PLUGIN_PAGE_TARGET_CHANGED', 'Document-bound calls cannot configure browser activation');
|
|
282
|
+
if (operation === 'attach' || operation === 'activation') {
|
|
283
|
+
if (Object.keys(options).some(key => !['browser', 'connector', 'session', 'local', 'transport', 'timeoutSec'].includes(key)))
|
|
284
|
+
throw new Error('Invalid plugin page routing options');
|
|
285
|
+
if (bridge.scope.bridge === 'service' && !decodeBrowserTarget(options.browser))
|
|
286
|
+
throw new Error('Plugin pages require a stable browser ID');
|
|
287
|
+
if (!execution ||
|
|
288
|
+
!isAbsolute(execution.cwd) ||
|
|
289
|
+
!statSync(execution.cwd).isDirectory() ||
|
|
290
|
+
!execution.environment ||
|
|
291
|
+
Buffer.byteLength(JSON.stringify(execution.environment)) > 512 * 1024 ||
|
|
292
|
+
Object.values(execution.environment).some(value => typeof value !== 'string'))
|
|
293
|
+
throw new Error('Invalid plugin page execution context');
|
|
294
|
+
}
|
|
295
|
+
if (operation === 'activation' && input.enabled !== true) {
|
|
296
|
+
const client = bridge.scope.bridge === 'service' ? new UnifiedClient() : undefined;
|
|
297
|
+
try {
|
|
298
|
+
return client
|
|
299
|
+
? await client.invoke('page_runtime', { ...input, plugin }, options)
|
|
300
|
+
: await bridge.invoke('page_runtime', { ...input, plugin }, options);
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
client?.disconnect();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (operation === 'attach' || operation === 'activation') {
|
|
307
|
+
const revision = execution.revision ?? capturePluginRevision(plugin);
|
|
308
|
+
if (input.installationId !== undefined && input.installationId !== revision.installationId)
|
|
309
|
+
throw failure('PLUGIN_PAGE_INSTALLATION_CHANGED', 'Page activation belongs to another plugin installation');
|
|
310
|
+
const release = this.tasks.pinRevision(revision);
|
|
311
|
+
let retained = false;
|
|
312
|
+
const client = bridge.scope.bridge === 'service' ? new UnifiedClient() : undefined;
|
|
313
|
+
const invoke = (data) => client
|
|
314
|
+
? client.invoke('page_runtime', data, options)
|
|
315
|
+
: bridge.invoke('page_runtime', data, options);
|
|
316
|
+
try {
|
|
317
|
+
const controller = new AbortController();
|
|
318
|
+
const description = {
|
|
319
|
+
controller,
|
|
320
|
+
promise: this.tasks.describeRevision(revision, execution.signal
|
|
321
|
+
? AbortSignal.any([controller.signal, execution.signal])
|
|
322
|
+
: controller.signal, { cache: true }),
|
|
323
|
+
};
|
|
324
|
+
this.describing.add(description);
|
|
325
|
+
let loaded;
|
|
326
|
+
try {
|
|
327
|
+
loaded = (await description.promise);
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
this.describing.delete(description);
|
|
331
|
+
}
|
|
332
|
+
if (this.closed)
|
|
333
|
+
throw new Error('Plugin page host stopped during attachment');
|
|
334
|
+
const runtime = loaded.pageRuntime;
|
|
335
|
+
if (!runtime)
|
|
336
|
+
throw failure('PLUGIN_PAGE_NOT_DECLARED', 'Plugin has no pageRuntime declaration');
|
|
337
|
+
if (operation === 'activation')
|
|
338
|
+
return await invoke({
|
|
339
|
+
...input,
|
|
340
|
+
operation: 'activation',
|
|
341
|
+
plugin,
|
|
342
|
+
installationId: revision.installationId,
|
|
343
|
+
allowedMatches: runtime.matches,
|
|
344
|
+
});
|
|
345
|
+
if (this.instances.size >= 256) {
|
|
346
|
+
for (const [id, item] of this.instances)
|
|
347
|
+
if (item.value.state !== 'active' && !item.running.size)
|
|
348
|
+
this.instances.delete(id);
|
|
349
|
+
if (this.instances.size >= 256)
|
|
350
|
+
throw new Error('Too many plugin page instances');
|
|
351
|
+
}
|
|
352
|
+
const target = await invoke({
|
|
353
|
+
operation: 'resolve',
|
|
354
|
+
tabId: input.tabId,
|
|
355
|
+
frame: input.frame,
|
|
356
|
+
expectedDocumentId: input.documentId,
|
|
357
|
+
});
|
|
358
|
+
if (this.closed)
|
|
359
|
+
throw failure('PLUGIN_PAGE_HOST_STOPPING', 'Plugin page service is stopping');
|
|
360
|
+
execution.signal?.throwIfAborted();
|
|
361
|
+
if (execution.expectedTarget && !sameTarget(target, execution.expectedTarget))
|
|
362
|
+
throw failure('PLUGIN_PAGE_TARGET_CHANGED', 'Nested plugin must use the inherited document');
|
|
363
|
+
if (!matchesPluginPageURL(runtime.matches, target.url))
|
|
364
|
+
throw failure('PLUGIN_PAGE_URL_NOT_ALLOWED', 'Plugin page URL is outside its declared matches');
|
|
365
|
+
const existing = [...this.instances.values()].find(item => item.value.state === 'active' &&
|
|
366
|
+
item.value.plugin === plugin &&
|
|
367
|
+
sameTarget(item.value.target, target));
|
|
368
|
+
if (existing &&
|
|
369
|
+
execution.revision &&
|
|
370
|
+
existing.value.revision !== revision.revision &&
|
|
371
|
+
input.replace !== true)
|
|
372
|
+
throw failure('PLUGIN_PAGE_REVISION_CHANGED', 'Existing page instance uses another plugin revision');
|
|
373
|
+
if (existing && input.replace !== true) {
|
|
374
|
+
try {
|
|
375
|
+
await existing.initialized;
|
|
376
|
+
await this.bind(existing, bridge, options);
|
|
377
|
+
await this.validate(existing);
|
|
378
|
+
if (input.activationGeneration === undefined)
|
|
379
|
+
await invoke({
|
|
380
|
+
operation: 'get',
|
|
381
|
+
instanceId: existing.value.instanceId,
|
|
382
|
+
tabId: target.tabId,
|
|
383
|
+
retainManual: true,
|
|
384
|
+
});
|
|
385
|
+
return existing.value;
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
if (isPluginPageUnavailable(error))
|
|
389
|
+
throw error;
|
|
390
|
+
await this.invalidate(existing, error);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (existing)
|
|
394
|
+
await this.invalidate(existing, new Error('Plugin page instance replaced'), 'detached');
|
|
395
|
+
const value = {
|
|
396
|
+
instanceId: `pi_${randomUUID()}`,
|
|
397
|
+
plugin,
|
|
398
|
+
installationId: revision.installationId,
|
|
399
|
+
revision: revision.revision,
|
|
400
|
+
host: this.tasks.host,
|
|
401
|
+
target,
|
|
402
|
+
state: 'active',
|
|
403
|
+
sequence: 0,
|
|
404
|
+
createdAt: Date.now(),
|
|
405
|
+
};
|
|
406
|
+
let initialized;
|
|
407
|
+
let initializationFailed;
|
|
408
|
+
const initialization = new Promise((resolve, reject) => {
|
|
409
|
+
initialized = resolve;
|
|
410
|
+
initializationFailed = reject;
|
|
411
|
+
});
|
|
412
|
+
void initialization.catch(() => { });
|
|
413
|
+
const record = {
|
|
414
|
+
initialized: initialization,
|
|
415
|
+
value,
|
|
416
|
+
runtime,
|
|
417
|
+
revision,
|
|
418
|
+
release,
|
|
419
|
+
bridge,
|
|
420
|
+
options: { ...options },
|
|
421
|
+
client,
|
|
422
|
+
ready: false,
|
|
423
|
+
lastVerifiedAt: Date.now(),
|
|
424
|
+
unavailable: false,
|
|
425
|
+
taskIds: new Set(),
|
|
426
|
+
execution: { cwd: execution.cwd, environment: { ...execution.environment } },
|
|
427
|
+
history: [],
|
|
428
|
+
deliveries: [],
|
|
429
|
+
lastReceived: 0,
|
|
430
|
+
running: new Set(),
|
|
431
|
+
};
|
|
432
|
+
this.instances.set(value.instanceId, record);
|
|
433
|
+
try {
|
|
434
|
+
const attached = await invoke({
|
|
435
|
+
operation: 'attach',
|
|
436
|
+
instance: value,
|
|
437
|
+
runtime,
|
|
438
|
+
input: input.input ?? {},
|
|
439
|
+
frame: input.frame ?? 'business',
|
|
440
|
+
activationGeneration: input.activationGeneration,
|
|
441
|
+
});
|
|
442
|
+
if (!sameTarget(attached.target, target))
|
|
443
|
+
throw failure('PLUGIN_PAGE_TARGET_CHANGED', 'Document changed while attaching plugin page');
|
|
444
|
+
await this.validate(record);
|
|
445
|
+
execution.signal?.throwIfAborted();
|
|
446
|
+
if (runtime.initialize) {
|
|
447
|
+
const parent = execution.parentIdentity;
|
|
448
|
+
if (parent?.taskId)
|
|
449
|
+
this.tasks.assertChildBudget(parent.taskId);
|
|
450
|
+
const { taskId } = this.start(record, runtime.initialize, { input: input.input ?? {} }, `initialize:${value.instanceId}`, parent);
|
|
451
|
+
const scope = {
|
|
452
|
+
access: 'public',
|
|
453
|
+
source: 'runtime',
|
|
454
|
+
bridge: 'service',
|
|
455
|
+
};
|
|
456
|
+
const cancel = () => this.tasks.cancel(taskId, scope);
|
|
457
|
+
execution.signal?.addEventListener('abort', cancel, { once: true });
|
|
458
|
+
if (execution.signal?.aborted)
|
|
459
|
+
cancel();
|
|
460
|
+
try {
|
|
461
|
+
if (parent?.taskId && execution.signal)
|
|
462
|
+
await this.tasks.awaitChild(parent.taskId, taskId, scope, execution.signal);
|
|
463
|
+
else
|
|
464
|
+
await this.wait(record, taskId);
|
|
465
|
+
}
|
|
466
|
+
finally {
|
|
467
|
+
execution.signal?.removeEventListener('abort', cancel);
|
|
468
|
+
record.taskIds.delete(taskId);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
execution.signal?.throwIfAborted();
|
|
472
|
+
await this.validate(record);
|
|
473
|
+
execution.signal?.throwIfAborted();
|
|
474
|
+
record.ready = true;
|
|
475
|
+
initialized();
|
|
476
|
+
this.drain(record);
|
|
477
|
+
retained = true;
|
|
478
|
+
return value;
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
initializationFailed(error);
|
|
482
|
+
await this.invalidate(record, error);
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
if (!retained) {
|
|
488
|
+
release();
|
|
489
|
+
client?.disconnect();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (operation === 'list')
|
|
494
|
+
return {
|
|
495
|
+
instances: [...this.instances.values()]
|
|
496
|
+
.filter(item => item.value.plugin === plugin &&
|
|
497
|
+
(!execution.expectedTarget ||
|
|
498
|
+
sameTarget(item.value.target, execution.expectedTarget)))
|
|
499
|
+
.map(item => item.value),
|
|
500
|
+
};
|
|
501
|
+
const record = this.own(plugin, input.instanceId, bridge);
|
|
502
|
+
if (execution.expectedTarget && !sameTarget(record.value.target, execution.expectedTarget))
|
|
503
|
+
throw failure('PLUGIN_PAGE_TARGET_CHANGED', 'Nested plugin must use the inherited document');
|
|
504
|
+
if (execution.revision && record.value.revision !== execution.revision.revision)
|
|
505
|
+
throw failure('PLUGIN_PAGE_REVISION_CHANGED', 'Page instance uses another plugin revision');
|
|
506
|
+
await this.bind(record, bridge, options);
|
|
507
|
+
if (operation === 'detach') {
|
|
508
|
+
await this.invalidate(record, new Error('Plugin page instance detached'), 'detached');
|
|
509
|
+
return record.value;
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
if (record.value.state === 'active')
|
|
513
|
+
await this.validate(record);
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
if (isPluginPageUnavailable(error))
|
|
517
|
+
throw error;
|
|
518
|
+
await this.invalidate(record, error);
|
|
519
|
+
}
|
|
520
|
+
if (operation === 'get')
|
|
521
|
+
return record.value;
|
|
522
|
+
if (operation === 'events') {
|
|
523
|
+
const cursor = input.cursor ?? 0;
|
|
524
|
+
const waitMs = input.waitMs ?? 0;
|
|
525
|
+
if (!Number.isSafeInteger(cursor) ||
|
|
526
|
+
Number(cursor) < 0 ||
|
|
527
|
+
Number(cursor) > record.value.sequence)
|
|
528
|
+
throw new Error('Invalid plugin page event cursor');
|
|
529
|
+
if (!Number.isSafeInteger(waitMs) || Number(waitMs) < 0 || Number(waitMs) > 25000)
|
|
530
|
+
throw new Error('Invalid plugin page event wait');
|
|
531
|
+
await this.waitForChange(record, Number(cursor), Number(waitMs), execution.signal);
|
|
532
|
+
execution.signal?.throwIfAborted();
|
|
533
|
+
const first = record.history[0]?.sequence ?? record.value.sequence + 1;
|
|
534
|
+
const events = [];
|
|
535
|
+
let bytes = 0;
|
|
536
|
+
for (const event of record.history)
|
|
537
|
+
if (event.sequence > Number(cursor)) {
|
|
538
|
+
const size = Buffer.byteLength(JSON.stringify(event));
|
|
539
|
+
if (events.length && bytes + size > 192 * 1024)
|
|
540
|
+
break;
|
|
541
|
+
events.push(event);
|
|
542
|
+
bytes += size;
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
instance: record.value,
|
|
546
|
+
events,
|
|
547
|
+
nextCursor: events.at(-1)?.sequence ?? Number(cursor),
|
|
548
|
+
firstAvailableSequence: first,
|
|
549
|
+
gap: Number(cursor) < first - 1,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
if (operation !== 'send')
|
|
553
|
+
throw new Error('Unknown plugin page operation');
|
|
554
|
+
const timeoutSec = input.timeoutSec;
|
|
555
|
+
if (timeoutSec !== undefined &&
|
|
556
|
+
(typeof timeoutSec !== 'number' ||
|
|
557
|
+
!Number.isFinite(timeoutSec) ||
|
|
558
|
+
timeoutSec <= 0 ||
|
|
559
|
+
timeoutSec > 300))
|
|
560
|
+
throw new Error('Page request timeoutSec must be between 0 and 300');
|
|
561
|
+
await this.validate(record);
|
|
562
|
+
execution.signal?.throwIfAborted();
|
|
563
|
+
const json = Buffer.from(JSON.stringify(input.message ?? null));
|
|
564
|
+
if (json.length > 24 * 1024 * 1024)
|
|
565
|
+
throw new Error('Plugin page message exceeds 24 MiB');
|
|
566
|
+
const requestId = randomUUID();
|
|
567
|
+
const cancel = () => {
|
|
568
|
+
void this.invoke(record, { operation: 'cancel', requestId }).catch(() => { });
|
|
569
|
+
};
|
|
570
|
+
execution.signal?.addEventListener('abort', cancel, { once: true });
|
|
571
|
+
try {
|
|
572
|
+
let result;
|
|
573
|
+
if (json.length <= 192 * 1024)
|
|
574
|
+
result = await this.invoke(record, {
|
|
575
|
+
operation: 'send',
|
|
576
|
+
message: input.message,
|
|
577
|
+
requestId,
|
|
578
|
+
}, timeoutSec);
|
|
579
|
+
else {
|
|
580
|
+
const transferId = randomUUID();
|
|
581
|
+
try {
|
|
582
|
+
await this.invoke(record, {
|
|
583
|
+
operation: 'message_begin',
|
|
584
|
+
transferId,
|
|
585
|
+
bytes: json.length,
|
|
586
|
+
sha256: createHash('sha256').update(json).digest('hex'),
|
|
587
|
+
});
|
|
588
|
+
for (let offset = 0, index = 0; offset < json.length; offset += 192 * 1024, index++) {
|
|
589
|
+
await this.validate(record);
|
|
590
|
+
execution.signal?.throwIfAborted();
|
|
591
|
+
await this.invoke(record, {
|
|
592
|
+
operation: 'message_chunk',
|
|
593
|
+
transferId,
|
|
594
|
+
index,
|
|
595
|
+
data: json.subarray(offset, offset + 192 * 1024).toString('base64'),
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
await this.validate(record);
|
|
599
|
+
execution.signal?.throwIfAborted();
|
|
600
|
+
result = await this.invoke(record, {
|
|
601
|
+
operation: 'message_commit',
|
|
602
|
+
transferId,
|
|
603
|
+
requestId,
|
|
604
|
+
}, timeoutSec);
|
|
605
|
+
}
|
|
606
|
+
finally {
|
|
607
|
+
await this.invoke(record, { operation: 'message_abort', transferId }).catch(() => { });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
execution.signal?.throwIfAborted();
|
|
611
|
+
await this.validate(record);
|
|
612
|
+
const reply = result;
|
|
613
|
+
if (reply?.kind === 'value')
|
|
614
|
+
return reply.value;
|
|
615
|
+
if (reply?.kind !== 'resource' ||
|
|
616
|
+
!Number.isSafeInteger(reply.bytes) ||
|
|
617
|
+
reply.bytes < 1 ||
|
|
618
|
+
reply.bytes > 24 * 1024 * 1024 ||
|
|
619
|
+
reply.chunks !== Math.ceil(reply.bytes / (192 * 1024)))
|
|
620
|
+
throw new Error('Invalid page response resource');
|
|
621
|
+
try {
|
|
622
|
+
const parts = [];
|
|
623
|
+
for (let index = 0; index < reply.chunks; index++) {
|
|
624
|
+
await this.validate(record);
|
|
625
|
+
const chunk = await this.invoke(record, {
|
|
626
|
+
operation: 'result_chunk',
|
|
627
|
+
resultId: reply.resultId,
|
|
628
|
+
index,
|
|
629
|
+
});
|
|
630
|
+
if (chunk.resultId !== reply.resultId ||
|
|
631
|
+
chunk.index !== index ||
|
|
632
|
+
chunk.bytes !== reply.bytes ||
|
|
633
|
+
chunk.sha256 !== reply.sha256 ||
|
|
634
|
+
chunk.chunks !== reply.chunks)
|
|
635
|
+
throw new Error('Page response chunk identity mismatch');
|
|
636
|
+
parts.push(Buffer.from(chunk.data, 'base64'));
|
|
637
|
+
}
|
|
638
|
+
const bytes = Buffer.concat(parts);
|
|
639
|
+
if (bytes.length !== reply.bytes ||
|
|
640
|
+
createHash('sha256').update(bytes).digest('hex') !== reply.sha256)
|
|
641
|
+
throw new Error('Page response integrity check failed');
|
|
642
|
+
await this.validate(record);
|
|
643
|
+
return JSON.parse(bytes.toString('utf8'));
|
|
644
|
+
}
|
|
645
|
+
finally {
|
|
646
|
+
await this.invoke(record, { operation: 'result_release', resultId: reply.resultId }).catch(() => { });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
cancel();
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
finally {
|
|
654
|
+
execution.signal?.removeEventListener('abort', cancel);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
async receive(plugin, input, bridge, options = {}) {
|
|
658
|
+
if (input.kind === 'sync') {
|
|
659
|
+
if (this.closed ||
|
|
660
|
+
!/^[a-z][a-z0-9-]*$/.test(plugin) ||
|
|
661
|
+
(bridge.scope.plugin && bridge.scope.plugin !== plugin) ||
|
|
662
|
+
!Array.isArray(input.instances) ||
|
|
663
|
+
input.instances.length > 32)
|
|
664
|
+
throw new Error('Invalid page reconciliation request');
|
|
665
|
+
const session = (await this.bounded(bridge.invoke('page_runtime', { operation: 'session' }, options), 8000));
|
|
666
|
+
if (typeof input.browserSessionId !== 'string' ||
|
|
667
|
+
session.browserSessionId !== input.browserSessionId)
|
|
668
|
+
throw failure('PLUGIN_PAGE_TARGET_CHANGED', 'Page reconciliation came from another browser session');
|
|
669
|
+
const instances = [];
|
|
670
|
+
for (const value of input.instances) {
|
|
671
|
+
const proposed = value;
|
|
672
|
+
const record = this.instances.get(proposed?.instanceId);
|
|
673
|
+
if (!record ||
|
|
674
|
+
record.value.plugin !== plugin ||
|
|
675
|
+
record.value.state !== 'active' ||
|
|
676
|
+
!proposed.host ||
|
|
677
|
+
proposed.host.hostId !== this.tasks.host.hostId ||
|
|
678
|
+
proposed.host.epoch !== this.tasks.host.epoch ||
|
|
679
|
+
proposed.installationId !== record.value.installationId ||
|
|
680
|
+
proposed.revision !== record.value.revision ||
|
|
681
|
+
!proposed.target ||
|
|
682
|
+
!sameTarget(proposed.target, record.value.target) ||
|
|
683
|
+
record.value.target.browserSessionId !== session.browserSessionId)
|
|
684
|
+
continue;
|
|
685
|
+
try {
|
|
686
|
+
await this.bind(record, bridge, options);
|
|
687
|
+
await this.validate(record);
|
|
688
|
+
instances.push({ instance: record.value, lastReceived: record.lastReceived });
|
|
689
|
+
}
|
|
690
|
+
catch (error) {
|
|
691
|
+
if (isPluginPageUnavailable(error))
|
|
692
|
+
throw error;
|
|
693
|
+
await this.invalidate(record, error);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return { host: this.tasks.host, instances };
|
|
697
|
+
}
|
|
698
|
+
const record = this.own(plugin, input.instanceId, bridge);
|
|
699
|
+
const session = (await this.bounded(bridge.invoke('page_runtime', { operation: 'session' }, options), 8000));
|
|
700
|
+
if (session.browserSessionId !== record.value.target.browserSessionId)
|
|
701
|
+
throw failure('PLUGIN_PAGE_TARGET_CHANGED', 'Page event came from another browser');
|
|
702
|
+
if (input.kind === 'invalidated') {
|
|
703
|
+
if (!input.target || !sameTarget(input.target, record.value.target))
|
|
704
|
+
throw failure('PLUGIN_PAGE_TARGET_CHANGED', 'Invalid document invalidation source');
|
|
705
|
+
await this.invalidate(record, new Error('Browser invalidated the document'));
|
|
706
|
+
return { accepted: true };
|
|
707
|
+
}
|
|
708
|
+
const source = (await this.bounded(bridge.invoke('page_runtime', { operation: 'get', tabId: record.value.target.tabId, instanceId: record.value.instanceId }, options), 8000));
|
|
709
|
+
if (!sameTarget(source.target, record.value.target))
|
|
710
|
+
throw failure('PLUGIN_PAGE_TARGET_CHANGED', 'Page event came from another document');
|
|
711
|
+
await this.bind(record, bridge, options);
|
|
712
|
+
await this.validate(record);
|
|
713
|
+
if (input.kind === 'navigation') {
|
|
714
|
+
this.append(record, { type: 'navigation', payload: { url: record.value.target.url } });
|
|
715
|
+
return { accepted: true };
|
|
716
|
+
}
|
|
717
|
+
const sequence = input.eventSequence;
|
|
718
|
+
if (!Number.isSafeInteger(sequence) || Number(sequence) < 1)
|
|
719
|
+
throw new Error('Invalid page event sequence');
|
|
720
|
+
const duplicate = record.deliveries.find(item => item.sequence === sequence);
|
|
721
|
+
if (Number(sequence) <= record.lastReceived)
|
|
722
|
+
return { accepted: true, duplicate: true, taskId: duplicate?.taskId, gap: !duplicate };
|
|
723
|
+
if (Number(sequence) !== record.lastReceived + 1)
|
|
724
|
+
throw failure('PLUGIN_PAGE_EVENT_GAP', 'Page event stream has a gap; reattach the instance');
|
|
725
|
+
const name = typeof input.name === 'string' ? input.name : '';
|
|
726
|
+
const binding = record.runtime.events?.[name];
|
|
727
|
+
if (!binding || Buffer.byteLength(JSON.stringify(input.payload ?? null)) > 32 * 1024)
|
|
728
|
+
throw new Error('Page event is not declared or exceeds 32 KiB');
|
|
729
|
+
const validate = compilePluginPageSchema(binding.schema);
|
|
730
|
+
if (!validate(input.payload))
|
|
731
|
+
throw failure('PLUGIN_PAGE_EVENT_INVALID', 'Page event payload does not match its schema');
|
|
732
|
+
const waiting = record.deliveries.filter(item => item.name === name && item.state === 'queued');
|
|
733
|
+
if (binding.policy === 'latest') {
|
|
734
|
+
for (const previous of waiting) {
|
|
735
|
+
previous.state = 'superseded';
|
|
736
|
+
this.append(record, { type: 'superseded', eventSequence: previous.sequence, name });
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
else if (waiting.length >= (binding.maxPending ?? 32))
|
|
740
|
+
throw failure('PLUGIN_PAGE_QUEUE_FULL', 'Page event queue is full');
|
|
741
|
+
if (record.deliveries.filter(item => ['queued', 'running'].includes(item.state)).length >= 128)
|
|
742
|
+
throw failure('PLUGIN_PAGE_QUEUE_FULL', 'Page event queue is full');
|
|
743
|
+
const delivery = {
|
|
744
|
+
sequence: Number(sequence),
|
|
745
|
+
name,
|
|
746
|
+
payload: input.payload,
|
|
747
|
+
state: 'queued',
|
|
748
|
+
};
|
|
749
|
+
record.lastReceived = Number(sequence);
|
|
750
|
+
record.deliveries.push(delivery);
|
|
751
|
+
while (record.deliveries.length > 256) {
|
|
752
|
+
const index = record.deliveries.findIndex(item => !['queued', 'running'].includes(item.state));
|
|
753
|
+
if (index < 0)
|
|
754
|
+
break;
|
|
755
|
+
record.deliveries.splice(index, 1);
|
|
756
|
+
}
|
|
757
|
+
this.append(record, {
|
|
758
|
+
type: 'received',
|
|
759
|
+
eventSequence: delivery.sequence,
|
|
760
|
+
name,
|
|
761
|
+
payload: input.payload,
|
|
762
|
+
});
|
|
763
|
+
this.drain(record);
|
|
764
|
+
return { accepted: true, eventSequence: delivery.sequence, taskId: delivery.taskId };
|
|
765
|
+
}
|
|
766
|
+
drain(record) {
|
|
767
|
+
if (this.closed || !record.ready || record.unavailable || record.value.state !== 'active')
|
|
768
|
+
return;
|
|
769
|
+
for (const delivery of record.deliveries) {
|
|
770
|
+
if (delivery.state !== 'queued')
|
|
771
|
+
continue;
|
|
772
|
+
const binding = record.runtime.events[delivery.name];
|
|
773
|
+
const running = [...record.running].filter(item => item.name === delivery.name).length;
|
|
774
|
+
if (running >= (binding.policy === 'parallel' ? (binding.concurrency ?? 2) : 1))
|
|
775
|
+
continue;
|
|
776
|
+
delivery.state = 'running';
|
|
777
|
+
record.running.add(delivery);
|
|
778
|
+
void this.execute(record, delivery).finally(() => {
|
|
779
|
+
record.running.delete(delivery);
|
|
780
|
+
this.drain(record);
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
async execute(record, delivery) {
|
|
785
|
+
try {
|
|
786
|
+
await this.validate(record);
|
|
787
|
+
const binding = record.runtime.events[delivery.name];
|
|
788
|
+
const started = this.start(record, binding.command, { event: { name: delivery.name, payload: delivery.payload, sequence: delivery.sequence } }, `page:${record.value.instanceId}:${delivery.sequence}`);
|
|
789
|
+
delivery.taskId = started.taskId;
|
|
790
|
+
this.append(record, {
|
|
791
|
+
type: 'running',
|
|
792
|
+
name: delivery.name,
|
|
793
|
+
eventSequence: delivery.sequence,
|
|
794
|
+
taskId: started.taskId,
|
|
795
|
+
});
|
|
796
|
+
await this.wait(record, started.taskId);
|
|
797
|
+
delivery.state = 'success';
|
|
798
|
+
this.append(record, {
|
|
799
|
+
type: 'success',
|
|
800
|
+
name: delivery.name,
|
|
801
|
+
eventSequence: delivery.sequence,
|
|
802
|
+
taskId: started.taskId,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
catch (error) {
|
|
806
|
+
// Only deliveries that have not started a task can be resumed without replaying side effects.
|
|
807
|
+
if (isPluginPageUnavailable(error) && !delivery.taskId && record.value.state === 'active') {
|
|
808
|
+
delivery.state = 'queued';
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
delivery.state = 'error';
|
|
812
|
+
this.append(record, {
|
|
813
|
+
type: 'error',
|
|
814
|
+
name: delivery.name,
|
|
815
|
+
eventSequence: delivery.sequence,
|
|
816
|
+
error: serializePluginExecutionError(error),
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
start(record, command, input, key, parentIdentity) {
|
|
821
|
+
// The runtime delegates through the instance's current connection. Its identity follows
|
|
822
|
+
// that connection so binding cannot replace the connection with this forwarding proxy.
|
|
823
|
+
const bridge = {
|
|
824
|
+
get id() {
|
|
825
|
+
return record.bridge.id;
|
|
826
|
+
},
|
|
827
|
+
scope: {
|
|
828
|
+
...record.bridge.scope,
|
|
829
|
+
plugin: record.value.plugin,
|
|
830
|
+
access: 'runtime',
|
|
831
|
+
source: 'runtime',
|
|
832
|
+
},
|
|
833
|
+
invoke: (action, payload, options) => record.client
|
|
834
|
+
? record.client.invoke(action, payload, options)
|
|
835
|
+
: record.bridge.invoke(action, payload, options),
|
|
836
|
+
};
|
|
837
|
+
const started = this.tasks.start({
|
|
838
|
+
plugin: record.value.plugin,
|
|
839
|
+
command,
|
|
840
|
+
input,
|
|
841
|
+
cwd: record.execution.cwd,
|
|
842
|
+
environment: record.execution.environment,
|
|
843
|
+
options: record.options,
|
|
844
|
+
idempotencyKey: key,
|
|
845
|
+
}, bridge, {
|
|
846
|
+
...(parentIdentity?.taskId ? { parentTaskId: parentIdentity.taskId, parentIdentity } : {}),
|
|
847
|
+
revision: record.revision,
|
|
848
|
+
guard: () => this.validate(record),
|
|
849
|
+
page: { instanceId: record.value.instanceId, target: { ...record.value.target } },
|
|
850
|
+
});
|
|
851
|
+
record.taskIds.add(started.taskId);
|
|
852
|
+
return started;
|
|
853
|
+
}
|
|
854
|
+
async wait(record, taskId) {
|
|
855
|
+
let cursor = 0;
|
|
856
|
+
while (true) {
|
|
857
|
+
const page = await this.tasks.events(taskId, { access: 'public', source: 'runtime', bridge: 'service' }, cursor, 25000);
|
|
858
|
+
cursor = page.nextCursor;
|
|
859
|
+
if (!isPluginTaskActive(page.task.state)) {
|
|
860
|
+
record.taskIds.delete(taskId);
|
|
861
|
+
if (page.task.state !== 'success')
|
|
862
|
+
throw Object.assign(pluginExecutionError(page.task.error ?? { message: 'Page handler failed' }), { childTaskId: taskId });
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (record.value.state !== 'active')
|
|
866
|
+
this.tasks.cancel(taskId, { access: 'public', source: 'runtime', bridge: 'service' });
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
async close() {
|
|
870
|
+
this.closed = true;
|
|
871
|
+
clearInterval(this.heartbeat);
|
|
872
|
+
for (const item of this.describing)
|
|
873
|
+
item.controller.abort();
|
|
874
|
+
await Promise.allSettled([...this.describing].map(item => item.promise));
|
|
875
|
+
await Promise.allSettled([...this.instances.values()].map(record => this.invalidate(record, new Error('Plugin page host stopped'))));
|
|
876
|
+
}
|
|
877
|
+
}
|