@bardioc/app-sdk 0.5.0 → 1.0.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.
Files changed (47) hide show
  1. package/README.md +86 -253
  2. package/dist/api/graph.d.ts +30 -0
  3. package/dist/api/graph.js +139 -0
  4. package/dist/api/os.d.ts +30 -0
  5. package/dist/api/os.js +48 -0
  6. package/dist/api/relay.d.ts +7 -0
  7. package/dist/api/relay.js +16 -0
  8. package/dist/api/request.d.ts +18 -0
  9. package/dist/api/request.js +12 -0
  10. package/dist/client.d.ts +104 -0
  11. package/dist/client.js +106 -0
  12. package/dist/contract.d.ts +294 -0
  13. package/dist/contract.js +150 -0
  14. package/dist/dev-idb.d.ts +10 -0
  15. package/dist/dev-idb.js +42 -0
  16. package/dist/dev-proxy.js +17 -3
  17. package/dist/dev.d.ts +16 -12
  18. package/dist/dev.js +101 -119
  19. package/dist/errors.d.ts +24 -25
  20. package/dist/errors.js +33 -48
  21. package/dist/index.d.ts +10 -7
  22. package/dist/index.js +2 -4
  23. package/dist/manifest.d.ts +10 -2
  24. package/dist/manifest.js +28 -24
  25. package/dist/protocol.d.ts +6 -29
  26. package/dist/protocol.js +3 -31
  27. package/dist/react.d.ts +21 -27
  28. package/dist/react.js +88 -212
  29. package/dist/serve.d.ts +26 -0
  30. package/dist/serve.js +64 -0
  31. package/dist/testing.d.ts +17 -0
  32. package/dist/testing.js +128 -0
  33. package/dist/types.d.ts +0 -250
  34. package/dist/vite.js +3 -0
  35. package/dist/wire.d.ts +46 -0
  36. package/dist/wire.js +138 -0
  37. package/package.json +12 -4
  38. package/dist/contract-matrix.d.ts +0 -148
  39. package/dist/contract-matrix.js +0 -150
  40. package/dist/host-bridge.d.ts +0 -3
  41. package/dist/host-bridge.js +0 -318
  42. package/dist/transports/backend-transport.d.ts +0 -24
  43. package/dist/transports/backend-transport.js +0 -54
  44. package/dist/transports/graph-transport.d.ts +0 -241
  45. package/dist/transports/graph-transport.js +0 -616
  46. package/dist/transports/os-transport.d.ts +0 -190
  47. package/dist/transports/os-transport.js +0 -444
package/dist/serve.js ADDED
@@ -0,0 +1,64 @@
1
+ import * as z from 'zod/mini';
2
+ import { APP_METHODS, HOST_METHODS, PROTOCOL_VERSION, } from './contract.js';
3
+ import { BardiocError } from './errors.js';
4
+ import { isHandshake, openStream, Peer } from './wire.js';
5
+ const parse = (schema, params) => {
6
+ const parsed = schema.safeParse(params);
7
+ if (parsed.success)
8
+ return parsed.data;
9
+ throw new BardiocError('invalid', z.prettifyError(parsed.error), {
10
+ details: parsed.error.issues,
11
+ });
12
+ };
13
+ /** Answers an app's calls on `port` with `handlers`, validating every call against the contract. */
14
+ export function serve(port, handlers) {
15
+ const streams = new Set();
16
+ const route = (name, method) => {
17
+ const handler = handlers[name];
18
+ if (method.kind === 'call') {
19
+ return (params, signal) => handler(parse(method.input, params), signal);
20
+ }
21
+ return params => {
22
+ const input = parse(method.input, params);
23
+ const { port: reader, dispose } = openStream(emit => {
24
+ const stop = handler(input, emit);
25
+ return () => {
26
+ streams.delete(dispose);
27
+ stop();
28
+ };
29
+ });
30
+ streams.add(dispose);
31
+ return reader;
32
+ };
33
+ };
34
+ const peer = new Peer(port, new Map(Object.entries(APP_METHODS).map(([name, method]) => [name, route(name, method)])));
35
+ return {
36
+ call: (method, params) => peer.call(method, params, { timeoutMs: HOST_METHODS[method].timeoutMs }),
37
+ emit: (event, payload) => peer.emit(event, payload),
38
+ close: () => {
39
+ streams.forEach(dispose => dispose());
40
+ peer.close();
41
+ },
42
+ };
43
+ }
44
+ /** Waits for an app's `ready` and answers each one with a fresh port. */
45
+ export function acceptApp({ source, origin, context, onConnect, onReject }) {
46
+ const onMessage = (event) => {
47
+ const app = source();
48
+ if (!app || event.source !== app || event.origin !== origin)
49
+ return;
50
+ if (!isHandshake(event.data) || event.data.bardioc !== 'ready')
51
+ return;
52
+ const reply = (message, transfer = []) => app.postMessage(message, origin, transfer);
53
+ if (event.data.protocol !== PROTOCOL_VERSION) {
54
+ reply({ bardioc: 'reject', protocol: PROTOCOL_VERSION });
55
+ onReject?.(event.data.protocol);
56
+ return;
57
+ }
58
+ const { port1, port2 } = new MessageChannel();
59
+ onConnect(port1);
60
+ reply({ bardioc: 'connect', protocol: PROTOCOL_VERSION, context: context() }, [port2]);
61
+ };
62
+ window.addEventListener('message', onMessage);
63
+ return () => window.removeEventListener('message', onMessage);
64
+ }
@@ -0,0 +1,17 @@
1
+ import type { GraphEvent } from './contract.js';
2
+ import { type HostHandlers } from './serve.js';
3
+ export interface ConformanceCase {
4
+ name: string;
5
+ run(): Promise<void>;
6
+ }
7
+ /** An EventSource double: cases push events into the sources a host opens. */
8
+ export declare class FakeEventSource extends EventTarget {
9
+ readonly url: string;
10
+ static readonly opened: FakeEventSource[];
11
+ readyState: number;
12
+ constructor(url: string);
13
+ push(event: GraphEvent): void;
14
+ close(): void;
15
+ }
16
+ /** Behaviour every host shares; run each case under any test runner. */
17
+ export declare function hostConformance(makeHandlers: () => Promise<HostHandlers>): ConformanceCase[];
@@ -0,0 +1,128 @@
1
+ import { deepStrictEqual, equal, ok, rejects } from 'node:assert/strict';
2
+ import { BardiocError } from './errors.js';
3
+ import { serve } from './serve.js';
4
+ import { Peer, readStream } from './wire.js';
5
+ /** An EventSource double: cases push events into the sources a host opens. */
6
+ export class FakeEventSource extends EventTarget {
7
+ url;
8
+ static opened = [];
9
+ readyState = 1;
10
+ constructor(url) {
11
+ super();
12
+ this.url = url;
13
+ FakeEventSource.opened.push(this);
14
+ }
15
+ push(event) {
16
+ this.dispatchEvent(new MessageEvent(event.type, { data: JSON.stringify(event) }));
17
+ }
18
+ close() {
19
+ this.readyState = 2;
20
+ }
21
+ }
22
+ async function withGlobals(globals, run) {
23
+ const saved = { fetch: globalThis.fetch, EventSource: globalThis.EventSource };
24
+ Object.assign(globalThis, { EventSource: FakeEventSource }, globals);
25
+ FakeEventSource.opened.length = 0;
26
+ try {
27
+ await run();
28
+ }
29
+ finally {
30
+ Object.assign(globalThis, saved);
31
+ }
32
+ }
33
+ const connect = (handlers) => {
34
+ const { port1, port2 } = new MessageChannel();
35
+ const connection = serve(port1, handlers);
36
+ const peer = new Peer(port2, new Map());
37
+ const call = (method, params) => peer.call(method, params, { timeoutMs: 2000 });
38
+ return { call, close: () => (connection.close(), peer.close()) };
39
+ };
40
+ const respond = (body, init = {}) => new Response(body, {
41
+ ...init,
42
+ headers: init.json ? { 'Content-Type': 'application/json' } : init.headers,
43
+ });
44
+ /** Behaviour every host shares; run each case under any test runner. */
45
+ export function hostConformance(makeHandlers) {
46
+ const test = (name, run) => ({
47
+ name,
48
+ run: async () => {
49
+ const host = connect(await makeHandlers());
50
+ try {
51
+ await run(host);
52
+ }
53
+ finally {
54
+ host.close();
55
+ }
56
+ },
57
+ });
58
+ return [
59
+ test('acknowledges ui calls', async ({ call }) => {
60
+ equal(await call('ui.notify', { message: 'Hi.', level: 'info' }), undefined);
61
+ equal(await call('ui.setCommands', { commands: [{ id: 'save' }] }), undefined);
62
+ equal(await call('ui.setMenu', { menu: null }), undefined);
63
+ equal(await call('ui.setActiveTab', { activeTab: 'Overview' }), undefined);
64
+ }),
65
+ test('stores idb entries per store', async ({ call }) => {
66
+ await call('idb.put', { store: 'notes', key: 'b', value: { n: 2 } });
67
+ await call('idb.put', { store: 'notes', key: 'a', value: { n: 1 } });
68
+ await call('idb.put', { store: 'other', key: 'a', value: 0 });
69
+ const entry = (await call('idb.get', { store: 'notes', key: 'a' }));
70
+ deepStrictEqual(entry, { key: 'a', value: { n: 1 }, updatedAt: entry.updatedAt });
71
+ const keys = (await call('idb.query', { store: 'notes' }));
72
+ deepStrictEqual(keys.map(({ key }) => key), ['a', 'b']);
73
+ await call('idb.delete', { store: 'notes', key: 'a' });
74
+ equal(await call('idb.get', { store: 'notes', key: 'a' }), null);
75
+ }),
76
+ test('answers bff requests with the parsed body', async ({ call }) => {
77
+ const requests = [];
78
+ const fetch = async (input, init) => {
79
+ requests.push(new Request(new URL(String(input), 'http://host'), init));
80
+ return respond(JSON.stringify({ ok: 1 }), { json: true });
81
+ };
82
+ await withGlobals({ fetch }, async () => {
83
+ const value = await call('bff.request', {
84
+ path: '/os/units',
85
+ method: 'POST',
86
+ body: { name: 'x' },
87
+ contentType: 'application/json',
88
+ scopePolicy: 'none',
89
+ });
90
+ deepStrictEqual(value, { ok: 1 });
91
+ });
92
+ const [request] = requests;
93
+ ok(request);
94
+ ok(request.url.endsWith('/os/units'));
95
+ equal(request.method, 'POST');
96
+ equal(request.headers.get('Content-Type'), 'application/json');
97
+ equal(request.headers.get('X-Requested-With'), 'XMLHttpRequest');
98
+ equal(await request.text(), '{"name":"x"}');
99
+ }),
100
+ test('classifies bff failures by status', async ({ call }) => {
101
+ const fetch = async () => respond('gone', { status: 404 });
102
+ await withGlobals({ fetch }, () => rejects(call('bff.request', { path: '/os/x', scopePolicy: 'none' }), (error) => {
103
+ ok(error instanceof BardiocError);
104
+ equal(error.code, 'not_found');
105
+ equal(error.status, 404);
106
+ return true;
107
+ }));
108
+ }),
109
+ test('streams graph events until the reader ends', async ({ call }) => {
110
+ await withGlobals({}, async () => {
111
+ const port = (await call('events.graph', { types: ['ogit/Note'] }));
112
+ const received = [];
113
+ const end = readStream(port, event => received.push(event));
114
+ const [source] = FakeEventSource.opened;
115
+ ok(source);
116
+ const filter = new URL(source.url, 'http://host').searchParams.get('filter');
117
+ equal(filter, '(element.ogit/_type=ogit/Note)');
118
+ const event = { id: 'e1', type: 'CREATE', timestamp: 1 };
119
+ source.push(event);
120
+ await new Promise(resolve => setTimeout(resolve, 20));
121
+ deepStrictEqual(received, [event]);
122
+ end();
123
+ await new Promise(resolve => setTimeout(resolve, 20));
124
+ equal(source.readyState, 2);
125
+ });
126
+ }),
127
+ ];
128
+ }
package/dist/types.d.ts CHANGED
@@ -1,218 +1,6 @@
1
- /** Wire-level envelope sent from iframe → host. */
2
- export interface SdkMessage<T extends string = string, P = unknown> {
3
- type: T;
4
- id: string;
5
- payload: P;
6
- }
7
- /** Wire-level envelope sent from host → iframe as a response. */
8
- export interface SdkResponse<P = unknown> {
9
- type: string;
10
- id: string;
11
- payload: P;
12
- error?: string;
13
- }
14
- /** OS configuration provided to apps. */
15
- export interface OsConfig {
16
- theme: string;
17
- language: string;
18
- tabId: string;
19
- windowId: string;
20
- instanceId: string | null;
21
- systemSize?: string;
22
- }
23
- /** Context returned by the host after a successful handshake. */
24
- export interface SdkInitResponse {
25
- appId: string;
26
- instanceId: string | null;
27
- osConfig?: OsConfig;
28
- }
29
- export type SdkCommandHandler = () => void | Promise<void>;
30
- export interface SdkCommandDefinition {
31
- id: string;
32
- title?: string;
33
- disabled?: boolean;
34
- checked?: boolean;
35
- }
36
- export interface SdkMenuCommandItem {
37
- type: 'command';
38
- commandId: string;
39
- label?: string;
40
- leftIcon?: string;
41
- separatorAbove?: boolean;
42
- shortcut?: string;
43
- variant?: 'default' | 'destructive';
44
- }
45
- export interface SdkMenuSubmenuItem {
46
- type: 'submenu';
47
- label: string;
48
- leftIcon?: string;
49
- separatorAbove?: boolean;
50
- items: SdkMenuItem[];
51
- contentWidth?: string;
52
- }
53
- export type SdkMenuItem = SdkMenuCommandItem | SdkMenuSubmenuItem;
54
- export type SdkMenuRootKind = 'app' | 'menu';
55
- export interface SdkMenuRoot {
56
- label: string;
57
- kind?: SdkMenuRootKind;
58
- leftIcon?: string;
59
- items: SdkMenuItem[];
60
- contentWidth?: string;
61
- }
62
- export interface SdkSetCommandsPayload {
63
- commands: SdkCommandDefinition[];
64
- }
65
- export interface SdkSetMenuPayload {
66
- menu: SdkMenuRoot[] | null;
67
- }
68
- export interface SdkSetActiveTabPayload {
69
- activeTab: string | null;
70
- }
71
- export interface SdkCommandInvokePayload {
72
- commandId: string;
73
- }
74
- export interface SdkShortcutEventPayload {
75
- /**
76
- * Physical-key identifier (`KeyboardEvent.code`), e.g. `"KeyS"`, `"Digit1"`,
77
- * `"Comma"`. Layout- and modifier-independent. Hosts that match shortcuts
78
- * by physical key (recommended for cross-platform / cross-layout
79
- * correctness, especially macOS Option combinations) use this field.
80
- */
81
- code: string;
82
- ctrlKey: boolean;
83
- metaKey: boolean;
84
- altKey: boolean;
85
- shiftKey: boolean;
86
- }
87
- /** Mirrors the BFF `GraphEvent` SSE contract delivered by the host. */
88
- export type GraphEventType = 'CREATE' | 'REPLACE' | 'UPDATE' | 'CONNECT' | 'DISCONNECT' | 'DELETE' | 'WRITE_TIMESERIES';
89
- export interface GraphEvent {
90
- id: string;
91
- type: GraphEventType;
92
- timestamp: number;
93
- nanotime?: number;
94
- body?: Record<string, unknown>;
95
- metadata?: Record<string, unknown>;
96
- }
97
- export interface EventSubscribeRequest {
98
- /** App-generated subscription id, echoed by the host and stamped on pushes. */
99
- subscriptionId: string;
100
- /** OGIT types to subscribe to, e.g. `['ogit/Documents/Document']`. */
101
- types: string[];
102
- }
103
- export interface EventSubscribeResponse {
104
- subscriptionId: string;
105
- }
106
- export interface EventUnsubscribeRequest {
107
- subscriptionId: string;
108
- }
109
- export interface GraphEventPushPayload {
110
- subscriptionId: string;
111
- event: GraphEvent;
112
- }
113
- /** Live graph-event subscription surface exposed to hosted apps. */
114
- export interface EventsApi {
115
- /**
116
- * Subscribe to graph mutations for the given OGIT types. Returns an
117
- * unsubscribe function; the handler fires for each matching event.
118
- */
119
- subscribe(options: {
120
- types: string[];
121
- }, handler: (event: GraphEvent) => void): () => void;
122
- }
123
- export type NotifyLevel = 'success' | 'error' | 'info' | 'warning';
124
- export interface SdkNotifyPayload {
125
- message: string;
126
- level: NotifyLevel;
127
- }
128
- export interface SdkStorageGetPayload {
129
- key: string;
130
- }
131
- export interface SdkStorageSetPayload {
132
- key: string;
133
- value: unknown;
134
- }
135
- export interface SdkStorageDeletePayload {
136
- key: string;
137
- }
138
- export interface SdkKernelProxyPayload {
139
- kernelType: string;
140
- kernelPayload?: unknown;
141
- }
142
- export interface SdkTransportPayload {
143
- path: string;
144
- method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
145
- body?: unknown;
146
- contentType?: string;
147
- scopeId?: string;
148
- scopePolicy?: 'none' | 'optional' | 'required';
149
- }
150
1
  export interface TransportScopeOptions {
151
2
  scopeId?: string;
152
3
  }
153
- export interface SdkIdbGetPayload {
154
- storeName: string;
155
- key: string;
156
- }
157
- export interface SdkIdbPutPayload {
158
- storeName: string;
159
- key: string;
160
- value: unknown;
161
- }
162
- export interface SdkIdbDeletePayload {
163
- storeName: string;
164
- key: string;
165
- }
166
- export interface SdkIdbQueryPayload {
167
- storeName: string;
168
- prefix?: string;
169
- limit?: number;
170
- }
171
- /** How bridge messages travel between an app and its host. */
172
- export interface SdkChannel {
173
- /** Delivers one message to the host. */
174
- post(message: SdkMessage | SdkResponse): void;
175
- /** Hands every message from the host to `receive`; returns the unsubscribe. */
176
- subscribe(receive: (message: SdkResponse) => void): () => void;
177
- }
178
- /** Configuration passed to {@link createHostBridge}. */
179
- export interface HostBridgeConfig {
180
- appId?: string;
181
- /** Message transport. Default: `postMessage` with the parent window. */
182
- channel?: SdkChannel;
183
- /** Override the host origin for `postMessage`. Inferred from `document.referrer` by default. */
184
- targetOrigin?: string;
185
- /** Handshake timeout in ms. Default: `5000`. */
186
- connectTimeout?: number;
187
- /** Enable verbose `[app-sdk]` console output. Default: `false`. */
188
- debug?: boolean;
189
- }
190
- /** Key-value storage sandboxed to this app by the host. */
191
- export interface SdkStorage {
192
- get<T = unknown>(key: string): Promise<T | null>;
193
- set(key: string, value: unknown): Promise<void>;
194
- delete(key: string): Promise<void>;
195
- keys(): Promise<string[]>;
196
- }
197
- export interface SdkIdbEntry<T = unknown> {
198
- key: string;
199
- value: T;
200
- updatedAt: number;
201
- }
202
- export interface SdkIdbStore {
203
- get<T = unknown>(key: string): Promise<T | null>;
204
- put(key: string, value: unknown): Promise<void>;
205
- delete(key: string): Promise<void>;
206
- query<T = unknown>(options?: {
207
- prefix?: string;
208
- limit?: number;
209
- }): Promise<SdkIdbEntry<T>[]>;
210
- }
211
- export interface SdkTransport {
212
- graph: GraphTransport;
213
- os: OsTransport;
214
- backend(routeKey: string): BackendTransport;
215
- }
216
4
  /** Graph node in raw backend format (ogit/* fields) */
217
5
  export interface GraphNodeRaw {
218
6
  'ogit/_id': string;
@@ -366,41 +154,3 @@ export interface UpdateMemberInput {
366
154
  lastName?: string;
367
155
  [key: string]: unknown;
368
156
  }
369
- export interface OsRequestOptions extends TransportScopeOptions {
370
- path: string;
371
- method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
372
- body?: unknown;
373
- contentType?: string;
374
- scopePolicy?: 'none' | 'optional' | 'required';
375
- }
376
- export type BackendRequestOptions = OsRequestOptions;
377
- /** Live connection between an iframe app and its host OS. */
378
- export interface HostBridge {
379
- /** Perform the SDK handshake. Resolves with host context. Cached after first call. */
380
- connect(): Promise<SdkInitResponse>;
381
- /** Tear down the bridge and reject all pending requests. */
382
- destroy(): void;
383
- readonly connected: boolean;
384
- readonly context: SdkInitResponse | null;
385
- notify(message: string, level?: NotifyLevel): void;
386
- storage: SdkStorage;
387
- idb(storeName: string): SdkIdbStore;
388
- transport: SdkTransport;
389
- events: EventsApi;
390
- setCommands(commands: SdkCommandDefinition[]): Promise<void>;
391
- setMenu(menu: SdkMenuRoot[] | null): Promise<void>;
392
- setActiveTab(activeTab: string | null): Promise<void>;
393
- sendShortcutEvent(payload: SdkShortcutEventPayload): void;
394
- registerCommandHandlers(handlers: Record<string, SdkCommandHandler>): () => void;
395
- onOsConfigUpdate(callback: (config: OsConfig) => void): () => void;
396
- /** Forward a whitelisted message to the OS service worker. */
397
- sendToKernel<R = unknown>(kernelType: string, kernelPayload?: unknown): Promise<R>;
398
- /** Launch an app by ID in the OS. Returns the window ID if successful. */
399
- launchApp(appId: string): Promise<{
400
- windowId: string | null;
401
- }>;
402
- }
403
- /** Forward declarations for transport classes */
404
- export type GraphTransport = import('./transports/graph-transport.js').GraphTransport;
405
- export type OsTransport = import('./transports/os-transport.js').OsTransport;
406
- export type BackendTransport = import('./transports/backend-transport.js').BackendTransport;
package/dist/vite.js CHANGED
@@ -33,6 +33,7 @@ function devProxy(opts) {
33
33
  },
34
34
  };
35
35
  }
36
+ const FRAME_ANCESTORS = "frame-ancestors 'self' http://localhost:* http://127.0.0.1:*";
36
37
  const FONTS_URL_PREFIX = '/fonts/';
37
38
  const FONT_CONTENT_TYPES = {
38
39
  css: 'text/css; charset=utf-8',
@@ -203,6 +204,8 @@ export function bardiocApp(opts) {
203
204
  server: {
204
205
  port,
205
206
  allowedHosts: true,
207
+ // Only a shell on this machine may frame the dev server and receive its port.
208
+ headers: { 'Content-Security-Policy': FRAME_ANCESTORS },
206
209
  },
207
210
  preview: {
208
211
  port: previewPort ?? port + 1000,
package/dist/wire.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { type Context } from './contract.js';
2
+ /** Window messages that open a connection; everything after them travels over its port. */
3
+ export type Handshake = {
4
+ bardioc: 'ready';
5
+ protocol: number;
6
+ } | {
7
+ bardioc: 'connect';
8
+ protocol: number;
9
+ context: Context;
10
+ } | {
11
+ bardioc: 'reject';
12
+ protocol: number;
13
+ };
14
+ export declare const isHandshake: (data: unknown) => data is Handshake;
15
+ export declare const READY: Handshake;
16
+ export type Handler = (params: unknown, signal: AbortSignal) => unknown;
17
+ export interface CallOptions {
18
+ timeoutMs: number;
19
+ signal?: AbortSignal;
20
+ transfer?: Transferable[];
21
+ }
22
+ /** One side of a connection: calls the other side and answers its calls. */
23
+ export declare class Peer {
24
+ private readonly port;
25
+ private readonly handlers;
26
+ private nextId;
27
+ private closed;
28
+ private readonly pending;
29
+ private readonly running;
30
+ private readonly listeners;
31
+ constructor(port: MessagePort, handlers: ReadonlyMap<string, Handler>);
32
+ call(method: string, params: unknown, options: CallOptions): Promise<unknown>;
33
+ emit(event: string, payload: unknown): void;
34
+ on(event: string, listener: (payload: unknown) => void): () => void;
35
+ close(): void;
36
+ private post;
37
+ private receive;
38
+ private answer;
39
+ }
40
+ /** Opens a stream; its port goes to the reader, `dispose` stops it from either side. */
41
+ export declare function openStream(start: (emit: (value: unknown) => void) => () => void): {
42
+ port: MessagePort;
43
+ dispose: () => void;
44
+ };
45
+ /** Reads a stream opened by the other side; the returned function ends it. */
46
+ export declare function readStream(port: MessagePort, onValue: (value: unknown) => void): () => void;
package/dist/wire.js ADDED
@@ -0,0 +1,138 @@
1
+ import { PROTOCOL_VERSION } from './contract.js';
2
+ import { BardiocError } from './errors.js';
3
+ export const isHandshake = (data) => typeof data === 'object' && data !== null && 'bardioc' in data;
4
+ export const READY = { bardioc: 'ready', protocol: PROTOCOL_VERSION };
5
+ /** One side of a connection: calls the other side and answers its calls. */
6
+ export class Peer {
7
+ port;
8
+ handlers;
9
+ nextId = 0;
10
+ closed = false;
11
+ pending = new Map();
12
+ running = new Map();
13
+ listeners = new Map();
14
+ constructor(port, handlers) {
15
+ this.port = port;
16
+ this.handlers = handlers;
17
+ port.onmessage = ({ data }) => this.receive(data);
18
+ }
19
+ call(method, params, options) {
20
+ const { timeoutMs, signal, transfer = [] } = options;
21
+ if (this.closed)
22
+ return Promise.reject(new BardiocError('unavailable', 'Connection closed'));
23
+ if (signal?.aborted)
24
+ return Promise.reject(cancelled(method));
25
+ const id = ++this.nextId;
26
+ return new Promise((resolve, reject) => {
27
+ const settle = (done) => {
28
+ clearTimeout(timer);
29
+ signal?.removeEventListener('abort', onAbort);
30
+ this.pending.delete(id);
31
+ done();
32
+ };
33
+ const abandon = (error) => {
34
+ this.post({ t: 'cancel', id });
35
+ settle(() => reject(error));
36
+ };
37
+ const onAbort = () => abandon(cancelled(method));
38
+ const timer = setTimeout(() => abandon(new BardiocError('timeout', `${method} timed out after ${timeoutMs} ms`)), timeoutMs);
39
+ signal?.addEventListener('abort', onAbort, { once: true });
40
+ this.pending.set(id, {
41
+ resolve: value => settle(() => resolve(value)),
42
+ reject: error => settle(() => reject(BardiocError.fromWire(error, method))),
43
+ });
44
+ this.post({ t: 'call', id, m: method, p: params }, transfer);
45
+ });
46
+ }
47
+ emit(event, payload) {
48
+ this.post({ t: 'event', m: event, p: payload });
49
+ }
50
+ on(event, listener) {
51
+ const listeners = this.listeners.get(event) ?? new Set();
52
+ this.listeners.set(event, listeners.add(listener));
53
+ return () => listeners.delete(listener);
54
+ }
55
+ close() {
56
+ if (this.closed)
57
+ return;
58
+ this.closed = true;
59
+ const error = new BardiocError('unavailable', 'Connection closed').toWire();
60
+ this.pending.forEach(call => call.reject(error));
61
+ this.running.forEach(controller => controller.abort());
62
+ this.port.close();
63
+ }
64
+ post(message, transfer = []) {
65
+ if (!this.closed)
66
+ this.port.postMessage(message, transfer);
67
+ }
68
+ receive(message) {
69
+ switch (message.t) {
70
+ case 'call':
71
+ return void this.answer(message.id, message.m, message.p);
72
+ case 'result':
73
+ return this.pending.get(message.id)?.resolve(message.v);
74
+ case 'error':
75
+ return this.pending.get(message.id)?.reject(message.e);
76
+ case 'event':
77
+ return this.listeners.get(message.m)?.forEach(listener => listener(message.p));
78
+ case 'cancel':
79
+ return this.running.get(message.id)?.abort();
80
+ }
81
+ }
82
+ async answer(id, method, params) {
83
+ const handler = this.handlers.get(method);
84
+ if (!handler) {
85
+ return this.post({
86
+ t: 'error',
87
+ id,
88
+ e: { code: 'protocol', message: `Unknown method ${method}` },
89
+ });
90
+ }
91
+ const controller = new AbortController();
92
+ this.running.set(id, controller);
93
+ try {
94
+ const value = await handler(params, controller.signal);
95
+ if (controller.signal.aborted)
96
+ return;
97
+ this.post({ t: 'result', id, v: value }, value instanceof MessagePort ? [value] : []);
98
+ }
99
+ catch (error) {
100
+ if (!controller.signal.aborted) {
101
+ this.post({ t: 'error', id, e: BardiocError.from(error).toWire() });
102
+ }
103
+ }
104
+ finally {
105
+ this.running.delete(id);
106
+ }
107
+ }
108
+ }
109
+ const cancelled = (method) => new BardiocError('cancelled', `${method} was cancelled`);
110
+ /** Opens a stream; its port goes to the reader, `dispose` stops it from either side. */
111
+ export function openStream(start) {
112
+ const { port1, port2 } = new MessageChannel();
113
+ const stop = start(value => port1.postMessage({ t: 'next', v: value }));
114
+ let open = true;
115
+ const dispose = () => {
116
+ if (!open)
117
+ return;
118
+ open = false;
119
+ stop();
120
+ port1.close();
121
+ };
122
+ port1.onmessage = ({ data }) => {
123
+ if (data.t === 'end')
124
+ dispose();
125
+ };
126
+ return { port: port2, dispose };
127
+ }
128
+ /** Reads a stream opened by the other side; the returned function ends it. */
129
+ export function readStream(port, onValue) {
130
+ port.onmessage = ({ data }) => {
131
+ if (data.t === 'next')
132
+ onValue(data.v);
133
+ };
134
+ return () => {
135
+ port.postMessage({ t: 'end' });
136
+ port.close();
137
+ };
138
+ }