@mearl/preset 0.0.0-stage → 3.0.0-beta.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/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 +145 -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 +35 -3
package/dist/prepare.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { BundleError, MAX_BROWSER_STORAGE_BYTES, bundleDigest, bundleSourceUrl, prepareBundle } from './bundle.js';
|
|
2
|
+
import { clonePresetDefaults, validatePreset } from './manifest.js';
|
|
3
|
+
import { resolvePreset } from './resolve.js';
|
|
4
|
+
export async function presetSnapshotRevision(value) {
|
|
5
|
+
return bundleDigest(new TextEncoder().encode(JSON.stringify(value)));
|
|
6
|
+
}
|
|
7
|
+
/** Each execution location must be able to satisfy its own package dependencies. */
|
|
8
|
+
export function presetPackages(preset, runtimes) {
|
|
9
|
+
if (runtimes.includes('node') && runtimes.includes('browser') && Object.values(preset.packages).some(entry => entry.enabled !== false && !entry.runtimes?.length)) {
|
|
10
|
+
throw new BundleError('BUNDLE_INCOMPATIBLE', 'Every enabled package must declare its browser and/or Node runtime');
|
|
11
|
+
}
|
|
12
|
+
for (const runtime of runtimes) {
|
|
13
|
+
const entries = Object.entries(preset.packages).filter(([, entry]) => entry.enabled !== false && entry.runtimes?.includes(runtime));
|
|
14
|
+
const ids = new Set(entries.map(([id]) => id));
|
|
15
|
+
for (const [id, entry] of entries) {
|
|
16
|
+
if (entry.dependsOn?.some(dependency => !ids.has(dependency))) {
|
|
17
|
+
throw new BundleError('BUNDLE_INCOMPATIBLE', `${runtime === 'browser' ? 'Browser' : 'Node'} package ${id} requires a package unavailable in ${runtime}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (runtime === 'browser') {
|
|
21
|
+
for (const target of Object.values(preset.appProfiles)) {
|
|
22
|
+
if (!/^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test(target) || !ids.has(target.split('/')[0])) {
|
|
23
|
+
throw new BundleError('BUNDLE_INVALID', 'App implementations must belong to an enabled browser package');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const entries = Object.entries(preset.packages).filter(([, entry]) => entry.enabled !== false && entry.runtimes?.some(runtime => runtimes.includes(runtime)));
|
|
29
|
+
if (entries.length > 64)
|
|
30
|
+
throw new BundleError('BUNDLE_QUOTA', 'A preset can activate at most 64 packages');
|
|
31
|
+
return entries;
|
|
32
|
+
}
|
|
33
|
+
/** Shared preparation performs no installation, code execution or permission changes. */
|
|
34
|
+
export async function preparePresetPackages(source, options, runtimes) {
|
|
35
|
+
source = bundleSourceUrl(source);
|
|
36
|
+
let remaining = options.maxDownloadBytes ?? MAX_BROWSER_STORAGE_BYTES;
|
|
37
|
+
if (!Number.isSafeInteger(remaining) || remaining <= 0 || remaining > MAX_BROWSER_STORAGE_BYTES)
|
|
38
|
+
throw new BundleError('BUNDLE_QUOTA', 'Invalid preset download budget');
|
|
39
|
+
const load = async (url, request) => {
|
|
40
|
+
if (remaining <= 0)
|
|
41
|
+
throw new BundleError('BUNDLE_QUOTA', 'Preset downloads exceed the shared budget');
|
|
42
|
+
const limit = Math.min(request.maxBytes, remaining);
|
|
43
|
+
const bytes = await options.load(url, { ...request, maxBytes: limit });
|
|
44
|
+
if (bytes.byteLength > limit)
|
|
45
|
+
throw new BundleError('BUNDLE_QUOTA', 'Preset download exceeds its size limit');
|
|
46
|
+
remaining -= bytes.byteLength;
|
|
47
|
+
return bytes;
|
|
48
|
+
};
|
|
49
|
+
const manifests = {};
|
|
50
|
+
const preset = await resolvePreset(source, {
|
|
51
|
+
mearlApiVersion: options.mearlApiVersion,
|
|
52
|
+
signal: options.signal,
|
|
53
|
+
load: async (url) => {
|
|
54
|
+
const bytes = Uint8Array.from(await load(url, { maxBytes: 262_144, signal: options.signal }));
|
|
55
|
+
let manifest;
|
|
56
|
+
try {
|
|
57
|
+
manifest = validatePreset(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)));
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
throw new BundleError('BUNDLE_INVALID', error instanceof Error ? error.message : 'Invalid preset manifest');
|
|
61
|
+
}
|
|
62
|
+
manifests[url] = manifest;
|
|
63
|
+
return manifest;
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const entries = presetPackages(preset, runtimes);
|
|
67
|
+
const bundles = [];
|
|
68
|
+
for (const [id, entry] of entries) {
|
|
69
|
+
bundles.push(await prepareBundle(entry.source, { ...options, load, expectedId: id, version: entry.version }));
|
|
70
|
+
}
|
|
71
|
+
options.signal?.throwIfAborted();
|
|
72
|
+
return { source, manifests, preset, bundles };
|
|
73
|
+
}
|
|
74
|
+
/** The exact graph is checked offline; installed resources are verified by the target store. */
|
|
75
|
+
export async function verifyPresetGraph(value, mearlApiVersion, runtimes, full) {
|
|
76
|
+
const json = clonePresetDefaults(value);
|
|
77
|
+
const allowed = ['source', 'manifests', 'bundleRevisions', 'revision', ...(full ? ['schemaVersion'] : [])];
|
|
78
|
+
if (Object.keys(json).some(key => !allowed.includes(key)) || (full && json.schemaVersion !== 1) ||
|
|
79
|
+
typeof json.source !== 'string' || typeof json.revision !== 'string' || !/^[a-f0-9]{64}$/.test(json.revision)) {
|
|
80
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Invalid stored preset identity');
|
|
81
|
+
}
|
|
82
|
+
const source = bundleSourceUrl(json.source);
|
|
83
|
+
const manifests = clonePresetDefaults(json.manifests);
|
|
84
|
+
if (Object.keys(manifests).length > 32)
|
|
85
|
+
throw new BundleError('BUNDLE_QUOTA', 'Stored preset inheritance exceeds 32 manifests');
|
|
86
|
+
const bundleRevisions = clonePresetDefaults(json.bundleRevisions);
|
|
87
|
+
if (Object.keys(bundleRevisions).length > 64 || Object.values(bundleRevisions).some(revision => typeof revision !== 'string' || !/^[a-f0-9]{64}$/.test(revision))) {
|
|
88
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Invalid stored package revisions');
|
|
89
|
+
}
|
|
90
|
+
const validated = {};
|
|
91
|
+
for (const [url, manifest] of Object.entries(manifests)) {
|
|
92
|
+
if (url !== bundleSourceUrl(url))
|
|
93
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Invalid stored preset source');
|
|
94
|
+
validated[url] = validatePreset(manifest);
|
|
95
|
+
}
|
|
96
|
+
const data = { ...(full ? { schemaVersion: 1 } : {}), source, manifests: validated, bundleRevisions };
|
|
97
|
+
if (await presetSnapshotRevision(data) !== json.revision)
|
|
98
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Stored preset revision does not match');
|
|
99
|
+
const preset = await resolvePreset(source, {
|
|
100
|
+
mearlApiVersion,
|
|
101
|
+
load: async (url) => {
|
|
102
|
+
if (!Object.hasOwn(validated, url))
|
|
103
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Stored parent preset is missing');
|
|
104
|
+
return validated[url];
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const entries = presetPackages(preset, runtimes);
|
|
108
|
+
if (preset.chain.length !== Object.keys(validated).length || entries.length !== Object.keys(bundleRevisions).length || entries.some(([id]) => !Object.hasOwn(bundleRevisions, id))) {
|
|
109
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Stored preset package graph does not match');
|
|
110
|
+
}
|
|
111
|
+
return { snapshot: { ...data, revision: json.revision }, preset };
|
|
112
|
+
}
|
package/dist/preset.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type PreparedBrowserPreset } from './browserPreset.js';
|
|
2
|
+
import { type PreparePresetOptions, type PresetGraphSnapshot } from './prepare.js';
|
|
3
|
+
import type { PreparedBundle } from './bundle.js';
|
|
4
|
+
import type { ResolvedPreset } from './types.js';
|
|
5
|
+
/** Whole-preset identity includes both execution locations and the captured inheritance graph. */
|
|
6
|
+
export interface PresetSnapshot extends PresetGraphSnapshot {
|
|
7
|
+
schemaVersion: 1;
|
|
8
|
+
}
|
|
9
|
+
export interface PreparedPreset {
|
|
10
|
+
snapshot: PresetSnapshot;
|
|
11
|
+
preset: ResolvedPreset;
|
|
12
|
+
/** Each package is downloaded once, including packages used by both runtimes. */
|
|
13
|
+
bundles: PreparedBundle[];
|
|
14
|
+
/** Compatible with the browser's existing atomic activation and offline restore contract. */
|
|
15
|
+
browser: PreparedBrowserPreset;
|
|
16
|
+
}
|
|
17
|
+
export declare function preparePreset(source: string, options: PreparePresetOptions): Promise<PreparedPreset>;
|
|
18
|
+
/** Verifies the lock graph without fetching sources or executing capability factories. */
|
|
19
|
+
export declare function verifyPreset(value: PresetSnapshot, mearlApiVersion: string): Promise<{
|
|
20
|
+
snapshot: PresetSnapshot;
|
|
21
|
+
preset: ResolvedPreset;
|
|
22
|
+
}>;
|
|
23
|
+
/** Captures and validates the entire accepted batch before an installation host persists it. */
|
|
24
|
+
export declare function verifyPreparedPreset(value: Pick<PreparedPreset, 'snapshot' | 'bundles'>, mearlApiVersion: string): Promise<PreparedPreset>;
|
package/dist/preset.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { browserPresetSnapshot } from './browserPreset.js';
|
|
2
|
+
import { preparePresetPackages, presetSnapshotRevision, verifyPresetGraph } from './prepare.js';
|
|
3
|
+
import { satisfies } from 'semver';
|
|
4
|
+
import { BundleError, MAX_BROWSER_STORAGE_BYTES, verifyBundle } from './bundle.js';
|
|
5
|
+
export async function preparePreset(source, options) {
|
|
6
|
+
const prepared = await preparePresetPackages(source, options, ['browser', 'node']);
|
|
7
|
+
const data = {
|
|
8
|
+
schemaVersion: 1,
|
|
9
|
+
source: prepared.source,
|
|
10
|
+
manifests: prepared.manifests,
|
|
11
|
+
bundleRevisions: Object.fromEntries(prepared.bundles.map(bundle => [bundle.manifest.id, bundle.revision])),
|
|
12
|
+
};
|
|
13
|
+
const snapshot = { ...data, revision: await presetSnapshotRevision(data) };
|
|
14
|
+
const browser = await browserPresetSnapshot(prepared.source, prepared.manifests, prepared.preset, prepared.bundles);
|
|
15
|
+
options.signal?.throwIfAborted();
|
|
16
|
+
return { snapshot, preset: prepared.preset, bundles: prepared.bundles, browser };
|
|
17
|
+
}
|
|
18
|
+
/** Verifies the lock graph without fetching sources or executing capability factories. */
|
|
19
|
+
export async function verifyPreset(value, mearlApiVersion) {
|
|
20
|
+
const result = await verifyPresetGraph(value, mearlApiVersion, ['browser', 'node'], true);
|
|
21
|
+
return { ...result, snapshot: result.snapshot };
|
|
22
|
+
}
|
|
23
|
+
/** Captures and validates the entire accepted batch before an installation host persists it. */
|
|
24
|
+
export async function verifyPreparedPreset(value, mearlApiVersion) {
|
|
25
|
+
if (!Array.isArray(value.bundles) || value.bundles.length > 64)
|
|
26
|
+
throw new BundleError('BUNDLE_QUOTA', 'Preset package count exceeds 64');
|
|
27
|
+
let total = 0;
|
|
28
|
+
for (const bundle of value.bundles) {
|
|
29
|
+
if (!bundle?.resources || typeof bundle.resources !== 'object' || Array.isArray(bundle.resources))
|
|
30
|
+
throw new BundleError('BUNDLE_INVALID', 'Expected package resource buffers');
|
|
31
|
+
const resources = Object.values(bundle.resources);
|
|
32
|
+
if (resources.length > 64)
|
|
33
|
+
throw new BundleError('BUNDLE_QUOTA', 'Preset package resource count exceeds 64');
|
|
34
|
+
for (const bytes of resources) {
|
|
35
|
+
if (!(bytes instanceof Uint8Array))
|
|
36
|
+
throw new BundleError('BUNDLE_INVALID', 'Expected package resource buffers');
|
|
37
|
+
total += bytes.byteLength;
|
|
38
|
+
if (total > MAX_BROWSER_STORAGE_BYTES)
|
|
39
|
+
throw new BundleError('BUNDLE_QUOTA', 'Preset packages exceed the storage budget');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const [graph, bundles] = await Promise.all([
|
|
43
|
+
verifyPreset(value.snapshot, mearlApiVersion),
|
|
44
|
+
Promise.all(value.bundles.map(bundle => verifyBundle(bundle, mearlApiVersion))),
|
|
45
|
+
]);
|
|
46
|
+
const { snapshot, preset } = graph;
|
|
47
|
+
if (bundles.length !== Object.keys(snapshot.bundleRevisions).length || new Set(bundles.map(bundle => bundle.manifest.id)).size !== bundles.length || bundles.some(bundle => {
|
|
48
|
+
const entry = preset.packages[bundle.manifest.id];
|
|
49
|
+
return snapshot.bundleRevisions[bundle.manifest.id] !== bundle.revision || entry?.source !== bundle.source || (entry.version && !satisfies(bundle.manifest.version, entry.version));
|
|
50
|
+
}))
|
|
51
|
+
throw new BundleError('BUNDLE_INTEGRITY', 'Preset installation requires every exact package');
|
|
52
|
+
return { snapshot, preset, bundles, browser: await browserPresetSnapshot(snapshot.source, snapshot.manifests, preset, bundles) };
|
|
53
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type PreparedBundle } from './bundle.js';
|
|
2
|
+
export interface BrowserUiText {
|
|
3
|
+
en: string;
|
|
4
|
+
zh_CN?: string;
|
|
5
|
+
}
|
|
6
|
+
/** Existing request detail, login and service-toggle positions use host-rendered controls. */
|
|
7
|
+
export interface BrowserRequestUi {
|
|
8
|
+
schemaVersion: 1;
|
|
9
|
+
service: string;
|
|
10
|
+
setting: {
|
|
11
|
+
key: string;
|
|
12
|
+
label: BrowserUiText;
|
|
13
|
+
};
|
|
14
|
+
apiLink: {
|
|
15
|
+
url: string;
|
|
16
|
+
query: Record<string, string>;
|
|
17
|
+
apiParameter: string;
|
|
18
|
+
versionParameter: string;
|
|
19
|
+
title: BrowserUiText;
|
|
20
|
+
};
|
|
21
|
+
login: {
|
|
22
|
+
url: string;
|
|
23
|
+
title: BrowserUiText;
|
|
24
|
+
requiredMessage: BrowserUiText;
|
|
25
|
+
errorIncludes: string[];
|
|
26
|
+
};
|
|
27
|
+
save?: {
|
|
28
|
+
hook: string;
|
|
29
|
+
setting: string;
|
|
30
|
+
};
|
|
31
|
+
details: {
|
|
32
|
+
hook: string;
|
|
33
|
+
toggleLabel: BrowserUiText;
|
|
34
|
+
interfaceLabel: BrowserUiText;
|
|
35
|
+
maintainerLabel: BrowserUiText;
|
|
36
|
+
viewTitle: BrowserUiText;
|
|
37
|
+
notFound: BrowserUiText;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export interface BrowserRequestDetail {
|
|
41
|
+
displayValue: string;
|
|
42
|
+
jumpUrl: string;
|
|
43
|
+
maintainerDisplayValue?: string;
|
|
44
|
+
maintainerTitle?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface SelectedBrowserRequestUi {
|
|
47
|
+
bundleId: string;
|
|
48
|
+
revision: string;
|
|
49
|
+
definition: BrowserRequestUi;
|
|
50
|
+
}
|
|
51
|
+
export declare function validateBrowserUiText(value: unknown): void;
|
|
52
|
+
export declare function browserUiHttpUrl(value: unknown): string;
|
|
53
|
+
export declare function validateBrowserRequestUi(input: unknown): BrowserRequestUi;
|
|
54
|
+
/** Contribution validation is pure; factories and UI side effects belong to the host. */
|
|
55
|
+
export declare function selectBrowserRequestUi(bundles: PreparedBundle[], apiVersion: string): SelectedBrowserRequestUi | undefined;
|
|
56
|
+
export declare function browserUiText(value: BrowserUiText, locale: string): string;
|
|
57
|
+
export declare function browserRequestUiLink(value: BrowserRequestUi, api: string, version: string): string;
|
|
58
|
+
export declare function browserRequestLoginError(value: BrowserRequestUi, message: string): boolean;
|
|
59
|
+
export declare function validateBrowserRequestDetail(input: unknown): BrowserRequestDetail;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { BundleError, MAX_REQUEST_UI_BYTES, readBundleJson, validateBundleManifest } from './bundle.js';
|
|
2
|
+
import { clonePresetDefaults, isJsonObject } from './manifest.js';
|
|
3
|
+
function invalid(message) { throw new BundleError('BUNDLE_INVALID', message); }
|
|
4
|
+
function fields(value, names) {
|
|
5
|
+
if (!isJsonObject(value) || Object.keys(value).some(key => !names.includes(key)))
|
|
6
|
+
invalid('Invalid request UI structure');
|
|
7
|
+
}
|
|
8
|
+
export function validateBrowserUiText(value) {
|
|
9
|
+
fields(value, ['en', 'zh_CN']);
|
|
10
|
+
if (typeof value.en !== 'string' || !value.en.trim() || Object.values(value).some(entry => typeof entry !== 'string' || entry.length > 1024 || !entry.trim()))
|
|
11
|
+
invalid('Request UI text requires bounded translations');
|
|
12
|
+
}
|
|
13
|
+
export function browserUiHttpUrl(value) {
|
|
14
|
+
if (typeof value !== 'string' || value.length > 4096)
|
|
15
|
+
invalid('Invalid request UI URL');
|
|
16
|
+
let url;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(value);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
invalid('Invalid request UI URL');
|
|
22
|
+
}
|
|
23
|
+
if (url.username || url.password || url.hash || url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)))
|
|
24
|
+
invalid('Request UI requires credential-free HTTPS or loopback URLs');
|
|
25
|
+
return url.href;
|
|
26
|
+
}
|
|
27
|
+
export function validateBrowserRequestUi(input) {
|
|
28
|
+
const value = clonePresetDefaults(input);
|
|
29
|
+
fields(value, ['schemaVersion', 'service', 'setting', 'apiLink', 'login', 'details', 'save']);
|
|
30
|
+
if (value.schemaVersion !== 1 || typeof value.service !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/.test(value.service))
|
|
31
|
+
invalid('Invalid request UI identity');
|
|
32
|
+
fields(value.setting, ['key', 'label']);
|
|
33
|
+
if (typeof value.setting.key !== 'string' || !/^[a-z][a-zA-Z0-9_-]{0,72}Enabled$/.test(value.setting.key) || ['agentEnabled', 'sseMonitoringEnabled', 'agentFloatingButtonEnabled'].includes(value.setting.key))
|
|
34
|
+
invalid('Invalid request UI setting');
|
|
35
|
+
validateBrowserUiText(value.setting.label);
|
|
36
|
+
fields(value.apiLink, ['url', 'query', 'apiParameter', 'versionParameter', 'title']);
|
|
37
|
+
value.apiLink.url = browserUiHttpUrl(value.apiLink.url);
|
|
38
|
+
if (new URL(value.apiLink.url).search || !isJsonObject(value.apiLink.query) || Object.keys(value.apiLink.query).length > 16 || Object.entries(value.apiLink.query).some(([key, entry]) => !key || key.length > 128 || typeof entry !== 'string' || entry.length > 2048))
|
|
39
|
+
invalid('Invalid request UI link parameters');
|
|
40
|
+
const parameters = [value.apiLink.apiParameter, value.apiLink.versionParameter];
|
|
41
|
+
const query = value.apiLink.query;
|
|
42
|
+
if (parameters.some(key => typeof key !== 'string' || !key || key.length > 128 || Object.hasOwn(query, key)) || parameters[0] === parameters[1])
|
|
43
|
+
invalid('Request UI link parameters must be distinct');
|
|
44
|
+
validateBrowserUiText(value.apiLink.title);
|
|
45
|
+
fields(value.login, ['url', 'title', 'requiredMessage', 'errorIncludes']);
|
|
46
|
+
value.login.url = browserUiHttpUrl(value.login.url);
|
|
47
|
+
validateBrowserUiText(value.login.title);
|
|
48
|
+
validateBrowserUiText(value.login.requiredMessage);
|
|
49
|
+
if (!Array.isArray(value.login.errorIncludes) || value.login.errorIncludes.length > 16 || value.login.errorIncludes.some(entry => typeof entry !== 'string' || !entry.trim() || entry.length > 256))
|
|
50
|
+
invalid('Invalid request UI login errors');
|
|
51
|
+
fields(value.details, ['hook', 'toggleLabel', 'interfaceLabel', 'maintainerLabel', 'viewTitle', 'notFound']);
|
|
52
|
+
if (typeof value.details.hook !== 'string' || !/^[a-z][a-zA-Z0-9_.-]{0,127}$/.test(value.details.hook))
|
|
53
|
+
invalid('Invalid request UI detail hook');
|
|
54
|
+
for (const name of ['toggleLabel', 'interfaceLabel', 'maintainerLabel', 'viewTitle', 'notFound'])
|
|
55
|
+
validateBrowserUiText(value.details[name]);
|
|
56
|
+
if (value.save !== undefined) {
|
|
57
|
+
fields(value.save, ['hook', 'setting']);
|
|
58
|
+
if (typeof value.save.hook !== 'string' || !/^[a-z][a-zA-Z0-9_.-]{0,127}$/.test(value.save.hook))
|
|
59
|
+
invalid('Invalid request save hook');
|
|
60
|
+
if (typeof value.save.setting !== 'string' || !/^[a-z][a-zA-Z0-9_-]{0,72}Enabled$/.test(value.save.setting) || ['agentEnabled', 'sseMonitoringEnabled', 'agentFloatingButtonEnabled'].includes(value.save.setting))
|
|
61
|
+
invalid('Invalid request save setting');
|
|
62
|
+
}
|
|
63
|
+
if (new TextEncoder().encode(JSON.stringify(value)).byteLength > MAX_REQUEST_UI_BYTES)
|
|
64
|
+
throw new BundleError('BUNDLE_QUOTA', 'Request UI exceeds 64 KiB');
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
/** Contribution validation is pure; factories and UI side effects belong to the host. */
|
|
68
|
+
export function selectBrowserRequestUi(bundles, apiVersion) {
|
|
69
|
+
let selected;
|
|
70
|
+
for (const bundle of bundles) {
|
|
71
|
+
const manifest = validateBundleManifest(bundle.manifest, apiVersion);
|
|
72
|
+
if (!manifest.contributions?.requestUi)
|
|
73
|
+
continue;
|
|
74
|
+
if (selected)
|
|
75
|
+
invalid('Request UI has multiple providers');
|
|
76
|
+
const definition = validateBrowserRequestUi(readBundleJson(bundle, manifest.contributions.requestUi));
|
|
77
|
+
const bindings = manifest.contributions.browserRuntimes?.extension?.bindings ?? {};
|
|
78
|
+
if ([definition.details.hook, ...(definition.save ? [definition.save.hook] : [])].some(hook => !Object.hasOwn(bindings, hook)))
|
|
79
|
+
invalid('Request UI hook must belong to its own browser runtime');
|
|
80
|
+
selected = { bundleId: manifest.id, revision: bundle.revision, definition };
|
|
81
|
+
}
|
|
82
|
+
return selected;
|
|
83
|
+
}
|
|
84
|
+
export function browserUiText(value, locale) {
|
|
85
|
+
return /^zh(?:[-_]CN)?$/i.test(locale) && value.zh_CN ? value.zh_CN : value.en;
|
|
86
|
+
}
|
|
87
|
+
export function browserRequestUiLink(value, api, version) {
|
|
88
|
+
const entries = [...Object.entries(value.apiLink.query), [value.apiLink.apiParameter, api], [value.apiLink.versionParameter, version]];
|
|
89
|
+
return value.apiLink.url + '?' + entries.map(([key, entry]) => `${encodeURIComponent(key)}=${encodeURIComponent(entry)}`).join('&');
|
|
90
|
+
}
|
|
91
|
+
export function browserRequestLoginError(value, message) {
|
|
92
|
+
const lower = message.toLowerCase();
|
|
93
|
+
return value.login.errorIncludes.some(fragment => lower.includes(fragment.toLowerCase()));
|
|
94
|
+
}
|
|
95
|
+
export function validateBrowserRequestDetail(input) {
|
|
96
|
+
const value = clonePresetDefaults(input);
|
|
97
|
+
fields(value, ['displayValue', 'jumpUrl', 'maintainerDisplayValue', 'maintainerTitle']);
|
|
98
|
+
if (typeof value.displayValue !== 'string' || !value.displayValue || value.displayValue.length > 16384 || ['maintainerDisplayValue', 'maintainerTitle'].some(key => value[key] !== undefined && (typeof value[key] !== 'string' || value[key].length > 16384)))
|
|
99
|
+
invalid('Invalid request detail value');
|
|
100
|
+
value.jumpUrl = browserUiHttpUrl(value.jumpUrl);
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { JsonObject, ResolvePresetOptions, ResolvedPreset } from './types.js';
|
|
2
|
+
/** Objects merge recursively; arrays, scalars and explicit null replace the previous value. */
|
|
3
|
+
export declare function mergePresetDefaults(base: JsonObject, overrides: JsonObject): JsonObject;
|
|
4
|
+
/** Pure resolution: transport and installation are supplied by the browser or Node host. */
|
|
5
|
+
export declare function resolvePreset(source: string, options: ResolvePresetOptions): Promise<ResolvedPreset>;
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { satisfies, valid } from 'semver';
|
|
2
|
+
import { clonePresetDefaults, isJsonObject, PresetError, validatePreset } from './manifest.js';
|
|
3
|
+
const kinds = ['packages', 'skills', 'providers'];
|
|
4
|
+
/** Objects merge recursively; arrays, scalars and explicit null replace the previous value. */
|
|
5
|
+
export function mergePresetDefaults(base, overrides) {
|
|
6
|
+
const result = clonePresetDefaults(base);
|
|
7
|
+
for (const [key, value] of Object.entries(clonePresetDefaults(overrides))) {
|
|
8
|
+
result[key] = isJsonObject(value) && isJsonObject(result[key])
|
|
9
|
+
? mergePresetDefaults(result[key], value)
|
|
10
|
+
: value;
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
function sourceUrl(source, parent) {
|
|
15
|
+
try {
|
|
16
|
+
const url = parent ? new URL(source, parent) : new URL(source);
|
|
17
|
+
if (!['https:', 'http:', 'file:'].includes(url.protocol) || url.username || url.password) {
|
|
18
|
+
throw new Error('unsupported source');
|
|
19
|
+
}
|
|
20
|
+
// URL fragments do not identify a different manifest for transport or cycle detection.
|
|
21
|
+
url.hash = '';
|
|
22
|
+
return url.href;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new PresetError('PRESET_INVALID', 'Expected an absolute HTTP(S)/file source URL without embedded credentials');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Install sources retain the selected tool's repository, package and ref syntax. */
|
|
29
|
+
function componentSource(kind, source, parent) {
|
|
30
|
+
if (kind === 'packages')
|
|
31
|
+
return sourceUrl(source, parent);
|
|
32
|
+
if (source.length > 8192 || /^[\s-]/.test(source) || /[\0-\x1f\x7f]/.test(source))
|
|
33
|
+
throw new PresetError('PRESET_INVALID', 'Invalid component install source');
|
|
34
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(source)) {
|
|
35
|
+
try {
|
|
36
|
+
if (new URL(source).password)
|
|
37
|
+
throw new Error('embedded credentials');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new PresetError('PRESET_INVALID', 'Invalid component install URL');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (/^(?:https?:|file:|\.{1,2}\/|\/)/i.test(source)) {
|
|
44
|
+
try {
|
|
45
|
+
const url = new URL(source, parent);
|
|
46
|
+
if (url.username || url.password)
|
|
47
|
+
throw new Error('embedded credentials');
|
|
48
|
+
return url.href;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new PresetError('PRESET_INVALID', 'Invalid component install URL');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return source;
|
|
55
|
+
}
|
|
56
|
+
function validateDependencies(kind, entries) {
|
|
57
|
+
const active = new Set();
|
|
58
|
+
const done = new Set();
|
|
59
|
+
const visit = (id) => {
|
|
60
|
+
if (done.has(id))
|
|
61
|
+
return;
|
|
62
|
+
if (active.has(id))
|
|
63
|
+
throw new PresetError('PRESET_DEPENDENCY', `${kind}: dependency cycle at ${id}`);
|
|
64
|
+
const entry = entries[id];
|
|
65
|
+
if (!entry || entry.enabled === false)
|
|
66
|
+
throw new PresetError('PRESET_DEPENDENCY', `${kind}: missing or disabled dependency ${id}`);
|
|
67
|
+
if (!entry.source)
|
|
68
|
+
throw new PresetError('PRESET_INVALID', `${kind}.${id}: enabled component requires a source`);
|
|
69
|
+
active.add(id);
|
|
70
|
+
for (const dependency of entry.dependsOn ?? [])
|
|
71
|
+
visit(dependency);
|
|
72
|
+
active.delete(id);
|
|
73
|
+
done.add(id);
|
|
74
|
+
};
|
|
75
|
+
for (const [id, entry] of Object.entries(entries))
|
|
76
|
+
if (entry.enabled !== false)
|
|
77
|
+
visit(id);
|
|
78
|
+
}
|
|
79
|
+
/** Pure resolution: transport and installation are supplied by the browser or Node host. */
|
|
80
|
+
export async function resolvePreset(source, options) {
|
|
81
|
+
if (valid(options.mearlApiVersion) !== options.mearlApiVersion) {
|
|
82
|
+
throw new PresetError('PRESET_INVALID', 'mearlApiVersion requires an exact semantic version');
|
|
83
|
+
}
|
|
84
|
+
const active = new Set();
|
|
85
|
+
const load = async (url) => {
|
|
86
|
+
options.signal?.throwIfAborted();
|
|
87
|
+
if (active.has(url))
|
|
88
|
+
throw new PresetError('PRESET_CYCLE', `Inheritance cycle at ${url}`);
|
|
89
|
+
if (active.size >= 32)
|
|
90
|
+
throw new PresetError('PRESET_INVALID', 'Inheritance exceeds 32 presets');
|
|
91
|
+
active.add(url);
|
|
92
|
+
const manifest = validatePreset(await options.load(url));
|
|
93
|
+
options.signal?.throwIfAborted();
|
|
94
|
+
if (manifest.requires?.mearlApi && !satisfies(options.mearlApiVersion, manifest.requires.mearlApi)) {
|
|
95
|
+
throw new PresetError('PRESET_INCOMPATIBLE', `${manifest.id} requires Mearl API ${manifest.requires.mearlApi}`);
|
|
96
|
+
}
|
|
97
|
+
let parent;
|
|
98
|
+
if (manifest.extends) {
|
|
99
|
+
parent = await load(sourceUrl(manifest.extends.source, url));
|
|
100
|
+
if (manifest.extends.version && !satisfies(parent.version, manifest.extends.version)) {
|
|
101
|
+
throw new PresetError('PRESET_INCOMPATIBLE', `${manifest.id} requires parent ${manifest.extends.version}; received ${parent.version}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const result = {
|
|
105
|
+
id: manifest.id,
|
|
106
|
+
version: manifest.version,
|
|
107
|
+
chain: [...(parent?.chain ?? []), { source: url, id: manifest.id, version: manifest.version }],
|
|
108
|
+
packages: { ...parent?.packages },
|
|
109
|
+
skills: { ...parent?.skills },
|
|
110
|
+
providers: { ...parent?.providers },
|
|
111
|
+
appProfiles: { ...parent?.appProfiles, ...manifest.appProfiles },
|
|
112
|
+
defaults: mergePresetDefaults(parent?.defaults ?? {}, manifest.defaults ?? {}),
|
|
113
|
+
permissions: { origins: [...new Set([...(parent?.permissions.origins ?? []), ...(manifest.permissions?.origins ?? [])])] },
|
|
114
|
+
};
|
|
115
|
+
for (const kind of kinds) {
|
|
116
|
+
for (const [id, patch] of Object.entries(manifest[kind] ?? {})) {
|
|
117
|
+
result[kind][id] = {
|
|
118
|
+
...result[kind][id],
|
|
119
|
+
...patch,
|
|
120
|
+
...(patch.source === undefined ? {} : { source: componentSource(kind, patch.source, url) }),
|
|
121
|
+
declaredBy: url,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
active.delete(url);
|
|
126
|
+
return result;
|
|
127
|
+
};
|
|
128
|
+
const result = await load(sourceUrl(source));
|
|
129
|
+
// Dependency validity is checked on the final graph, so children may complete or disable parent entries.
|
|
130
|
+
for (const kind of kinds)
|
|
131
|
+
validateDependencies(kind, result[kind]);
|
|
132
|
+
options.signal?.throwIfAborted();
|
|
133
|
+
return result;
|
|
134
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject;
|
|
2
|
+
export interface JsonObject {
|
|
3
|
+
[key: string]: JsonValue;
|
|
4
|
+
}
|
|
5
|
+
export type PresetRuntime = 'browser' | 'node';
|
|
6
|
+
/** A child declaration patches its parent's entry with the same stable ID. */
|
|
7
|
+
export interface PresetComponent {
|
|
8
|
+
/** Bundle URL for packages; installer-specific source for skills and providers. */
|
|
9
|
+
source?: string;
|
|
10
|
+
version?: string;
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
runtimes?: PresetRuntime[];
|
|
13
|
+
dependsOn?: string[];
|
|
14
|
+
}
|
|
15
|
+
export interface PresetReference {
|
|
16
|
+
source: string;
|
|
17
|
+
version?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface PresetManifest {
|
|
20
|
+
schemaVersion: 1;
|
|
21
|
+
id: string;
|
|
22
|
+
version: string;
|
|
23
|
+
extends?: PresetReference;
|
|
24
|
+
requires?: {
|
|
25
|
+
mearlApi?: string;
|
|
26
|
+
};
|
|
27
|
+
packages?: Record<string, PresetComponent>;
|
|
28
|
+
skills?: Record<string, PresetComponent>;
|
|
29
|
+
providers?: Record<string, PresetComponent>;
|
|
30
|
+
appProfiles?: Record<string, string>;
|
|
31
|
+
defaults?: JsonObject;
|
|
32
|
+
permissions?: {
|
|
33
|
+
origins?: string[];
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export type PresetComponentKind = 'packages' | 'skills' | 'providers';
|
|
37
|
+
export interface ResolvedComponent extends PresetComponent {
|
|
38
|
+
/** Preset URL that most recently declared this entry. */
|
|
39
|
+
declaredBy: string;
|
|
40
|
+
}
|
|
41
|
+
export interface ResolvedPreset {
|
|
42
|
+
id: string;
|
|
43
|
+
version: string;
|
|
44
|
+
/** Parent-first order; every source and exact preset version is retained. */
|
|
45
|
+
chain: Array<{
|
|
46
|
+
source: string;
|
|
47
|
+
id: string;
|
|
48
|
+
version: string;
|
|
49
|
+
}>;
|
|
50
|
+
packages: Record<string, ResolvedComponent>;
|
|
51
|
+
skills: Record<string, ResolvedComponent>;
|
|
52
|
+
providers: Record<string, ResolvedComponent>;
|
|
53
|
+
appProfiles: Record<string, string>;
|
|
54
|
+
defaults: JsonObject;
|
|
55
|
+
/** Required origins only. Actual browser grants belong to the installer. */
|
|
56
|
+
permissions: {
|
|
57
|
+
origins: string[];
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export interface ResolvePresetOptions {
|
|
61
|
+
mearlApiVersion: string;
|
|
62
|
+
/** Receives canonical absolute URLs; owns transport, credentials and source trust. */
|
|
63
|
+
load(source: string): Promise<unknown>;
|
|
64
|
+
signal?: AbortSignal;
|
|
65
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mearl/preset",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "3.0.0-beta.0",
|
|
4
|
+
"description": "Browser-compatible preset validation, inheritance and capability resolution for Mearl",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./bundles": {
|
|
15
|
+
"types": "./dist/bundles.d.ts",
|
|
16
|
+
"import": "./dist/bundles.js"
|
|
17
|
+
},
|
|
18
|
+
"./installation": {
|
|
19
|
+
"types": "./dist/installation.d.ts",
|
|
20
|
+
"import": "./dist/installation.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"!dist/**/*.map"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"semver": "^7.8.5"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/semver": "^7.7.1"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "rm -rf dist/ && tsc",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"test": "vitest run"
|
|
37
|
+
}
|
|
6
38
|
}
|