@mearl/preset 0.0.0-stage → 3.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +57 -2
- package/dist/browserPreset.d.ts +22 -0
- package/dist/browserPreset.js +52 -0
- package/dist/browserSandbox.d.ts +47 -0
- package/dist/browserSandbox.js +141 -0
- package/dist/browserTransfer.d.ts +42 -0
- package/dist/browserTransfer.js +102 -0
- package/dist/browserTransport.d.ts +8 -0
- package/dist/browserTransport.js +64 -0
- package/dist/browserViews.d.ts +44 -0
- package/dist/browserViews.js +204 -0
- package/dist/bundle.d.ts +59 -0
- package/dist/bundle.js +219 -0
- package/dist/bundleFetch.d.ts +6 -0
- package/dist/bundleFetch.js +42 -0
- package/dist/bundleStore.d.ts +70 -0
- package/dist/bundleStore.js +478 -0
- package/dist/bundles.d.ts +22 -0
- package/dist/bundles.js +12 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/installation.d.ts +24 -0
- package/dist/installation.js +116 -0
- package/dist/manifest.d.ts +9 -0
- package/dist/manifest.js +156 -0
- package/dist/nodeSnapshot.d.ts +38 -0
- package/dist/nodeSnapshot.js +136 -0
- package/dist/pageRuntime.d.ts +19 -0
- package/dist/pageRuntime.js +47 -0
- package/dist/prepare.d.ts +34 -0
- package/dist/prepare.js +112 -0
- package/dist/preset.d.ts +24 -0
- package/dist/preset.js +53 -0
- package/dist/requestUi.d.ts +59 -0
- package/dist/requestUi.js +102 -0
- package/dist/resolve.d.ts +5 -0
- package/dist/resolve.js +134 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.js +1 -0
- package/package.json +40 -3
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { BundleError, MAX_BROWSER_STORAGE_BYTES, bundleDigest, validateBundleManifest } from './bundle.js';
|
|
2
|
+
import { verifyBrowserPreset, verifyPreparedBrowserPreset } from './browserPreset.js';
|
|
3
|
+
import { mergePresetDefaults } from './resolve.js';
|
|
4
|
+
/** Every encoded response stays well below Native Messaging's one MiB host limit. */
|
|
5
|
+
export const PRESET_TRANSFER_CHUNK_BYTES = 128 * 1024;
|
|
6
|
+
export const MAX_PRESET_TRANSFER_HEADER_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
function object(value) {
|
|
8
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
9
|
+
throw new BundleError('BUNDLE_INVALID', 'Invalid preset transfer response');
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function hash(value) { return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); }
|
|
13
|
+
export async function readPresetTransferResource(sha256, byteLength, options) {
|
|
14
|
+
if (!hash(sha256) || !Number.isSafeInteger(byteLength) || byteLength < 0 || byteLength > MAX_BROWSER_STORAGE_BYTES)
|
|
15
|
+
throw new BundleError('BUNDLE_QUOTA', 'Invalid preset resource digest or size');
|
|
16
|
+
const bytes = new Uint8Array(byteLength);
|
|
17
|
+
for (let offset = 0; offset < byteLength; offset += PRESET_TRANSFER_CHUNK_BYTES) {
|
|
18
|
+
options.signal?.throwIfAborted();
|
|
19
|
+
const count = Math.min(PRESET_TRANSFER_CHUNK_BYTES, byteLength - offset);
|
|
20
|
+
const response = object(await options.read(offset));
|
|
21
|
+
if (typeof response.data !== 'string' || response.data.length !== Math.ceil(count / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(response.data))
|
|
22
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Invalid preset resource chunk');
|
|
23
|
+
const decoded = atob(response.data);
|
|
24
|
+
if (decoded.length !== count)
|
|
25
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Truncated preset resource chunk');
|
|
26
|
+
for (let i = 0; i < count; i++)
|
|
27
|
+
bytes[offset + i] = decoded.charCodeAt(i);
|
|
28
|
+
}
|
|
29
|
+
if (await bundleDigest(bytes) !== sha256)
|
|
30
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Preset transfer resource digest does not match');
|
|
31
|
+
return bytes;
|
|
32
|
+
}
|
|
33
|
+
/** Receives the captured browser projection, excluding Node-only packages and executing no factories. */
|
|
34
|
+
export async function receiveBrowserPreset(installationRevision, options) {
|
|
35
|
+
if (!hash(installationRevision))
|
|
36
|
+
throw new BundleError('BUNDLE_INVALID', 'Expected an installation revision');
|
|
37
|
+
options.signal?.throwIfAborted();
|
|
38
|
+
const opened = object(await options.transport({ operation: 'open', installationRevision }));
|
|
39
|
+
if (typeof opened.session !== 'string' || !/^[a-zA-Z0-9-]{1,80}$/.test(opened.session))
|
|
40
|
+
throw new BundleError('BUNDLE_INVALID', 'Invalid preset transfer session');
|
|
41
|
+
const session = opened.session;
|
|
42
|
+
let released = false;
|
|
43
|
+
const release = async () => { if (!released) {
|
|
44
|
+
released = true;
|
|
45
|
+
await options.transport({ operation: 'release', session });
|
|
46
|
+
} };
|
|
47
|
+
const confirm = async () => {
|
|
48
|
+
options.signal?.throwIfAborted();
|
|
49
|
+
const response = object(await options.transport({ operation: 'confirm', session }));
|
|
50
|
+
if (response.installationRevision !== installationRevision)
|
|
51
|
+
throw new BundleError('BUNDLE_CONFLICT', 'The selected installation changed during browser transfer');
|
|
52
|
+
};
|
|
53
|
+
const read = (sha256, byteLength) => readPresetTransferResource(sha256, byteLength, { signal: options.signal, read: offset => options.transport({ operation: 'read', session, sha256, offset }) });
|
|
54
|
+
try {
|
|
55
|
+
if (!hash(opened.sha256) || !Number.isSafeInteger(opened.byteLength) || opened.byteLength < 1 || opened.byteLength > MAX_PRESET_TRANSFER_HEADER_BYTES)
|
|
56
|
+
throw new BundleError('BUNDLE_QUOTA', 'Invalid preset transfer header or size');
|
|
57
|
+
const metadataBytes = await read(opened.sha256, opened.byteLength);
|
|
58
|
+
const value = object(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(metadataBytes)));
|
|
59
|
+
if (Object.keys(value).some(key => !['schemaVersion', 'installationRevision', 'snapshot', 'bundles', 'overrides'].includes(key)) || value.schemaVersion !== 1 || value.installationRevision !== installationRevision || !Array.isArray(value.bundles) || value.bundles.length > 64)
|
|
60
|
+
throw new BundleError('BUNDLE_INVALID', 'Invalid browser transfer metadata');
|
|
61
|
+
const graph = await verifyBrowserPreset(value.snapshot, options.mearlApiVersion);
|
|
62
|
+
const overrides = mergePresetDefaults({}, value.overrides);
|
|
63
|
+
if (new TextEncoder().encode(JSON.stringify(overrides)).byteLength > 262144)
|
|
64
|
+
throw new BundleError('BUNDLE_QUOTA', 'User overrides exceed 256 KiB');
|
|
65
|
+
let total = 0;
|
|
66
|
+
const descriptors = value.bundles.map(entry => {
|
|
67
|
+
const bundle = object(entry);
|
|
68
|
+
if (Object.keys(bundle).some(key => !['source', 'revision', 'manifest'].includes(key)) || typeof bundle.source !== 'string' || !hash(bundle.revision))
|
|
69
|
+
throw new BundleError('BUNDLE_INVALID', 'Invalid browser package descriptor');
|
|
70
|
+
const manifest = validateBundleManifest(bundle.manifest, options.mearlApiVersion);
|
|
71
|
+
total += Object.values(manifest.resources).reduce((sum, resource) => sum + resource.byteLength, 0);
|
|
72
|
+
if (total > MAX_BROWSER_STORAGE_BYTES)
|
|
73
|
+
throw new BundleError('BUNDLE_QUOTA', 'Browser transfer resources exceed 64 MiB');
|
|
74
|
+
return { source: bundle.source, revision: bundle.revision, manifest };
|
|
75
|
+
});
|
|
76
|
+
if (descriptors.length !== Object.keys(graph.snapshot.bundleRevisions).length || new Set(descriptors.map(bundle => bundle.manifest.id)).size !== descriptors.length || descriptors.some(bundle => graph.snapshot.bundleRevisions[bundle.manifest.id] !== bundle.revision))
|
|
77
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Browser transfer requires every exact package');
|
|
78
|
+
const resources = new Map();
|
|
79
|
+
const bundles = [];
|
|
80
|
+
for (const bundle of descriptors) {
|
|
81
|
+
const entries = {};
|
|
82
|
+
for (const [name, entry] of Object.entries(bundle.manifest.resources)) {
|
|
83
|
+
const bytes = resources.get(entry.sha256) ?? await read(entry.sha256, entry.byteLength);
|
|
84
|
+
if (bytes.byteLength !== entry.byteLength)
|
|
85
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Resource digest has inconsistent lengths');
|
|
86
|
+
resources.set(entry.sha256, bytes);
|
|
87
|
+
entries[name] = bytes;
|
|
88
|
+
}
|
|
89
|
+
bundles.push({ ...bundle, resources: entries });
|
|
90
|
+
}
|
|
91
|
+
const prepared = await verifyPreparedBrowserPreset({ snapshot: graph.snapshot, bundles }, options.mearlApiVersion);
|
|
92
|
+
await confirm();
|
|
93
|
+
return { prepared, installation: { revision: installationRevision, browserRevision: prepared.snapshot.revision, overrides }, confirm, release };
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
try {
|
|
97
|
+
await release();
|
|
98
|
+
}
|
|
99
|
+
catch { /* The transport may have closed with the failed transfer. */ }
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BrowserViewTransport } from './browserViews.js';
|
|
2
|
+
/** WebSocket permissions use their HTTP counterpart; native sockets reject redirects. */
|
|
3
|
+
export declare function browserTransportUrl(input: unknown, kind: BrowserViewTransport['kind']): {
|
|
4
|
+
url: string;
|
|
5
|
+
permissionUrl: string;
|
|
6
|
+
};
|
|
7
|
+
/** Native SSE redirects remain inside the intersecting domain and Chrome host policies. */
|
|
8
|
+
export declare function browserTransportPolicies(domains: string[], chromeOrigins: string[], allowAllDomains: boolean, kind: BrowserViewTransport['kind']): string[];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { BundleError } from './bundle.js';
|
|
2
|
+
/** WebSocket permissions use their HTTP counterpart; native sockets reject redirects. */
|
|
3
|
+
export function browserTransportUrl(input, kind) {
|
|
4
|
+
if (typeof input !== 'string' || input.length > 16384)
|
|
5
|
+
throw new BundleError('BUNDLE_INVALID', 'Expected a browser transport URL');
|
|
6
|
+
const url = new URL(input);
|
|
7
|
+
const protocols = kind === 'websocket' ? ['ws:', 'wss:'] : kind === 'eventSource' ? ['http:', 'https:'] : [];
|
|
8
|
+
if (!protocols.includes(url.protocol) || url.username || url.password || url.hash)
|
|
9
|
+
throw new BundleError('BUNDLE_INVALID', 'Invalid browser transport URL');
|
|
10
|
+
const permission = new URL(url);
|
|
11
|
+
if (permission.protocol === 'ws:')
|
|
12
|
+
permission.protocol = 'http:';
|
|
13
|
+
if (permission.protocol === 'wss:')
|
|
14
|
+
permission.protocol = 'https:';
|
|
15
|
+
return { url: url.href, permissionUrl: permission.href };
|
|
16
|
+
}
|
|
17
|
+
function rules(origins, domainOnly) {
|
|
18
|
+
return origins.flatMap(origin => {
|
|
19
|
+
if (origin === '<all_urls>' && !domainOnly)
|
|
20
|
+
return [{ scheme: '*', host: '*' }];
|
|
21
|
+
const match = /^(\*|https?):\/\/(\*|(?:\*\.)?[a-zA-Z0-9.-]+|\[[a-fA-F0-9:]+\])\/\*$/.exec(origin);
|
|
22
|
+
return !match || domainOnly && (match[1] !== '*' || match[2] === '*') ? [] : [{ scheme: match[1], host: match[2].toLowerCase() }];
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function intersection(a, b) {
|
|
26
|
+
const covers = (outer, inner) => outer === '*' || outer === inner || outer.startsWith('*.') && (inner.replace(/^\*\./, '') === outer.slice(2) || inner.replace(/^\*\./, '').endsWith('.' + outer.slice(2)));
|
|
27
|
+
return covers(a, b) ? b : covers(b, a) ? a : undefined;
|
|
28
|
+
}
|
|
29
|
+
function sources(origins, domainOnly, kind) {
|
|
30
|
+
const values = new Set();
|
|
31
|
+
const add = (scheme, host) => {
|
|
32
|
+
for (const name of host.startsWith('*.') ? [host.slice(2), host] : [host])
|
|
33
|
+
values.add(name === '*' ? `${scheme}:` : `${scheme}://${name}:*`);
|
|
34
|
+
};
|
|
35
|
+
const accepted = rules(origins, domainOnly);
|
|
36
|
+
for (const { scheme, host } of accepted) {
|
|
37
|
+
if (domainOnly || kind === 'websocket') {
|
|
38
|
+
for (const name of scheme === '*' ? ['http', 'https', 'ws', 'wss'] : scheme === 'https' ? ['https', 'wss'] : ['http', 'ws'])
|
|
39
|
+
add(name, host);
|
|
40
|
+
}
|
|
41
|
+
else if (scheme === '*' || scheme === 'https') {
|
|
42
|
+
add('https', host);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!domainOnly && kind === 'eventSource') {
|
|
46
|
+
// CSP http sources also permit https. Only their shared grant scope can be represented.
|
|
47
|
+
const http = accepted.filter(rule => rule.scheme !== 'https');
|
|
48
|
+
const https = accepted.filter(rule => rule.scheme !== 'http');
|
|
49
|
+
for (const a of http)
|
|
50
|
+
for (const b of https) {
|
|
51
|
+
const host = intersection(a.host, b.host);
|
|
52
|
+
if (host)
|
|
53
|
+
add('http', host);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...values].join(' ') || "'none'";
|
|
57
|
+
}
|
|
58
|
+
/** Native SSE redirects remain inside the intersecting domain and Chrome host policies. */
|
|
59
|
+
export function browserTransportPolicies(domains, chromeOrigins, allowAllDomains, kind) {
|
|
60
|
+
return [
|
|
61
|
+
`connect-src ${allowAllDomains ? 'http: https: ws: wss:' : sources(domains, true, kind)}`,
|
|
62
|
+
`connect-src ${sources(chromeOrigins, false, kind)}`,
|
|
63
|
+
];
|
|
64
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type PreparedBundle } from './bundle.js';
|
|
2
|
+
import type { JsonObject, JsonValue } from './types.js';
|
|
3
|
+
import { type BrowserUiText } from './requestUi.js';
|
|
4
|
+
export type BrowserViewSlot = 'requestImport' | 'requestCases' | 'captureLauncher' | 'captureController';
|
|
5
|
+
export interface BrowserViewAction {
|
|
6
|
+
hook: string;
|
|
7
|
+
setting?: string;
|
|
8
|
+
disabledResult?: JsonValue;
|
|
9
|
+
}
|
|
10
|
+
export interface BrowserViewTransport {
|
|
11
|
+
kind: 'websocket' | 'eventSource';
|
|
12
|
+
setting: string;
|
|
13
|
+
}
|
|
14
|
+
export interface BrowserViewDefinition {
|
|
15
|
+
schemaVersion: 1;
|
|
16
|
+
entry: string;
|
|
17
|
+
actions: Record<string, BrowserViewAction>;
|
|
18
|
+
preferences?: Record<string, {
|
|
19
|
+
legacyKey?: string;
|
|
20
|
+
area?: 'local' | 'session';
|
|
21
|
+
}>;
|
|
22
|
+
transports?: Record<string, BrowserViewTransport>;
|
|
23
|
+
logins?: Record<string, {
|
|
24
|
+
url: string;
|
|
25
|
+
title: BrowserUiText;
|
|
26
|
+
size?: 'default' | 'fullscreen';
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
export interface SelectedBrowserView {
|
|
30
|
+
bundleId: string;
|
|
31
|
+
revision: string;
|
|
32
|
+
slot: BrowserViewSlot;
|
|
33
|
+
definition: BrowserViewDefinition;
|
|
34
|
+
html: string;
|
|
35
|
+
}
|
|
36
|
+
export interface BrowserViewBootstrap {
|
|
37
|
+
locale: string;
|
|
38
|
+
context: JsonObject;
|
|
39
|
+
preferences: JsonObject;
|
|
40
|
+
}
|
|
41
|
+
/** Views occupy existing host positions; their code stays in an opaque sandbox. */
|
|
42
|
+
export declare function validateBrowserView(input: unknown): BrowserViewDefinition;
|
|
43
|
+
export declare function selectBrowserView(bundles: PreparedBundle[], slot: BrowserViewSlot, apiVersion: string): SelectedBrowserView | undefined;
|
|
44
|
+
export declare function buildBrowserViewPage(html: string): string;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { BundleError, MAX_REQUEST_UI_BYTES, readBundleJson, readBundleText, validateBundleManifest } from './bundle.js';
|
|
2
|
+
import { clonePresetDefaults, isJsonObject } from './manifest.js';
|
|
3
|
+
import { browserUiHttpUrl, validateBrowserUiText } from './requestUi.js';
|
|
4
|
+
const identifier = /^[a-z][a-zA-Z0-9_.-]{0,79}$/;
|
|
5
|
+
function invalid(message) { throw new BundleError('BUNDLE_INVALID', message); }
|
|
6
|
+
function fields(value, keys) {
|
|
7
|
+
if (!isJsonObject(value) || Object.keys(value).some(key => !keys.includes(key)))
|
|
8
|
+
invalid('Invalid browser view structure');
|
|
9
|
+
}
|
|
10
|
+
function entries(value, limit) {
|
|
11
|
+
if (!isJsonObject(value) || Object.keys(value).length > limit || Object.keys(value).some(key => !identifier.test(key)))
|
|
12
|
+
invalid('Invalid browser view entries');
|
|
13
|
+
return Object.entries(value);
|
|
14
|
+
}
|
|
15
|
+
/** Views occupy existing host positions; their code stays in an opaque sandbox. */
|
|
16
|
+
export function validateBrowserView(input) {
|
|
17
|
+
const value = clonePresetDefaults(input);
|
|
18
|
+
fields(value, ['schemaVersion', 'entry', 'actions', 'preferences', 'logins', 'transports']);
|
|
19
|
+
if (value.schemaVersion !== 1 || typeof value.entry !== 'string' || !/^[a-zA-Z0-9_][a-zA-Z0-9_./-]{0,255}$/.test(value.entry) || value.entry.split('/').some(part => !part || part === '.' || part === '..'))
|
|
20
|
+
invalid('Invalid browser view entry');
|
|
21
|
+
for (const [, action] of entries(value.actions, 32)) {
|
|
22
|
+
fields(action, ['hook', 'setting', 'disabledResult']);
|
|
23
|
+
if (typeof action.hook !== 'string' || !/^[a-z][a-zA-Z0-9_.-]{0,127}$/.test(action.hook))
|
|
24
|
+
invalid('Invalid browser view hook');
|
|
25
|
+
if (action.setting !== undefined && (typeof action.setting !== 'string' || !/^[a-z][a-zA-Z0-9_-]{0,72}Enabled$/.test(action.setting) || ['agentEnabled', 'sseMonitoringEnabled', 'agentFloatingButtonEnabled'].includes(action.setting)))
|
|
26
|
+
invalid('Invalid browser view setting');
|
|
27
|
+
if ((action.setting === undefined) !== (action.disabledResult === undefined))
|
|
28
|
+
invalid('A guarded view action requires its disabled result');
|
|
29
|
+
}
|
|
30
|
+
if (value.preferences !== undefined) {
|
|
31
|
+
const aliases = new Set();
|
|
32
|
+
for (const [, preference] of entries(value.preferences, 32)) {
|
|
33
|
+
fields(preference, ['legacyKey', 'area']);
|
|
34
|
+
if (preference.area !== undefined && preference.area !== 'local' && preference.area !== 'session')
|
|
35
|
+
invalid('Invalid browser view preference area');
|
|
36
|
+
if (preference.legacyKey !== undefined) {
|
|
37
|
+
if (typeof preference.legacyKey !== 'string' || !/^[a-z][a-zA-Z0-9:_.-]{0,127}$/.test(preference.legacyKey) || aliases.has(preference.legacyKey))
|
|
38
|
+
invalid('Invalid browser view preference alias');
|
|
39
|
+
aliases.add(preference.legacyKey);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (value.transports !== undefined) {
|
|
44
|
+
for (const [, transport] of entries(value.transports, 16)) {
|
|
45
|
+
fields(transport, ['kind', 'setting']);
|
|
46
|
+
if (transport.kind !== 'websocket' && transport.kind !== 'eventSource')
|
|
47
|
+
invalid('Invalid browser view transport');
|
|
48
|
+
if (typeof transport.setting !== 'string' || !/^[a-z][a-zA-Z0-9_-]{0,72}Enabled$/.test(transport.setting) || ['agentEnabled', 'sseMonitoringEnabled', 'agentFloatingButtonEnabled'].includes(transport.setting))
|
|
49
|
+
invalid('Invalid browser view transport setting');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (value.logins !== undefined) {
|
|
53
|
+
for (const [, login] of entries(value.logins, 16)) {
|
|
54
|
+
fields(login, ['url', 'title', 'size']);
|
|
55
|
+
login.url = browserUiHttpUrl(login.url);
|
|
56
|
+
validateBrowserUiText(login.title);
|
|
57
|
+
if (login.size !== undefined && !['default', 'fullscreen'].includes(login.size))
|
|
58
|
+
invalid('Invalid browser view login size');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (new TextEncoder().encode(JSON.stringify(value)).byteLength > MAX_REQUEST_UI_BYTES)
|
|
62
|
+
throw new BundleError('BUNDLE_QUOTA', 'Browser view definition exceeds 64 KiB');
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
export function selectBrowserView(bundles, slot, apiVersion) {
|
|
66
|
+
if (slot !== 'requestImport' && slot !== 'requestCases' && slot !== 'captureLauncher' && slot !== 'captureController')
|
|
67
|
+
invalid('Unsupported browser view position');
|
|
68
|
+
let selected;
|
|
69
|
+
for (const bundle of bundles) {
|
|
70
|
+
const manifest = validateBundleManifest(bundle.manifest, apiVersion);
|
|
71
|
+
const resource = manifest.contributions?.browserViews?.[slot];
|
|
72
|
+
if (!resource)
|
|
73
|
+
continue;
|
|
74
|
+
if (selected)
|
|
75
|
+
invalid('Browser view position has multiple providers');
|
|
76
|
+
const definition = validateBrowserView(readBundleJson(bundle, resource));
|
|
77
|
+
const bindings = manifest.contributions?.browserRuntimes?.extension?.bindings ?? {};
|
|
78
|
+
if (Object.values(definition.actions).some(action => !Object.hasOwn(bindings, action.hook)))
|
|
79
|
+
invalid('View actions must belong to their own browser runtime');
|
|
80
|
+
if (!Object.hasOwn(manifest.resources, definition.entry))
|
|
81
|
+
invalid('Browser view entry must be a declared resource');
|
|
82
|
+
const html = readBundleText(bundle, definition.entry);
|
|
83
|
+
if (!html.trim())
|
|
84
|
+
invalid('Browser view entry must not be empty');
|
|
85
|
+
selected = { bundleId: manifest.id, revision: bundle.revision, slot, definition, html };
|
|
86
|
+
}
|
|
87
|
+
return selected;
|
|
88
|
+
}
|
|
89
|
+
/** Serialized before the view bundle; only its parent receives this private port. */
|
|
90
|
+
function installBrowserViewBridge() {
|
|
91
|
+
let connected = false;
|
|
92
|
+
let closed = false;
|
|
93
|
+
let serial = 0;
|
|
94
|
+
let port;
|
|
95
|
+
let resolveReady;
|
|
96
|
+
let rejectReady;
|
|
97
|
+
const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
|
|
98
|
+
void ready.catch(() => { });
|
|
99
|
+
const pending = new Map();
|
|
100
|
+
const listeners = new Set();
|
|
101
|
+
const eventListeners = new Set();
|
|
102
|
+
const handlers = new Map();
|
|
103
|
+
const call = async (method, input = {}) => {
|
|
104
|
+
await ready;
|
|
105
|
+
if (!connected)
|
|
106
|
+
throw new Error('Browser view is closed');
|
|
107
|
+
const value = JSON.parse(JSON.stringify(input));
|
|
108
|
+
const id = ++serial;
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
pending.set(id, { resolve, reject });
|
|
111
|
+
try {
|
|
112
|
+
port.postMessage({ type: 'view-call', id, method, input: value });
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
pending.delete(id);
|
|
116
|
+
reject(error);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
Object.defineProperty(window, 'mearlView', { value: Object.freeze({
|
|
121
|
+
ready: () => ready,
|
|
122
|
+
call,
|
|
123
|
+
handle: (method, handler) => {
|
|
124
|
+
if (typeof method !== 'string' || !/^[a-z][a-zA-Z0-9_.-]{0,79}$/.test(method) || typeof handler !== 'function' || handlers.has(method))
|
|
125
|
+
throw new Error('Invalid or duplicate browser view command');
|
|
126
|
+
handlers.set(method, handler);
|
|
127
|
+
return () => { if (handlers.get(method) === handler)
|
|
128
|
+
handlers.delete(method); };
|
|
129
|
+
},
|
|
130
|
+
onEvent: (listener) => { eventListeners.add(listener); return () => eventListeners.delete(listener); },
|
|
131
|
+
onContext: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
|
|
132
|
+
}) });
|
|
133
|
+
window.addEventListener('message', event => {
|
|
134
|
+
if (event.source !== window.parent || event.data?.type !== 'mearl-plugin-connect' || !event.ports[0] || connected || closed)
|
|
135
|
+
return;
|
|
136
|
+
connected = true;
|
|
137
|
+
port = event.ports[0];
|
|
138
|
+
port.onmessage = event => {
|
|
139
|
+
const value = event.data;
|
|
140
|
+
if (value?.type === 'view-command' && Number.isSafeInteger(value.id) && value.id > 0) {
|
|
141
|
+
const handler = handlers.get(value.method);
|
|
142
|
+
void Promise.resolve().then(() => {
|
|
143
|
+
if (!handler)
|
|
144
|
+
throw new Error('Unknown browser view command');
|
|
145
|
+
return handler(value.input);
|
|
146
|
+
}).then(result => {
|
|
147
|
+
if (connected)
|
|
148
|
+
port.postMessage({ type: 'view-command-result', id: value.id, success: true, value: result });
|
|
149
|
+
}, error => {
|
|
150
|
+
if (connected)
|
|
151
|
+
port.postMessage({ type: 'view-command-result', id: value.id, success: false, error: error instanceof Error ? error.message : String(error) });
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (value?.type === 'view-init') {
|
|
156
|
+
resolveReady(value.bootstrap);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (value?.type === 'view-event') {
|
|
160
|
+
for (const listener of eventListeners)
|
|
161
|
+
listener(value.event);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (value?.type === 'view-context') {
|
|
165
|
+
for (const listener of listeners)
|
|
166
|
+
listener(value.context);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (value?.type === 'view-key' && value.key === 'Escape') {
|
|
170
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (value?.type === 'view-close') {
|
|
174
|
+
connected = false;
|
|
175
|
+
closed = true;
|
|
176
|
+
rejectReady(new Error('Browser view is closed'));
|
|
177
|
+
for (const request of pending.values())
|
|
178
|
+
request.reject(new Error('Browser view is closed'));
|
|
179
|
+
pending.clear();
|
|
180
|
+
port.close();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (value?.type !== 'view-result' || !Number.isSafeInteger(value.id))
|
|
184
|
+
return;
|
|
185
|
+
const request = pending.get(value.id);
|
|
186
|
+
if (!request)
|
|
187
|
+
return;
|
|
188
|
+
pending.delete(value.id);
|
|
189
|
+
if (value.success === true)
|
|
190
|
+
request.resolve(value.value);
|
|
191
|
+
else
|
|
192
|
+
request.reject(new Error(typeof value.error === 'string' ? value.error : 'Browser view request failed'));
|
|
193
|
+
};
|
|
194
|
+
port.start();
|
|
195
|
+
port.postMessage({ type: 'view-ready' });
|
|
196
|
+
});
|
|
197
|
+
window.parent.postMessage({ type: 'mearl-plugin-ready' }, '*');
|
|
198
|
+
}
|
|
199
|
+
export function buildBrowserViewPage(html) {
|
|
200
|
+
if (typeof html !== 'string' || !html.trim())
|
|
201
|
+
invalid('Browser view entry must not be empty');
|
|
202
|
+
const policy = "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
203
|
+
return `<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${policy}"><script>(${installBrowserViewBridge.toString()})();</script>${html}`;
|
|
204
|
+
}
|
package/dist/bundle.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export declare const MAX_BUNDLE_BYTES: number;
|
|
2
|
+
export declare const MAX_BUNDLE_MANIFEST_BYTES: number;
|
|
3
|
+
export declare const MAX_BROWSER_STORAGE_BYTES: number;
|
|
4
|
+
export declare const MAX_PAGE_RUNTIME_BYTES: number;
|
|
5
|
+
export declare const MAX_REQUEST_UI_BYTES: number;
|
|
6
|
+
export declare class BundleError extends Error {
|
|
7
|
+
readonly code: 'BUNDLE_INVALID' | 'BUNDLE_INTEGRITY' | 'BUNDLE_INCOMPATIBLE' | 'BUNDLE_CONFLICT' | 'BUNDLE_QUOTA';
|
|
8
|
+
constructor(code: 'BUNDLE_INVALID' | 'BUNDLE_INTEGRITY' | 'BUNDLE_INCOMPATIBLE' | 'BUNDLE_CONFLICT' | 'BUNDLE_QUOTA', message: string);
|
|
9
|
+
}
|
|
10
|
+
export interface BundleManifest {
|
|
11
|
+
schemaVersion: 1;
|
|
12
|
+
id: string;
|
|
13
|
+
version: string;
|
|
14
|
+
requires?: {
|
|
15
|
+
mearlApi: string;
|
|
16
|
+
};
|
|
17
|
+
resources: Record<string, {
|
|
18
|
+
sha256: string;
|
|
19
|
+
byteLength: number;
|
|
20
|
+
}>;
|
|
21
|
+
contributions?: {
|
|
22
|
+
appProfiles: string[];
|
|
23
|
+
pageRuntimes?: Record<string, string>;
|
|
24
|
+
nodeRuntimes?: Record<string, CapabilityRuntimeDeclaration>;
|
|
25
|
+
browserRuntimes?: Record<string, CapabilityRuntimeDeclaration>;
|
|
26
|
+
requestUi?: string;
|
|
27
|
+
browserViews?: Record<string, string>;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export interface CapabilityRuntimeDeclaration {
|
|
31
|
+
entry: string;
|
|
32
|
+
bindings: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
export interface PreparedBundle {
|
|
35
|
+
source: string;
|
|
36
|
+
revision: string;
|
|
37
|
+
manifest: BundleManifest;
|
|
38
|
+
resources: Record<string, Uint8Array>;
|
|
39
|
+
}
|
|
40
|
+
export interface BundleLoadOptions {
|
|
41
|
+
maxBytes: number;
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
}
|
|
44
|
+
export type BundleLoader = (source: string, options: BundleLoadOptions) => Promise<Uint8Array>;
|
|
45
|
+
export interface PrepareBundleOptions {
|
|
46
|
+
load: BundleLoader;
|
|
47
|
+
mearlApiVersion: string;
|
|
48
|
+
expectedId: string;
|
|
49
|
+
version?: string;
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
}
|
|
52
|
+
export declare function bundleSourceUrl(source: string): string;
|
|
53
|
+
export declare function validateBundleManifest(value: unknown, mearlApiVersion: string): BundleManifest;
|
|
54
|
+
export declare function bundleDigest(bytes: Uint8Array): Promise<string>;
|
|
55
|
+
export declare function readBundleJson(bundle: PreparedBundle, name: string): unknown;
|
|
56
|
+
export declare function readBundleText(bundle: PreparedBundle, name: string): string;
|
|
57
|
+
export declare function prepareBundle(source: string, options: PrepareBundleOptions): Promise<PreparedBundle>;
|
|
58
|
+
/** Revalidates persisted snapshots without network access and returns independent resource buffers. */
|
|
59
|
+
export declare function verifyBundle(bundle: PreparedBundle, mearlApiVersion: string): Promise<PreparedBundle>;
|