@mearl/preset 3.0.0-beta.5 → 3.0.0-beta.8

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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # @mearl/preset
2
2
 
3
+ 3.x 的声明、配置、能力目录及对象存储设计见 [能力包模型](../../docs/capability-model.md)。包支持的位置从贡献推导,Preset 的可选 `targets` 仅选择启用子集;操作和登录定义归包,配置覆盖归 Preset 与用户。
4
+
3
5
  浏览器包的准备与本地存储见 [能力包](../../docs/browser-bundles.md),整套原子激活与 App 实现映射见 [浏览器 Preset](../../docs/browser-presets.md)。这两类能力通过 `@mearl/preset/bundles` 导出。
4
6
 
5
7
  浏览器和 Node 共用一次继承解析及资源下载,见 [整套资源准备](../../docs/preset-preparation.md)。`preparePreset` 返回完整确切图与兼容的浏览器投影;Node 模块选择直接复用同批字节,准备过程不执行代码或安装外部环境。
@@ -35,7 +37,7 @@ Node 模块快照与宿主加载合同见 [Node 能力包](../../docs/node-capab
35
37
  "extends": { "source": "../base/preset.json", "version": "^1" },
36
38
  "requires": { "mearlApi": "^1" },
37
39
  "packages": {
38
- "capture": { "source": "./capture/package.json", "runtimes": ["browser"] },
40
+ "capture": { "source": "./capture/package.json", "targets": ["browser"] },
39
41
  "unused": { "enabled": false }
40
42
  },
41
43
  "skills": {},
@@ -56,6 +58,47 @@ Node 模块快照与宿主加载合同见 [Node 能力包](../../docs/node-capab
56
58
  - 结果保留父级到子级的确切版本链和各组件最近声明来源,加载失败不会生成部分成功的结果。
57
59
  - 拒绝未知字段、非 JSON 配置和原型污染键。清单总量不超过 256 Ki UTF-16 code units,defaults 最大嵌套 32 层。
58
60
 
59
- 本包尚不包含完整 preset 安装器、preset 锁文件持久化或 UI。浏览器宿主和 setup 需要复用同一解析结果接续资源准备、权限、注册与激活;能力包本地存储只负责已校验快照的事务替换。
61
+ 本包负责通用清单解析、校验和资源图,不执行安装或展示 UI。完整安装器、preset 锁文件持久化、权限、注册与激活由浏览器宿主及 setup 提供,见 [安装宿主](../../docs/preset-installation.md);能力包本地存储负责已校验快照的事务替换。
60
62
 
61
63
  Skill manager 的可选 `readSources(scope, cwd)` 返回该管理器核验过的来源收据,结构与 setup 的 `TrackedSkillSource` 一致。CLI 与 Native 安装状态共用该读取接口;管理器负责持久化、校验和失效自身补充记录,公共宿主继续核对来源、ref、清单身份与托管归属。未提供时读取标准 Skills 锁文件。
64
+
65
+ ## 工具目录
66
+
67
+ Preset 可通过 `defaults.extension.toolCatalogs` 注册额外的可安装工具目录:
68
+
69
+ ```json
70
+ {
71
+ "defaults": {
72
+ "extension": {
73
+ "toolCatalogs": {
74
+ "team": {
75
+ "url": "https://tools.example.com/toolbox.json",
76
+ "label": "Team"
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ 目录使用绝对 HTTPS URL(本机开发允许 loopback HTTP),最多 16 个。使用稳定的目录 ID,子 preset 可只覆盖 `url` 或 `label`,或设置 `enabled: false` 禁用父级目录。官方目录始终保留且优先,不能使用保留 ID `official`。需要跨域访问时在 preset 的 `permissions.origins` 声明相应域名,由现有授权流程取得权限。
85
+
86
+ 目录 JSON 与官方目录使用同一结构,最多 256 项、256 KiB:
87
+
88
+ ```json
89
+ {
90
+ "tools": [{
91
+ "id": "plugin:team-tool",
92
+ "plugin": "team-tool",
93
+ "repository": "example/skills",
94
+ "skill": "team-tool",
95
+ "title": "Team tool",
96
+ "description": "A tool for the team",
97
+ "icon": "wrench"
98
+ }]
99
+ }
100
+ ```
101
+
102
+ `repository` 也支持 HTTPS/SSH Git 来源和固定 ref(例如 `git@code.example.com:team/skills.git#release/v3`),安装时原样交给 preset 选择的 Skills 管理器。清单不接受 shell 参数、凭据或本地文件路径,也不执行其中的代码;列出工具不会自动安装。
103
+
104
+ 各来源并发读取、独立缓存,重试时刷新当前目录;失败时保留该来源已有缓存,其他来源仍可使用。相同插件 ID 与安装来源去重;同 ID 指向不同仓库或 Skill 时保留优先目录并提示冲突。较低优先目录的安装按钮会等待前面的目录完成检查,避免加载顺序改变安装目标。更换或禁用目录后,旧来源和迟到响应不会留在当前列表。
@@ -0,0 +1,29 @@
1
+ import { type PreparedBundle } from './bundle.js';
2
+ import { type BrowserUiText } from './requestUi.js';
3
+ import { type NetworkRoutePlan } from './networkRoutes.js';
4
+ export interface BrowserSegmentedControl {
5
+ schemaVersion: 1;
6
+ id: string;
7
+ kind: 'segmented';
8
+ label: BrowserUiText;
9
+ description: BrowserUiText;
10
+ defaultValue: string;
11
+ aliases?: string[];
12
+ messages: {
13
+ changed: BrowserUiText;
14
+ failed: BrowserUiText;
15
+ };
16
+ options: Array<{
17
+ value: string;
18
+ label: BrowserUiText;
19
+ networkRoutes: NetworkRoutePlan;
20
+ }>;
21
+ }
22
+ export interface SelectedBrowserControl {
23
+ bundleId: string;
24
+ revision: string;
25
+ definition: BrowserSegmentedControl;
26
+ }
27
+ export declare function validateBrowserControl(input: unknown): BrowserSegmentedControl;
28
+ /** The network-route surface has one owner, so competing packages cannot silently overwrite it. */
29
+ export declare function selectBrowserControl(bundles: PreparedBundle[]): SelectedBrowserControl | undefined;
@@ -0,0 +1,54 @@
1
+ import { BundleError, readBundleJson } from './bundle.js';
2
+ import { validateBrowserUiText } from './requestUi.js';
3
+ import { parseNetworkRoutePlan } from './networkRoutes.js';
4
+ const id = /^[a-z][a-zA-Z0-9_.-]{0,79}$/;
5
+ function fields(value, names) {
6
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !names.includes(key)))
7
+ throw new BundleError('BUNDLE_INVALID', 'Invalid browser control');
8
+ }
9
+ export function validateBrowserControl(input) {
10
+ fields(input, ['schemaVersion', 'id', 'kind', 'label', 'description', 'defaultValue', 'aliases', 'messages', 'options']);
11
+ if (input.schemaVersion !== 1 || input.kind !== 'segmented' || typeof input.id !== 'string' || !id.test(input.id) || typeof input.defaultValue !== 'string' || !id.test(input.defaultValue))
12
+ throw new BundleError('BUNDLE_INVALID', 'Invalid segmented control');
13
+ validateBrowserUiText(input.label);
14
+ validateBrowserUiText(input.description);
15
+ fields(input.messages, ['changed', 'failed']);
16
+ validateBrowserUiText(input.messages.changed);
17
+ validateBrowserUiText(input.messages.failed);
18
+ if (input.aliases !== undefined && (!Array.isArray(input.aliases) || input.aliases.length > 8 || new Set(input.aliases).size !== input.aliases.length || input.aliases.some(alias => typeof alias !== 'string' || !id.test(alias))))
19
+ throw new BundleError('BUNDLE_INVALID', 'Invalid browser control aliases');
20
+ if (!Array.isArray(input.options) || input.options.length < 2 || input.options.length > 8)
21
+ throw new BundleError('BUNDLE_INVALID', 'Invalid browser control options');
22
+ const values = new Set();
23
+ const options = input.options.map(option => {
24
+ fields(option, ['value', 'label', 'networkRoutes']);
25
+ if (typeof option.value !== 'string' || !id.test(option.value) || values.has(option.value))
26
+ throw new BundleError('BUNDLE_INVALID', 'Invalid browser control option');
27
+ values.add(option.value);
28
+ validateBrowserUiText(option.label);
29
+ try {
30
+ return { value: option.value, label: structuredClone(option.label), networkRoutes: parseNetworkRoutePlan(option.networkRoutes) };
31
+ }
32
+ catch {
33
+ throw new BundleError('BUNDLE_INVALID', 'Invalid control network route plan');
34
+ }
35
+ });
36
+ if (!values.has(input.defaultValue) || options.find(option => option.value === input.defaultValue).networkRoutes.routes.length)
37
+ throw new BundleError('BUNDLE_INVALID', 'The default option must clear network routes');
38
+ if (new TextEncoder().encode(JSON.stringify(input)).byteLength > 65536)
39
+ throw new BundleError('BUNDLE_QUOTA', 'Browser control exceeds 64 KiB');
40
+ return { schemaVersion: 1, id: input.id, kind: 'segmented', label: structuredClone(input.label), description: structuredClone(input.description), defaultValue: input.defaultValue, messages: structuredClone(input.messages), options, ...(input.aliases ? { aliases: [...input.aliases] } : {}) };
41
+ }
42
+ /** The network-route surface has one owner, so competing packages cannot silently overwrite it. */
43
+ export function selectBrowserControl(bundles) {
44
+ let selected;
45
+ for (const bundle of bundles) {
46
+ const resource = bundle.manifest.contributions?.browserControls;
47
+ if (!resource)
48
+ continue;
49
+ if (selected)
50
+ throw new BundleError('BUNDLE_CONFLICT', 'Browser network control has multiple providers');
51
+ selected = { bundleId: bundle.manifest.id, revision: bundle.revision, definition: validateBrowserControl(readBundleJson(bundle, resource)) };
52
+ }
53
+ return selected;
54
+ }
@@ -1,15 +1,18 @@
1
+ import { selectBrowserControl } from './browserControls.js';
1
2
  import { BundleError, MAX_BROWSER_STORAGE_BYTES, verifyBundle } from './bundle.js';
2
3
  import { satisfies } from 'semver';
3
- import { preparePresetPackages, presetPackages, presetSnapshotRevision, verifyPresetGraph } from './prepare.js';
4
+ import { preparePresetPackages, presetPackages, presetSnapshotRevision, verifyPresetGraph, verifyPackageTargets } from './prepare.js';
4
5
  /** Core browser defaults also need synchronization when no browser package is declared. */
5
6
  export function presetRequiresBrowser(preset) {
6
- return Object.values(preset.packages).some(entry => entry.enabled !== false && entry.runtimes?.includes('browser')) || Object.keys(preset.appProfiles).length > 0 || Object.keys(preset.defaults).some(key => key === 'simulation' || key === 'extension' || key === 'services');
7
+ return Object.values(preset.packages).some(entry => entry.enabled !== false && entry.targets?.includes('browser')) || Object.keys(preset.appProfiles).length > 0 || Object.keys(preset.defaults).some(key => key === 'simulation' || key === 'extension' || key === 'services');
7
8
  }
8
9
  /** Browser selection can be derived from the same accepted batch without another download. */
9
10
  export async function browserPresetSnapshot(source, manifests, preset, bundles) {
10
11
  const ids = new Set(presetPackages(preset, ['browser']).map(([id]) => id));
11
12
  const selected = bundles.filter(bundle => ids.has(bundle.manifest.id));
12
- const data = { source, manifests, bundleRevisions: Object.fromEntries(selected.map(bundle => [bundle.manifest.id, bundle.revision])) };
13
+ selectBrowserControl(selected);
14
+ const packageTargets = Object.fromEntries(Object.entries(preset.packages).filter(([, entry]) => entry.enabled !== false).map(([id, entry]) => [id, entry.targets]));
15
+ const data = { source, manifests, bundleRevisions: Object.fromEntries(selected.map(bundle => [bundle.manifest.id, bundle.revision])), packageTargets };
13
16
  return { snapshot: { ...data, revision: await presetSnapshotRevision(data) }, preset, bundles: selected };
14
17
  }
15
18
  export async function prepareBrowserPreset(source, options) {
@@ -48,5 +51,7 @@ export async function verifyPreparedBrowserPreset(value, mearlApiVersion) {
48
51
  return graph.snapshot.bundleRevisions[bundle.manifest.id] !== bundle.revision || entry?.source !== bundle.source || (entry.version && !satisfies(bundle.manifest.version, entry.version));
49
52
  }))
50
53
  throw new BundleError('BUNDLE_INTEGRITY', 'Browser activation requires every exact package');
54
+ selectBrowserControl(bundles);
55
+ verifyPackageTargets(graph.preset, bundles);
51
56
  return { ...graph, bundles };
52
57
  }
@@ -16,7 +16,7 @@ export interface BrowserViewDefinition {
16
16
  entry: string;
17
17
  actions: Record<string, BrowserViewAction>;
18
18
  preferences?: Record<string, {
19
- legacyKey?: string;
19
+ shared?: 'editor.highlight' | 'json.viewMode' | 'json.shortcuts' | 'code.shortcuts';
20
20
  area?: 'local' | 'session';
21
21
  }>;
22
22
  transports?: Record<string, BrowserViewTransport>;
@@ -30,13 +30,13 @@ export function validateBrowserView(input) {
30
30
  if (value.preferences !== undefined) {
31
31
  const aliases = new Set();
32
32
  for (const [, preference] of entries(value.preferences, 32)) {
33
- fields(preference, ['legacyKey', 'area']);
33
+ fields(preference, ['shared', 'area']);
34
34
  if (preference.area !== undefined && preference.area !== 'local' && preference.area !== 'session')
35
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))
36
+ if (preference.shared !== undefined) {
37
+ if (typeof preference.shared !== 'string' || !['editor.highlight', 'json.viewMode', 'json.shortcuts', 'code.shortcuts'].includes(preference.shared) || aliases.has(preference.shared))
38
38
  invalid('Invalid browser view preference alias');
39
- aliases.add(preference.legacyKey);
39
+ aliases.add(preference.shared);
40
40
  }
41
41
  }
42
42
  }
@@ -200,5 +200,5 @@ export function buildBrowserViewPage(html) {
200
200
  if (typeof html !== 'string' || !html.trim())
201
201
  invalid('Browser view entry must not be empty');
202
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}`;
203
+ return `<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${policy}"><script>(${installBrowserViewBridge.toString()})();</script>${html}<style data-mearl-view-surface>html,body{background:transparent!important}</style>`;
204
204
  }
package/dist/bundle.d.ts CHANGED
@@ -14,17 +14,20 @@ export interface BundleManifest {
14
14
  requires?: {
15
15
  mearlApi: string;
16
16
  };
17
+ defaults?: import('./types.js').JsonObject;
17
18
  resources: Record<string, {
18
19
  sha256: string;
19
20
  byteLength: number;
20
21
  }>;
21
22
  contributions?: {
22
- appProfiles: string[];
23
+ appProfiles?: string[];
23
24
  pageRuntimes?: Record<string, string>;
24
25
  nodeRuntimes?: Record<string, CapabilityRuntimeDeclaration>;
25
26
  browserRuntimes?: Record<string, CapabilityRuntimeDeclaration>;
27
+ extension?: string;
26
28
  requestUi?: string;
27
29
  browserViews?: Record<string, string>;
30
+ browserControls?: string;
28
31
  };
29
32
  }
30
33
  export interface CapabilityRuntimeDeclaration {
package/dist/bundle.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { satisfies, valid, validRange } from 'semver';
2
2
  import { clonePresetDefaults } from './manifest.js';
3
+ import { validateBrowserControl } from './browserControls.js';
3
4
  export const MAX_BUNDLE_BYTES = 16 * 1024 * 1024;
4
5
  export const MAX_BUNDLE_MANIFEST_BYTES = 64 * 1024;
5
6
  export const MAX_BROWSER_STORAGE_BYTES = 64 * 1024 * 1024;
@@ -47,7 +48,9 @@ export function validateBundleManifest(value, mearlApiVersion) {
47
48
  catch {
48
49
  invalid('Expected a JSON manifest');
49
50
  }
50
- fields(json, ['schemaVersion', 'id', 'version', 'requires', 'resources', 'contributions']);
51
+ fields(json, ['schemaVersion', 'id', 'version', 'requires', 'defaults', 'resources', 'contributions']);
52
+ if (json.defaults !== undefined)
53
+ json.defaults = clonePresetDefaults(json.defaults);
51
54
  if (json.schemaVersion !== 1 || typeof json.id !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/.test(json.id))
52
55
  invalid('Invalid bundle identity');
53
56
  if (typeof json.version !== 'string' || valid(json.version) !== json.version)
@@ -79,7 +82,17 @@ export function validateBundleManifest(value, mearlApiVersion) {
79
82
  throw new BundleError('BUNDLE_QUOTA', 'Bundle resources exceed 16 MiB');
80
83
  }
81
84
  if (json.contributions !== undefined) {
82
- fields(json.contributions, ['appProfiles', 'pageRuntimes', 'nodeRuntimes', 'browserRuntimes', 'requestUi', 'browserViews']);
85
+ fields(json.contributions, ['appProfiles', 'pageRuntimes', 'nodeRuntimes', 'browserRuntimes', 'extension', 'requestUi', 'browserViews', 'browserControls']);
86
+ const extension = json.contributions.extension;
87
+ if (extension !== undefined && (typeof extension !== 'string' || !Object.hasOwn(json.resources, extension)))
88
+ invalid('Extension contributions must reference a declared resource');
89
+ if (typeof extension === 'string' && json.resources[extension].byteLength > MAX_REQUEST_UI_BYTES)
90
+ throw new BundleError('BUNDLE_QUOTA', 'Extension contributions exceed 64 KiB');
91
+ const controls = json.contributions.browserControls;
92
+ if (controls !== undefined && (typeof controls !== 'string' || !Object.hasOwn(json.resources, controls)))
93
+ invalid('Browser controls must reference a declared resource');
94
+ if (typeof controls === 'string' && json.resources[controls].byteLength > MAX_REQUEST_UI_BYTES)
95
+ throw new BundleError('BUNDLE_QUOTA', 'Browser controls exceed 64 KiB');
83
96
  if (json.contributions.requestUi !== undefined && (typeof json.contributions.requestUi !== 'string' || !Object.hasOwn(json.resources, json.contributions.requestUi)))
84
97
  invalid('Request UI must reference a declared resource');
85
98
  if (typeof json.contributions.requestUi === 'string' && json.resources[json.contributions.requestUi].byteLength > MAX_REQUEST_UI_BYTES)
@@ -93,7 +106,7 @@ export function validateBundleManifest(value, mearlApiVersion) {
93
106
  throw new BundleError('BUNDLE_QUOTA', 'Browser view definition exceeds 64 KiB');
94
107
  }
95
108
  }
96
- const profiles = json.contributions.appProfiles;
109
+ const profiles = json.contributions.appProfiles === undefined ? [] : json.contributions.appProfiles;
97
110
  if (!Array.isArray(profiles) || profiles.some(name => typeof name !== 'string' || !Object.hasOwn(json.resources, name)) ||
98
111
  new Set(profiles).size !== profiles.length)
99
112
  invalid('App profiles must reference distinct declared resources');
@@ -188,7 +201,10 @@ export async function prepareBundle(source, options) {
188
201
  resources[name] = resource;
189
202
  }
190
203
  options.signal?.throwIfAborted();
191
- return { source, manifest, revision: await manifestRevision(source, manifest), resources };
204
+ const prepared = { source, manifest, revision: await manifestRevision(source, manifest), resources };
205
+ if (manifest.contributions?.browserControls)
206
+ validateBrowserControl(readBundleJson(prepared, manifest.contributions.browserControls));
207
+ return prepared;
192
208
  }
193
209
  /** Revalidates persisted snapshots without network access and returns independent resource buffers. */
194
210
  export async function verifyBundle(bundle, mearlApiVersion) {
@@ -215,5 +231,8 @@ export async function verifyBundle(bundle, mearlApiVersion) {
215
231
  if (await bundleDigest(resources[name]) !== expected.sha256)
216
232
  throw new BundleError('BUNDLE_INTEGRITY', `Stored resource integrity check failed: ${name}`);
217
233
  }
218
- return { source, manifest, revision, resources };
234
+ const prepared = { source, manifest, revision, resources };
235
+ if (manifest.contributions?.browserControls)
236
+ validateBrowserControl(readBundleJson(prepared, manifest.contributions.browserControls));
237
+ return prepared;
219
238
  }
@@ -11,8 +11,8 @@ function installationMetadata(value) {
11
11
  }
12
12
  const STORE = 'bundles';
13
13
  const PRESET = 'preset';
14
- const RUNTIMES = 'pageRuntimes';
15
- const PINS = 'pageRuntimePins';
14
+ const OBJECTS = 'objects';
15
+ const PINS = 'resourcePins';
16
16
  function captureRevisions(input) {
17
17
  if (input === undefined)
18
18
  return;
@@ -25,39 +25,54 @@ function checkRevisions(records, expected) {
25
25
  if (expected && (records.length !== Object.keys(expected).length || records.some(record => !Object.hasOwn(expected, record.id) || expected[record.id] !== record.revision)))
26
26
  throw new BundleError('BUNDLE_CONFLICT', 'Browser bundles changed while contributions were being validated');
27
27
  }
28
- function runtimePaths(bundle) {
29
- return [...new Set([
30
- ...Object.values(bundle?.manifest?.contributions?.pageRuntimes ?? {}),
31
- ...Object.values(bundle?.manifest?.contributions?.browserRuntimes ?? {}).map(runtime => runtime?.entry),
32
- ].filter((name) => typeof name === 'string'))];
28
+ function storedBundle(bundle) {
29
+ const { resources, ...metadata } = bundle;
30
+ return { id: bundle.manifest.id, revision: bundle.revision,
31
+ byteLength: Object.values(resources).reduce((sum, bytes) => sum + bytes.byteLength, 0), bundle: metadata };
33
32
  }
34
- function referencedRuntimes(records, runtimes, pins) {
35
- const referenced = new Set(pins.map(pin => pin.sha256));
36
- for (const record of records)
37
- for (const name of runtimePaths(record.bundle)) {
38
- const digest = record.bundle?.manifest?.resources?.[name]?.sha256;
39
- if (digest)
40
- referenced.add(digest);
41
- }
42
- return runtimes.filter(runtime => referenced.has(runtime.sha256));
33
+ function bundleObjectHashes(records) {
34
+ const hashes = new Set();
35
+ for (const record of records) {
36
+ const resources = record?.bundle?.manifest?.resources;
37
+ if (!resources || typeof resources !== 'object' || Array.isArray(resources))
38
+ continue;
39
+ for (const resource of Object.values(resources))
40
+ if (resource && typeof resource.sha256 === 'string' && /^[a-f0-9]{64}$/.test(resource.sha256))
41
+ hashes.add(resource.sha256);
42
+ }
43
+ return hashes;
44
+ }
45
+ function referencedObjects(records, resources, pins) {
46
+ const referenced = bundleObjectHashes(records);
47
+ for (const pin of pins)
48
+ referenced.add(pin.sha256);
49
+ return resources.filter(resource => referenced.has(resource.sha256));
43
50
  }
44
- function runtimeResources(bundles) {
51
+ function bundleObjects(bundles) {
45
52
  const result = new Map();
46
53
  for (const bundle of bundles)
47
- for (const name of runtimePaths(bundle)) {
48
- const resource = bundle?.manifest?.resources?.[name], bytes = bundle?.resources?.[name];
49
- if (resource && typeof resource.sha256 === 'string' && /^[a-f0-9]{64}$/.test(resource.sha256) && bytes instanceof Uint8Array && bytes.byteLength === resource.byteLength)
50
- result.set(resource.sha256, { sha256: resource.sha256, bytes });
51
- }
54
+ for (const [name, resource] of Object.entries(bundle.manifest.resources))
55
+ result.set(resource.sha256, { sha256: resource.sha256, bytes: bundle.resources[name] });
52
56
  return [...result.values()];
53
57
  }
58
+ /** Schedule object reads inside the metadata transaction, preserving one consistent snapshot. */
59
+ function readObjects(records, store) {
60
+ const result = new Map();
61
+ const hashes = bundleObjectHashes(records);
62
+ for (const hash of hashes) {
63
+ const request = store.get(hash);
64
+ request.onsuccess = () => { if (request.result)
65
+ result.set(hash, request.result); };
66
+ }
67
+ return result;
68
+ }
54
69
  function checkStorageBudget(records, runtimes) {
55
70
  if (records.some(record => !Number.isSafeInteger(record.byteLength) || record.byteLength < 0 || record.byteLength > MAX_BUNDLE_BYTES) ||
56
71
  runtimes.some(runtime => !(runtime.bytes instanceof Uint8Array) || runtime.bytes.byteLength > MAX_BUNDLE_BYTES)) {
57
72
  throw new BundleError('BUNDLE_INTEGRITY', 'Stored resource size is invalid');
58
73
  }
59
74
  const retained = runtimes.reduce((sum, runtime) => sum + runtime.bytes.byteLength, 0);
60
- const total = records.reduce((sum, record) => sum + record.byteLength, retained);
75
+ const total = retained;
61
76
  if (records.length > 64 || !Number.isSafeInteger(total) || total > MAX_BROWSER_STORAGE_BYTES)
62
77
  throw new BundleError('BUNDLE_QUOTA', 'Installed and retained resources exceed the browser storage budget');
63
78
  }
@@ -71,14 +86,14 @@ export class BrowserBundleStore {
71
86
  if (this.database)
72
87
  return this.database;
73
88
  this.database = new Promise((resolve, reject) => {
74
- const request = (this.options.indexedDB ?? indexedDB).open(this.options.databaseName ?? 'mearl-capability-bundles', 4);
89
+ const request = (this.options.indexedDB ?? indexedDB).open(this.options.databaseName ?? 'mearl-capabilities-v3', 1);
75
90
  request.onupgradeneeded = () => {
76
91
  if (!request.result.objectStoreNames.contains(STORE))
77
92
  request.result.createObjectStore(STORE, { keyPath: 'id' });
78
93
  if (!request.result.objectStoreNames.contains(PRESET))
79
94
  request.result.createObjectStore(PRESET);
80
- if (!request.result.objectStoreNames.contains(RUNTIMES))
81
- request.result.createObjectStore(RUNTIMES, { keyPath: 'sha256' });
95
+ if (!request.result.objectStoreNames.contains(OBJECTS))
96
+ request.result.createObjectStore(OBJECTS, { keyPath: 'sha256' });
82
97
  if (!request.result.objectStoreNames.contains(PINS))
83
98
  request.result.createObjectStore(PINS, { keyPath: 'owner' });
84
99
  };
@@ -100,30 +115,41 @@ export class BrowserBundleStore {
100
115
  async records() {
101
116
  const database = await this.open();
102
117
  return new Promise((resolve, reject) => {
103
- const transaction = database.transaction([STORE, PRESET], 'readonly');
118
+ const transaction = database.transaction([STORE, PRESET, OBJECTS], 'readonly');
104
119
  const request = transaction.objectStore(STORE).getAll();
120
+ let objects = new Map();
121
+ request.onsuccess = () => { objects = readObjects(request.result, transaction.objectStore(OBJECTS)); };
105
122
  const active = transaction.objectStore(PRESET).get('active');
106
123
  const installation = transaction.objectStore(PRESET).get('installation');
107
124
  const token = transaction.objectStore(PRESET).get('installationToken');
108
- transaction.oncomplete = () => resolve({ records: request.result, active: active.result, installation: installation.result, installationToken: token.result ?? null });
125
+ transaction.oncomplete = () => resolve({ objects, records: request.result, active: active.result, installation: installation.result, installationToken: token.result ?? null });
109
126
  transaction.onabort = transaction.onerror = () => reject(transaction.error ?? new Error('Bundle read failed'));
110
127
  });
111
128
  }
112
129
  async get(id) {
113
130
  const database = await this.open();
131
+ let objects = new Map();
114
132
  const record = await new Promise((resolve, reject) => {
115
- const transaction = database.transaction(STORE, 'readonly');
133
+ const transaction = database.transaction([STORE, OBJECTS], 'readonly');
116
134
  const request = transaction.objectStore(STORE).get(id);
135
+ request.onsuccess = () => { objects = readObjects(request.result ? [request.result] : [], transaction.objectStore(OBJECTS)); };
117
136
  transaction.oncomplete = () => resolve(request.result);
118
137
  transaction.onabort = transaction.onerror = () => reject(transaction.error ?? new Error('Bundle read failed'));
119
138
  });
120
- return record ? this.verified(record) : undefined;
139
+ return record ? this.verified(record, objects) : undefined;
121
140
  }
122
- async verified(record) {
141
+ async verified(record, objects) {
123
142
  if (record.id !== record.bundle?.manifest?.id || record.revision !== record.bundle?.revision) {
124
143
  throw new BundleError('BUNDLE_INTEGRITY', 'Stored bundle identity does not match');
125
144
  }
126
- const bundle = await verifyBundle(record.bundle, this.options.mearlApiVersion);
145
+ const resources = {};
146
+ for (const [name, metadata] of Object.entries(record.bundle.manifest.resources)) {
147
+ const object = objects.get(metadata.sha256);
148
+ if (!object || object.sha256 !== metadata.sha256)
149
+ throw new BundleError('BUNDLE_INTEGRITY', 'Stored resource integrity check failed: object is missing');
150
+ resources[name] = object.bytes;
151
+ }
152
+ const bundle = await verifyBundle({ ...record.bundle, resources }, this.options.mearlApiVersion);
127
153
  if (Object.values(bundle.resources).reduce((sum, bytes) => sum + bytes.byteLength, 0) !== record.byteLength) {
128
154
  throw new BundleError('BUNDLE_INTEGRITY', 'Stored resource size does not match');
129
155
  }
@@ -132,10 +158,10 @@ export class BrowserBundleStore {
132
158
  async list() {
133
159
  const bundles = [];
134
160
  const errors = [];
135
- const { records, active, installation: storedInstallation, installationToken } = await this.records();
161
+ const { objects, records, active, installation: storedInstallation, installationToken } = await this.records();
136
162
  for (const record of records) {
137
163
  try {
138
- bundles.push(await this.verified(record));
164
+ bundles.push(await this.verified(record, objects));
139
165
  }
140
166
  catch (error) {
141
167
  errors.push({ id: record.id, error: error instanceof Error ? error.message : 'Invalid stored bundle' });
@@ -191,8 +217,8 @@ export class BrowserBundleStore {
191
217
  throw new BundleError('BUNDLE_INVALID', 'Invalid browser runtime digest');
192
218
  const database = await this.open();
193
219
  const record = await new Promise((resolve, reject) => {
194
- const transaction = database.transaction(RUNTIMES, 'readonly');
195
- const request = transaction.objectStore(RUNTIMES).get(sha256);
220
+ const transaction = database.transaction(OBJECTS, 'readonly');
221
+ const request = transaction.objectStore(OBJECTS).get(sha256);
196
222
  transaction.oncomplete = () => resolve(request.result);
197
223
  transaction.onabort = transaction.onerror = () => reject(transaction.error ?? new Error('Browser runtime read failed'));
198
224
  });
@@ -228,8 +254,8 @@ export class BrowserBundleStore {
228
254
  });
229
255
  const database = await this.open();
230
256
  await new Promise((resolve, reject) => {
231
- const transaction = database.transaction([STORE, RUNTIMES, PINS], 'readwrite');
232
- const runtimeStore = transaction.objectStore(RUNTIMES);
257
+ const transaction = database.transaction([STORE, OBJECTS, PINS], 'readwrite');
258
+ const runtimeStore = transaction.objectStore(OBJECTS);
233
259
  const pinStore = transaction.objectStore(PINS);
234
260
  const records = transaction.objectStore(STORE).getAll();
235
261
  const resources = runtimeStore.getAll();
@@ -237,26 +263,7 @@ export class BrowserBundleStore {
237
263
  let failure;
238
264
  request.onsuccess = () => {
239
265
  const cached = new Map(resources.result.map(resource => [resource.sha256, resource]));
240
- const missing = new Set(patch.flatMap(({ sha256 }) => sha256 && !cached.has(sha256) ? [sha256] : []));
241
- let added = false;
242
- for (const runtime of runtimeResources(records.result.map(record => record.bundle))) {
243
- if (missing.has(runtime.sha256)) {
244
- cached.set(runtime.sha256, runtime);
245
- runtimeStore.put(runtime);
246
- added = true;
247
- }
248
- }
249
266
  const available = [...cached.values()];
250
- if (added) {
251
- try {
252
- checkStorageBudget(records.result, available);
253
- }
254
- catch (error) {
255
- failure = error;
256
- transaction.abort();
257
- return;
258
- }
259
- }
260
267
  const pins = new Map(replace ? [] : request.result.map((pin) => [pin.owner, pin]));
261
268
  for (const { owner, sha256 } of patch) {
262
269
  if (sha256 === null) {
@@ -273,7 +280,7 @@ export class BrowserBundleStore {
273
280
  pinStore.clear();
274
281
  for (const pin of pins.values())
275
282
  pinStore.put(pin);
276
- const retained = new Set(referencedRuntimes(records.result, available, [...pins.values()]).map(runtime => runtime.sha256));
283
+ const retained = new Set(referencedObjects(records.result, available, [...pins.values()]).map(runtime => runtime.sha256));
277
284
  for (const runtime of available)
278
285
  if (!retained.has(runtime.sha256))
279
286
  runtimeStore.delete(runtime.sha256);
@@ -290,10 +297,12 @@ export class BrowserBundleStore {
290
297
  return undefined;
291
298
  const id = target.split('/')[0];
292
299
  const database = await this.open();
300
+ let objects = new Map();
293
301
  const record = await new Promise((resolve, reject) => {
294
- const transaction = database.transaction([PRESET, STORE], 'readonly');
302
+ const transaction = database.transaction([PRESET, STORE, OBJECTS], 'readonly');
295
303
  const current = transaction.objectStore(PRESET).get('active');
296
304
  const bundle = transaction.objectStore(STORE).get(id);
305
+ bundle.onsuccess = () => { objects = readObjects(bundle.result ? [bundle.result] : [], transaction.objectStore(OBJECTS)); };
297
306
  transaction.oncomplete = () => {
298
307
  if (current.result?.revision !== activation.snapshot.revision) {
299
308
  reject(new BundleError('BUNDLE_CONFLICT', 'The selected preset changed during profile lookup'));
@@ -306,7 +315,7 @@ export class BrowserBundleStore {
306
315
  });
307
316
  if (!record || record.revision !== activation.snapshot.bundleRevisions[id])
308
317
  throw new BundleError('BUNDLE_INTEGRITY', 'The selected app provider is missing');
309
- return { target, bundle: await this.verified(record) };
318
+ return { target, bundle: await this.verified(record, objects) };
310
319
  }
311
320
  /** Packages and the exact preset graph become visible together, after host contribution checks. */
312
321
  async activatePreset(input, replacements, expectedRevision, association, expectedBundleRevisions) {
@@ -323,11 +332,11 @@ export class BrowserBundleStore {
323
332
  const token = accepted ? await bundleDigest(crypto.getRandomValues(new Uint8Array(32))) : null;
324
333
  const database = await this.open();
325
334
  await new Promise((resolve, reject) => {
326
- const transaction = database.transaction([STORE, PRESET, RUNTIMES, PINS], 'readwrite');
335
+ const transaction = database.transaction([STORE, PRESET, OBJECTS, PINS], 'readwrite');
327
336
  const store = transaction.objectStore(STORE);
328
337
  const presets = transaction.objectStore(PRESET);
329
338
  const records = store.getAll();
330
- const runtimeStore = transaction.objectStore(RUNTIMES);
339
+ const runtimeStore = transaction.objectStore(OBJECTS);
331
340
  const retained = runtimeStore.getAll();
332
341
  const pins = transaction.objectStore(PINS).getAll();
333
342
  const installation = presets.get('installationToken');
@@ -356,12 +365,12 @@ export class BrowserBundleStore {
356
365
  }
357
366
  const result = records.result.filter(record => !replacing.has(record.id) && !previousManaged.has(record.id));
358
367
  for (const { bundle } of next)
359
- result.push({ id: bundle.manifest.id, revision: bundle.revision, byteLength: Object.values(bundle.resources).reduce((sum, bytes) => sum + bytes.byteLength, 0), bundle });
360
- const resources = runtimeResources(next.map(item => item.bundle));
368
+ result.push(storedBundle(bundle));
369
+ const resources = bundleObjects(next.map(item => item.bundle));
361
370
  const cache = new Map(retained.result.map(runtime => [runtime.sha256, runtime]));
362
371
  for (const runtime of resources)
363
372
  cache.set(runtime.sha256, runtime);
364
- const keeping = referencedRuntimes(result, [...cache.values()], pins.result);
373
+ const keeping = referencedObjects(result, [...cache.values()], pins.result);
365
374
  try {
366
375
  checkStorageBudget(result, keeping);
367
376
  }
@@ -400,21 +409,18 @@ export class BrowserBundleStore {
400
409
  async install(bundle, expectedRevision, expectedBundleRevisions) {
401
410
  const selection = captureRevisions(expectedBundleRevisions);
402
411
  const verified = await verifyBundle(bundle, this.options.mearlApiVersion);
403
- const byteLength = Object.values(verified.resources).reduce((sum, bytes) => sum + bytes.byteLength, 0);
404
- await this.change(verified.manifest.id, expectedRevision, {
405
- id: verified.manifest.id, revision: verified.revision, byteLength, bundle: verified,
406
- }, selection);
412
+ await this.change(verified.manifest.id, expectedRevision, storedBundle(verified), selection, bundleObjects([verified]));
407
413
  }
408
414
  async remove(id, expectedRevision) {
409
415
  await this.change(id, expectedRevision);
410
416
  }
411
- async change(id, expectedRevision, next, selection) {
417
+ async change(id, expectedRevision, next, selection, resources = []) {
412
418
  const database = await this.open();
413
419
  await new Promise((resolve, reject) => {
414
- const transaction = database.transaction([STORE, PRESET, RUNTIMES, PINS], 'readwrite');
420
+ const transaction = database.transaction([STORE, PRESET, OBJECTS, PINS], 'readwrite');
415
421
  const store = transaction.objectStore(STORE);
416
422
  const active = transaction.objectStore(PRESET).get('active');
417
- const runtimeStore = transaction.objectStore(RUNTIMES);
423
+ const runtimeStore = transaction.objectStore(OBJECTS);
418
424
  const retained = runtimeStore.getAll();
419
425
  const pins = transaction.objectStore(PINS).getAll();
420
426
  let failure;
@@ -442,11 +448,10 @@ export class BrowserBundleStore {
442
448
  }
443
449
  if (next) {
444
450
  const others = records.filter(record => record.id !== id);
445
- const resources = runtimeResources([next.bundle]);
446
451
  const cache = new Map(retained.result.map(runtime => [runtime.sha256, runtime]));
447
452
  for (const runtime of resources)
448
453
  cache.set(runtime.sha256, runtime);
449
- const keeping = referencedRuntimes([...others, next], [...cache.values()], pins.result);
454
+ const keeping = referencedObjects([...others, next], [...cache.values()], pins.result);
450
455
  try {
451
456
  checkStorageBudget([...others, next], keeping);
452
457
  }
@@ -465,7 +470,7 @@ export class BrowserBundleStore {
465
470
  }
466
471
  else {
467
472
  store.delete(id);
468
- const keep = new Set(referencedRuntimes(records.filter(record => record.id !== id), retained.result, pins.result).map(runtime => runtime.sha256));
473
+ const keep = new Set(referencedObjects(records.filter(record => record.id !== id), retained.result, pins.result).map(runtime => runtime.sha256));
469
474
  for (const runtime of retained.result)
470
475
  if (!keep.has(runtime.sha256))
471
476
  runtimeStore.delete(runtime.sha256);