@backdrop-augmentation-protocol/component-sdk 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +38 -2
- package/README.zh-CN.md +42 -0
- package/examples/minimal/component.js +3 -0
- package/examples/minimal/index.html +1 -0
- package/examples/minimal/manifest.json +11 -0
- package/examples/minimal/serve.mjs +7 -0
- package/package.json +19 -4
- package/src/index.d.ts +11 -0
- package/src/index.js +5 -0
- package/src/manifest.js +46 -0
- package/src/post-message-client.js +50 -0
- package/src/protocol.js +43 -0
- package/src/runtime.js +77 -0
- package/src/standalone-shell.js +19 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Backdrop Augmentation Protocol contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# BAP Component SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[English](README.md) | [简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
Official MVP SDK for building dual-mode BAP components. A component can run independently in Standalone Mode or inside a compatible host through an Embedded iframe.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { StandaloneShell } from '@backdrop-augmentation-protocol/component-sdk';
|
|
11
|
+
|
|
12
|
+
const manifest = {
|
|
13
|
+
protocol: { name: 'bap', version: '0.1.0', profile: 'core' },
|
|
14
|
+
id: 'example.clock', name: 'Example Clock', version: '0.1.0',
|
|
15
|
+
role: 'component', capabilities: [], modes: ['standalone', 'embedded'],
|
|
16
|
+
entrypoints: {
|
|
17
|
+
default: './component.js',
|
|
18
|
+
embedded: './component.js',
|
|
19
|
+
standalone: './component.js'
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
new StandaloneShell({ manifest, render(root, runtime) {
|
|
24
|
+
root.textContent = runtime.getConfig('label') ?? 'Hello BAP';
|
|
25
|
+
} }).start();
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The official Standalone Shell is an optional convenience. The protocol only requires that Standalone Mode can run as a real component entry point.
|
|
29
|
+
|
|
30
|
+
## MVP API
|
|
31
|
+
|
|
32
|
+
- `validateManifest` / `assertManifest`: runtime manifest validation.
|
|
33
|
+
- `createEmbeddedEntry`: creates a component runtime for a Host-provided iframe.
|
|
34
|
+
- `createStandaloneEntry`: creates a minimal standalone runtime without a shell.
|
|
35
|
+
- `StandaloneShell`: optional minimal document shell for examples and development.
|
|
36
|
+
- `PostMessageClient`: request/response and event messaging over `postMessage`.
|
|
37
|
+
- `ComponentRuntime`: lifecycle (`start`, `pause`, `resume`, `stop`) and configuration APIs.
|
|
38
|
+
|
|
39
|
+
Run tests with `npm test`. Run the minimal example with `npm run example`, then open `http://localhost:4173`.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# BAP Component SDK
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 简体中文
|
|
4
|
+
|
|
5
|
+
用于构建 BAP 双模式组件的官方 MVP SDK。组件可以在 Standalone Mode 中独立
|
|
6
|
+
运行,也可以通过 Embedded iframe 运行在兼容 Host 中。
|
|
7
|
+
|
|
8
|
+
## 快速开始
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
import { StandaloneShell } from '@backdrop-augmentation-protocol/component-sdk';
|
|
12
|
+
|
|
13
|
+
const manifest = {
|
|
14
|
+
protocol: { name: 'bap', version: '0.1.0', profile: 'core' },
|
|
15
|
+
id: 'example.clock', name: 'Example Clock', version: '0.1.0',
|
|
16
|
+
role: 'component', capabilities: [], modes: ['standalone', 'embedded'],
|
|
17
|
+
entrypoints: {
|
|
18
|
+
default: './component.js',
|
|
19
|
+
embedded: './component.js',
|
|
20
|
+
standalone: './component.js'
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
new StandaloneShell({ manifest, render(root, runtime) {
|
|
25
|
+
root.textContent = runtime.getConfig('label') ?? 'Hello BAP';
|
|
26
|
+
} }).start();
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
官方 Standalone Shell 是可选的便利工具。协议只要求 Standalone Mode 能够
|
|
30
|
+
作为真实组件入口运行。
|
|
31
|
+
|
|
32
|
+
## MVP API
|
|
33
|
+
|
|
34
|
+
- `validateManifest` / `assertManifest`:运行时 Manifest 校验;
|
|
35
|
+
- `createEmbeddedEntry`:为 Host 提供的 iframe 创建组件运行时;
|
|
36
|
+
- `createStandaloneEntry`:不依赖 Shell 创建最小独立运行时;
|
|
37
|
+
- `StandaloneShell`:供示例和开发使用的可选最小文档外壳;
|
|
38
|
+
- `PostMessageClient`:通过 `postMessage` 实现请求/响应和事件通信;
|
|
39
|
+
- `ComponentRuntime`:生命周期(`start`、`pause`、`resume`、`stop`)和配置 API。
|
|
40
|
+
|
|
41
|
+
使用 `npm test` 运行测试。使用 `npm run example` 运行最小示例,然后打开
|
|
42
|
+
`http://localhost:4173`。
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { StandaloneShell } from '../../src/index.js';
|
|
2
|
+
const manifest = { protocol: { name: 'bap', version: '0.1.0', profile: 'core' }, id: 'example.clock', name: 'BAP Example Clock', version: '0.1.0', role: 'component', capabilities: [], modes: ['standalone', 'embedded'], entrypoints: { default: './component.js', embedded: './component.js', standalone: './component.js' } };
|
|
3
|
+
new StandaloneShell({ manifest, render(root, runtime) { const title = document.createElement('h1'); const time = document.createElement('time'); title.textContent = 'BAP Component SDK'; root.append(title, time); const tick = () => { time.textContent = new Date().toLocaleTimeString(); }; tick(); const timer = setInterval(tick, 1000); runtime.onLifecycle('stopped', () => clearInterval(timer)); } }).start();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><meta charset="utf-8"><title>BAP Example Clock</title><body><script type="module" src="./examples/minimal/component.js"></script></body>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"protocol": { "name": "bap", "version": "0.1.0", "profile": "core" },
|
|
3
|
+
"id": "example.clock",
|
|
4
|
+
"name": "BAP Example Clock",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"role": "component",
|
|
7
|
+
"modes": ["standalone", "embedded"],
|
|
8
|
+
"entrypoints": { "default": "./component.js", "embedded": "./component.js", "standalone": "./component.js" },
|
|
9
|
+
"capabilities": [],
|
|
10
|
+
"permissions": []
|
|
11
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { extname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
const root = fileURLToPath(new URL('../../', import.meta.url));
|
|
6
|
+
const types = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json' };
|
|
7
|
+
createServer(async (req, res) => { try { const pathname = new URL(req.url, 'http://localhost').pathname; const fileName = pathname === '/' ? 'examples/minimal/index.html' : pathname.slice(1); const file = await readFile(join(root, fileName)); res.writeHead(200, { 'content-type': types[extname(fileName)] || 'text/plain' }); res.end(file); } catch { res.writeHead(404); res.end('Not found'); } }).listen(4173, () => console.log('Example: http://localhost:4173'));
|
package/package.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backdrop-augmentation-protocol/component-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Official BAP Component SDK for dual-mode web components.",
|
|
6
|
+
"publishConfig": { "access": "public" },
|
|
7
|
+
"type": "module",
|
|
8
|
+
"types": "./src/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./manifest": "./src/manifest.js",
|
|
12
|
+
"./client": "./src/post-message-client.js",
|
|
13
|
+
"./runtime": "./src/runtime.js",
|
|
14
|
+
"./standalone-shell": "./src/standalone-shell.js"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test",
|
|
18
|
+
"example": "node examples/minimal/serve.mjs"
|
|
19
|
+
},
|
|
20
|
+
"files": ["src", "examples", "README.md", "README.zh-CN.md", "LICENSE"]
|
|
21
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ComponentMode = 'embedded' | 'standalone';
|
|
2
|
+
export interface ComponentManifest { protocol: { name: 'bap'; version: string; profile: string }; id: string; name: string; version: string; implementationVersion?: string; role: 'component'; capabilities: string[]; permissions?: string[]; modes: ComponentMode[]; entrypoints: { default: string; embedded?: string; standalone?: string }; configScopes?: Array<'global' | 'series' | 'wallpaper' | 'instance'>; description?: string; }
|
|
3
|
+
export interface ManifestResult { valid: boolean; errors: string[]; }
|
|
4
|
+
export declare function validateManifest(value: unknown): ManifestResult;
|
|
5
|
+
export declare function assertManifest(manifest: unknown): ComponentManifest;
|
|
6
|
+
export declare class ManifestValidationError extends Error { errors: string[]; }
|
|
7
|
+
export declare class PostMessageClient { constructor(options: { target: Window; targetOrigin?: string; receiveWindow?: Window; timeoutMs?: number; sessionId?: string }); request(method: string, payload?: unknown): Promise<unknown>; notify(event: string, payload?: unknown, options?: { sessionId?: string }): void; on(event: string, listener: (payload: unknown, message: unknown) => void): () => void; onRequest(handler: (message: unknown) => Promise<Record<string, unknown>> | Record<string, unknown>): () => void; close(): void; }
|
|
8
|
+
export declare class ComponentRuntime { manifest: ComponentManifest; mode: ComponentMode; state: string; constructor(options: { manifest: ComponentManifest; mode: ComponentMode; client?: PostMessageClient; config?: Record<string, unknown>; root?: HTMLElement }); start(): Promise<void>; pause(): Promise<void>; resume(): Promise<void>; stop(): Promise<void>; getConfig(key?: string): unknown; setConfig(patch: Record<string, unknown>): Promise<Record<string, unknown>>; onConfigChange(listener: (config: Record<string, unknown>) => void): () => void; onLifecycle(state: string, listener: (event: unknown) => void): () => void; hostRequest(method: string, params?: unknown): Promise<unknown>; }
|
|
9
|
+
export declare function createEmbeddedEntry(options: Record<string, unknown>): { runtime: ComponentRuntime; start(): Promise<void>; pause(): Promise<void>; resume(): Promise<void>; stop(): Promise<void> };
|
|
10
|
+
export declare function createStandaloneEntry(options: Record<string, unknown>): { runtime: ComponentRuntime; start(): Promise<void>; pause(): Promise<void>; resume(): Promise<void>; stop(): Promise<void> };
|
|
11
|
+
export declare class StandaloneShell { constructor(options: { manifest: ComponentManifest; root?: HTMLElement; config?: Record<string, unknown>; render?: (root: HTMLElement, runtime: ComponentRuntime) => void }); start(): Promise<void>; pause(): Promise<void>; resume(): Promise<void>; stop(): Promise<void>; }
|
package/src/index.js
ADDED
package/src/manifest.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const ID = /^[a-z0-9][a-z0-9._-]{1,127}$/;
|
|
2
|
+
const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
3
|
+
|
|
4
|
+
export class ManifestValidationError extends Error {
|
|
5
|
+
constructor(errors) {
|
|
6
|
+
super(`Invalid BAP component manifest: ${errors.join('; ')}`);
|
|
7
|
+
this.name = 'ManifestValidationError';
|
|
8
|
+
this.errors = errors;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function validateManifest(value) {
|
|
13
|
+
const errors = [];
|
|
14
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
15
|
+
return { valid: false, errors: ['manifest must be an object'] };
|
|
16
|
+
}
|
|
17
|
+
const allowed = new Set(['protocol', 'id', 'name', 'version', 'implementationVersion', 'role', 'capabilities', 'permissions', 'modes', 'entrypoints', 'configScopes', 'description']);
|
|
18
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`unknown field: ${key}`);
|
|
19
|
+
if (!value.protocol || value.protocol.name !== 'bap' || typeof value.protocol.version !== 'string' || !VERSION.test(value.protocol.version) || typeof value.protocol.profile !== 'string') errors.push('protocol must be { name: bap, version, profile }');
|
|
20
|
+
if (typeof value.id !== 'string' || !ID.test(value.id)) errors.push('id must be a lowercase package identifier');
|
|
21
|
+
if (typeof value.name !== 'string' || !value.name.trim()) errors.push('name must be a non-empty string');
|
|
22
|
+
if (typeof value.version !== 'string' || !VERSION.test(value.version)) errors.push('version must be semver-like (x.y.z)');
|
|
23
|
+
if (value.role !== 'component') errors.push('role must be component');
|
|
24
|
+
if (!Array.isArray(value.capabilities) || value.capabilities.some((item) => typeof item !== 'string')) errors.push('capabilities must be an array of strings');
|
|
25
|
+
if (!Array.isArray(value.modes) || value.modes.length === 0 || value.modes.some((item) => !['embedded', 'standalone'].includes(item))) errors.push('modes must contain standalone and/or embedded');
|
|
26
|
+
if (!value.entrypoints || typeof value.entrypoints !== 'object') errors.push('entrypoints is required');
|
|
27
|
+
else {
|
|
28
|
+
if (typeof value.entrypoints.default !== 'string' || !value.entrypoints.default.trim()) errors.push('entrypoints.default must be a non-empty path');
|
|
29
|
+
for (const mode of value.modes || []) {
|
|
30
|
+
if (typeof value.entrypoints[mode] !== 'string' || !value.entrypoints[mode].trim()) errors.push(`entrypoints.${mode} must be a non-empty path`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const field of ['permissions', 'configScopes']) {
|
|
34
|
+
if (value[field] !== undefined && (!Array.isArray(value[field]) || value[field].some((item) => typeof item !== 'string'))) {
|
|
35
|
+
errors.push(`${field} must be an array of strings`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(value.configScopes) && value.configScopes.some((item) => !['global', 'series', 'wallpaper', 'instance'].includes(item))) errors.push('configScopes contains an unsupported scope');
|
|
39
|
+
return { valid: errors.length === 0, errors };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function assertManifest(manifest) {
|
|
43
|
+
const result = validateManifest(manifest);
|
|
44
|
+
if (!result.valid) throw new ManifestValidationError(result.errors);
|
|
45
|
+
return manifest;
|
|
46
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createErrorResponse, createEvent, createRequest, createResponse, isProtocolMessage } from './protocol.js';
|
|
2
|
+
|
|
3
|
+
export class BapProtocolError extends Error {
|
|
4
|
+
constructor(error) {
|
|
5
|
+
super(error?.message || 'BAP protocol error');
|
|
6
|
+
this.name = 'BapProtocolError';
|
|
7
|
+
Object.assign(this, error);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class PostMessageClient {
|
|
12
|
+
constructor({ target, targetOrigin = '*', receiveWindow = globalThis.window, timeoutMs = 5000, sessionId } = {}) {
|
|
13
|
+
if (!target || typeof target.postMessage !== 'function') throw new TypeError('target must support postMessage');
|
|
14
|
+
this.target = target; this.targetOrigin = targetOrigin; this.receiveWindow = receiveWindow; this.timeoutMs = timeoutMs; this.sessionId = sessionId;
|
|
15
|
+
this.pending = new Map(); this.listeners = new Map(); this.requestHandler = null; this.peerOrigin = null; this._onMessage = (event) => this._receive(event);
|
|
16
|
+
this.receiveWindow?.addEventListener?.('message', this._onMessage);
|
|
17
|
+
}
|
|
18
|
+
request(method, payload = {}) {
|
|
19
|
+
const request = createRequest(method, payload, { sessionId: this.sessionId });
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const timer = setTimeout(() => { this.pending.delete(request.id); reject(new BapProtocolError({ code: 'TIMEOUT', message: `BAP request timed out: ${method}`, retryable: true })); }, this.timeoutMs);
|
|
22
|
+
this.pending.set(request.id, { resolve, reject, timer }); this.target.postMessage(request, this.targetOrigin);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
notify(event, payload = {}, options = {}) { this.target.postMessage(createEvent(event, payload, { ...options, sessionId: options.sessionId || this.sessionId }), this.targetOrigin); }
|
|
26
|
+
on(event, listener) { if (!this.listeners.has(event)) this.listeners.set(event, new Set()); this.listeners.get(event).add(listener); return () => this.listeners.get(event)?.delete(listener); }
|
|
27
|
+
onRequest(handler) { this.requestHandler = handler; return () => { if (this.requestHandler === handler) this.requestHandler = null; }; }
|
|
28
|
+
_receive(event) {
|
|
29
|
+
if (event.source && event.source !== this.target) return;
|
|
30
|
+
const message = event.data;
|
|
31
|
+
if (!isProtocolMessage(message)) return;
|
|
32
|
+
if (this.peerOrigin !== null && event.origin !== this.peerOrigin) return;
|
|
33
|
+
if (message.kind === 'request' && this.requestHandler) {
|
|
34
|
+
if (this.sessionId && message.method !== 'hello' && message.sessionId !== this.sessionId) return;
|
|
35
|
+
if (this.peerOrigin === null && message.method === 'hello') this.peerOrigin = event.origin ?? 'null';
|
|
36
|
+
void Promise.resolve().then(() => this.requestHandler(message)).then(
|
|
37
|
+
(result) => this._reply(createResponse(message, result, { sessionId: this.sessionId })),
|
|
38
|
+
(error) => this._reply(createErrorResponse(message, error?.code || 'INTERNAL_ERROR', error?.message || 'Component request failed', { sessionId: this.sessionId }))
|
|
39
|
+
);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (this.sessionId && message.sessionId !== this.sessionId) return;
|
|
43
|
+
if (message.kind === 'response' && message.correlationId && this.pending.has(message.correlationId)) {
|
|
44
|
+
const pending = this.pending.get(message.correlationId); this.pending.delete(message.correlationId); clearTimeout(pending.timer);
|
|
45
|
+
if (message.ok) pending.resolve(message.result); else pending.reject(new BapProtocolError(message.error));
|
|
46
|
+
} else if (message.kind === 'event') for (const listener of this.listeners.get(message.event) || []) listener(message.payload, message);
|
|
47
|
+
}
|
|
48
|
+
_reply(message) { this.target.postMessage(message, this.targetOrigin); }
|
|
49
|
+
close() { this.receiveWindow?.removeEventListener?.('message', this._onMessage); for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(new BapProtocolError({ code: 'INTERNAL_ERROR', message: 'BAP client closed' })); } this.pending.clear(); }
|
|
50
|
+
}
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const BAP_PROTOCOL = Object.freeze({ name: 'bap', version: '0.1.0', profile: 'core' });
|
|
2
|
+
export const CORE_METHODS = Object.freeze(['hello', 'initialize', 'start', 'pause', 'resume', 'stop']);
|
|
3
|
+
export const ERROR_CODES = Object.freeze([
|
|
4
|
+
'INVALID_MESSAGE', 'UNSUPPORTED_VERSION', 'UNSUPPORTED_PROFILE', 'UNAUTHORIZED',
|
|
5
|
+
'CAPABILITY_DENIED', 'INVALID_STATE', 'NOT_FOUND', 'CONFLICT', 'TIMEOUT', 'INTERNAL_ERROR'
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
export function timestamp() { return new Date().toISOString(); }
|
|
9
|
+
|
|
10
|
+
export function createRequest(method, payload = {}, { id = createId(), sessionId } = {}) {
|
|
11
|
+
return withSession({ protocol: BAP_PROTOCOL, kind: 'request', id, timestamp: timestamp(), method, payload }, sessionId);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createResponse(request, result = {}, { sessionId } = {}) {
|
|
15
|
+
return withSession({ protocol: BAP_PROTOCOL, kind: 'response', id: createId(), correlationId: request.id, timestamp: timestamp(), ok: true, result }, sessionId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createErrorResponse(request, code, message, { details, retryable = false, sessionId } = {}) {
|
|
19
|
+
if (!ERROR_CODES.includes(code)) throw new TypeError(`Unsupported BAP error code: ${code}`);
|
|
20
|
+
const error = { code, message, retryable };
|
|
21
|
+
if (details !== undefined) error.details = details;
|
|
22
|
+
return withSession({ protocol: BAP_PROTOCOL, kind: 'response', id: createId(), correlationId: request.id, timestamp: timestamp(), ok: false, error }, sessionId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createEvent(event, payload = {}, { id = createId(), sessionId, correlationId } = {}) {
|
|
26
|
+
const message = { protocol: BAP_PROTOCOL, kind: 'event', id, timestamp: timestamp(), event, payload };
|
|
27
|
+
if (correlationId) message.correlationId = correlationId;
|
|
28
|
+
return withSession(message, sessionId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createId() { return `msg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; }
|
|
32
|
+
|
|
33
|
+
export function isProtocolMessage(message) {
|
|
34
|
+
return Boolean(message && typeof message === 'object' && message.protocol?.name === 'bap' &&
|
|
35
|
+
typeof message.protocol.version === 'string' && message.protocol.profile === 'core' &&
|
|
36
|
+
['request', 'response', 'event'].includes(message.kind) && typeof message.id === 'string' &&
|
|
37
|
+
typeof message.timestamp === 'string');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function withSession(message, sessionId) {
|
|
41
|
+
if (sessionId) message.sessionId = sessionId;
|
|
42
|
+
return message;
|
|
43
|
+
}
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { assertManifest } from './manifest.js';
|
|
2
|
+
import { PostMessageClient } from './post-message-client.js';
|
|
3
|
+
import { BAP_PROTOCOL, createId } from './protocol.js';
|
|
4
|
+
|
|
5
|
+
export class ComponentRuntime {
|
|
6
|
+
constructor({ manifest, mode, client = null, config = {}, root = null } = {}) {
|
|
7
|
+
assertManifest(manifest);
|
|
8
|
+
if (!['embedded', 'standalone'].includes(mode)) throw new TypeError('mode must be embedded or standalone');
|
|
9
|
+
this.manifest = manifest; this.mode = mode; this.client = client; this.root = root;
|
|
10
|
+
this.state = 'created'; this.sessionId = null; this.config = globalThis.structuredClone ? globalThis.structuredClone(config) : { ...config }; this.configListeners = new Set();
|
|
11
|
+
this.lifecycleListeners = new Map();
|
|
12
|
+
if (this.mode === 'embedded' && this.client) this.client.onRequest((message) => this._hostRequest(message));
|
|
13
|
+
}
|
|
14
|
+
onLifecycle(state, listener) { if (!this.lifecycleListeners.has(state)) this.lifecycleListeners.set(state, new Set()); this.lifecycleListeners.get(state).add(listener); return () => this.lifecycleListeners.get(state)?.delete(listener); }
|
|
15
|
+
async start() {
|
|
16
|
+
if (this.mode === 'embedded') {
|
|
17
|
+
if (!this.client) throw new Error('Embedded mode requires a Host session channel');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (this.state === 'running') return;
|
|
21
|
+
if (this.state !== 'created' && this.state !== 'stopped') throw this._invalidState('start');
|
|
22
|
+
if (this.state === 'created') {
|
|
23
|
+
this.sessionId = `standalone-${Date.now().toString(36)}`;
|
|
24
|
+
await this._transition('connected'); await this._requestLifecycle('initialize'); await this._transition('initialized');
|
|
25
|
+
} else { await this._requestLifecycle('initialize'); await this._transition('initialized'); }
|
|
26
|
+
await this._requestLifecycle('start'); await this._transition('running');
|
|
27
|
+
}
|
|
28
|
+
async pause() { if (this.state === 'paused') return; if (this.state !== 'running') throw this._invalidState('pause'); await this._requestLifecycle('pause'); await this._transition('paused'); }
|
|
29
|
+
async resume() { if (this.state === 'paused') { await this._requestLifecycle('resume'); await this._transition('running'); } }
|
|
30
|
+
async stop() { if (this.state === 'stopped') return; if (!['running', 'paused'].includes(this.state)) throw this._invalidState('stop'); await this._requestLifecycle('stop'); await this._transition('stopped'); }
|
|
31
|
+
async _requestLifecycle(method) { if (this.client) await this.client.request(method, { componentId: this.manifest.id, mode: this.mode }); }
|
|
32
|
+
_invalidState(method) { const error = new Error(`Cannot ${method} from ${this.state}`); error.code = 'INVALID_STATE'; return error; }
|
|
33
|
+
async _hostRequest(message) {
|
|
34
|
+
const { method, payload } = message;
|
|
35
|
+
if (method === 'hello') {
|
|
36
|
+
if (this.state !== 'created') throw this._invalidState('hello');
|
|
37
|
+
if (message.protocol?.name !== BAP_PROTOCOL.name || message.protocol.profile !== BAP_PROTOCOL.profile) {
|
|
38
|
+
const error = new Error('Unsupported Core profile'); error.code = 'UNSUPPORTED_PROFILE'; throw error;
|
|
39
|
+
}
|
|
40
|
+
if (payload?.role !== 'host' || !payload.identity?.id || !Array.isArray(payload.protocolVersions) || !Array.isArray(payload.capabilities) || !Array.isArray(payload.requestedPermissions)) {
|
|
41
|
+
const error = new Error('Invalid hello payload'); error.code = 'INVALID_MESSAGE'; throw error;
|
|
42
|
+
}
|
|
43
|
+
if (!payload.protocolVersions.includes(BAP_PROTOCOL.version) || message.protocol.version !== BAP_PROTOCOL.version) {
|
|
44
|
+
const error = new Error('Unsupported protocol version'); error.code = 'UNSUPPORTED_VERSION'; throw error;
|
|
45
|
+
}
|
|
46
|
+
this.sessionId = createId(); this.client.sessionId = this.sessionId;
|
|
47
|
+
await this._transition('connected');
|
|
48
|
+
return { selectedProtocol: BAP_PROTOCOL, role: 'component', capabilities: this.manifest.capabilities.filter(value => payload.capabilities.includes(value)), permissions: (this.manifest.permissions || []).filter(value => payload.requestedPermissions.includes(value)) };
|
|
49
|
+
}
|
|
50
|
+
if (!this.sessionId || message.sessionId !== this.sessionId) {
|
|
51
|
+
const error = new Error('Missing or invalid session'); error.code = 'UNAUTHORIZED'; throw error;
|
|
52
|
+
}
|
|
53
|
+
if (method === 'initialize' && (this.state === 'connected' || this.state === 'stopped')) { await this._transition('initialized'); return { state: this.state }; }
|
|
54
|
+
if (method === 'start' && this.state === 'initialized') { await this._transition('running'); return { state: this.state }; }
|
|
55
|
+
if (method === 'pause' && this.state === 'running') { await this._transition('paused'); return { state: this.state }; }
|
|
56
|
+
if (method === 'resume' && this.state === 'paused') { await this._transition('running'); return { state: this.state }; }
|
|
57
|
+
if (method === 'stop' && ['running', 'paused'].includes(this.state)) { await this._transition('stopped'); return { state: this.state }; }
|
|
58
|
+
if ((method === 'pause' && this.state === 'paused') || (method === 'resume' && this.state === 'running') || (method === 'stop' && this.state === 'stopped')) return { state: this.state };
|
|
59
|
+
if (['initialize', 'start', 'pause', 'resume', 'stop'].includes(method)) throw this._invalidState(method);
|
|
60
|
+
const error = new Error(`Unknown method: ${method}`); error.code = 'NOT_FOUND'; throw error;
|
|
61
|
+
}
|
|
62
|
+
async _transition(state) { this.state = state; for (const listener of this.lifecycleListeners.get(state) || []) await listener({ state, runtime: this }); if (state !== 'connected') this.client?.notify('state.changed', { role: 'component', state, id: this.manifest.id }, { sessionId: this.sessionId }); }
|
|
63
|
+
getConfig(key) { return key === undefined ? this.config : this.config[key]; }
|
|
64
|
+
async setConfig(patch) { if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new TypeError('config patch must be an object'); this.config = { ...this.config, ...patch }; for (const listener of this.configListeners) await listener(this.config); this.client?.notify('config.changed', { scope: 'wallpaper', config: this.config }, { sessionId: this.sessionId }); return this.config; }
|
|
65
|
+
onConfigChange(listener) { this.configListeners.add(listener); return () => this.configListeners.delete(listener); }
|
|
66
|
+
hostRequest(method, params) { if (!this.client) return Promise.reject(new Error('Host API is unavailable in standalone mode')); return this.client.request(method, params); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createEmbeddedEntry(options) { return createEntry({ ...options, mode: 'embedded' }); }
|
|
70
|
+
export function createStandaloneEntry(options) { return createEntry({ ...options, mode: 'standalone' }); }
|
|
71
|
+
function createEntry({ manifest, mode, root = globalThis.document?.body, client, config, targetOrigin = '*' } = {}) {
|
|
72
|
+
if (mode === 'embedded' && !client && globalThis.window?.parent && globalThis.window.parent !== globalThis.window) {
|
|
73
|
+
client = new PostMessageClient({ target: globalThis.window.parent, targetOrigin, receiveWindow: globalThis.window });
|
|
74
|
+
}
|
|
75
|
+
const runtime = new ComponentRuntime({ manifest, mode, root, client, config });
|
|
76
|
+
return { runtime, start: () => runtime.start(), pause: () => runtime.pause(), resume: () => runtime.resume(), stop: () => runtime.stop() };
|
|
77
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createStandaloneEntry } from './runtime.js';
|
|
2
|
+
|
|
3
|
+
export class StandaloneShell {
|
|
4
|
+
constructor({ manifest, root = document.body, config = {}, render } = {}) {
|
|
5
|
+
this.root = root; this.render = render;
|
|
6
|
+
root.classList.add('bap-standalone-shell');
|
|
7
|
+
root.style.cssText = 'margin:0;min-height:100vh;background:#111;color:#eee;font:14px system-ui,sans-serif;';
|
|
8
|
+
this.surface = document.createElement('main');
|
|
9
|
+
this.surface.dataset.bapComponent = manifest.id;
|
|
10
|
+
this.surface.style.cssText = 'min-height:100vh;box-sizing:border-box;padding:24px;';
|
|
11
|
+
root.append(this.surface);
|
|
12
|
+
this.entry = createStandaloneEntry({ manifest, root: this.surface, config });
|
|
13
|
+
if (render) render(this.surface, this.entry.runtime);
|
|
14
|
+
}
|
|
15
|
+
start() { return this.entry.start(); }
|
|
16
|
+
pause() { return this.entry.pause(); }
|
|
17
|
+
resume() { return this.entry.resume(); }
|
|
18
|
+
stop() { return this.entry.stop(); }
|
|
19
|
+
}
|