@agentshouse/app-bridge 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # @agentshouse/app-bridge
2
+
3
+ The renderer-side client of the agents.house App bridge. An App served by House
4
+ receives one message port before its own scripts run; this package is that
5
+ port's one listener. It carries three families:
6
+
7
+ - **Navigation** - the part of the shell's URL below the App's path, with its
8
+ query and fragment. The shell delivers it when the App starts and whenever the
9
+ User navigates; the App asks the shell to push or replace it.
10
+ - **Ceremony** - the App asks the shell to leave for one of House's own
11
+ purpose-bound authenticated pages. The shell navigates only to a same-origin
12
+ absolute path, carries its current address as the return target, and the page
13
+ returns there when it completes. An App names an address House gave it in a
14
+ reading and composes none.
15
+ - **Status** - sync state, credential state, release availability and observed
16
+ failure.
17
+
18
+ The package carries no credential handling and no Runtime family. It surfaces
19
+ the dedicated Runtime port the worker transfers over the bridge as a plain
20
+ message port, which the App hands to the MD Runtime client entry.
21
+
22
+ ## Use
23
+
24
+ ```js
25
+ import { connectHouseBridge } from '@agentshouse/app-bridge';
26
+ import { connectMdRuntime } from '@agentshouse/mdruntime/client';
27
+
28
+ const bridge = connectHouseBridge();
29
+ const md = connectMdRuntime(await bridge.runtime);
30
+
31
+ bridge.onAddress((address) => render(address));
32
+ bridge.onStatus((status) => showSync(status.sync));
33
+
34
+ open('/rooms/atlas');
35
+ function open(address) {
36
+ bridge.push(address);
37
+ }
38
+
39
+ async function manageSecurity(securityPath) {
40
+ try {
41
+ await bridge.ceremony(securityPath);
42
+ } catch (refusal) {
43
+ report(refusal);
44
+ }
45
+ }
46
+ ```
47
+
48
+ `bridge.runtime` resolves with a structural `HouseMessagePort`. A consumer whose
49
+ Runtime client entry asks for the DOM `MessagePort` type passes it through
50
+ directly at run time and casts at the type level.
51
+
52
+ ## Test without a House
53
+
54
+ ```js
55
+ import { connectHouseBridge } from '@agentshouse/app-bridge';
56
+ import { createHouseBridgeTestHost } from '@agentshouse/app-bridge/test-host';
57
+
58
+ const host = createHouseBridgeTestHost({ address: '/rooms/atlas' });
59
+ const bridge = connectHouseBridge(host.port);
60
+
61
+ bridge.push('/rooms/atlas/members');
62
+ host.open('/rooms/atlas');
63
+ ```
64
+
65
+ The host plays the shell and the worker: it delivers the address the App starts
66
+ at, records every push and replace and answers each with the address the shell
67
+ would then show, records an admitted ceremony, refuses one that is not a
68
+ same-origin absolute path, publishes a status, and attaches a Runtime port.
package/bridge.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { HouseBridgePort, HouseMessagePort, HouseStatus } from './wire.js';
2
+ export declare class HouseCeremonyRefused extends Error {
3
+ readonly path: string | null;
4
+ constructor(path: string | null);
5
+ }
6
+ export declare class HouseNavigationRefused extends Error {
7
+ readonly address: string | null;
8
+ constructor(address: string | null);
9
+ }
10
+ export interface HouseBridge {
11
+ readonly runtime: Promise<HouseMessagePort>;
12
+ address(): string | null;
13
+ onAddress(listener: (address: string) => void): () => void;
14
+ push(address: string): Promise<string>;
15
+ replace(address: string): Promise<string>;
16
+ ceremony(path: string): Promise<never>;
17
+ status(): HouseStatus | null;
18
+ onStatus(listener: (status: HouseStatus) => void): () => void;
19
+ }
20
+ export declare function connectHouseBridge(source?: Promise<HouseBridgePort>): HouseBridge;
package/bridge.js ADDED
@@ -0,0 +1,134 @@
1
+ export class HouseCeremonyRefused extends Error {
2
+ path;
3
+ constructor(path) {
4
+ super('house_ceremony_refused');
5
+ this.name = 'HouseCeremonyRefused';
6
+ this.path = path;
7
+ }
8
+ }
9
+ export class HouseNavigationRefused extends Error {
10
+ address;
11
+ constructor(address) {
12
+ super('house_navigation_refused');
13
+ this.name = 'HouseNavigationRefused';
14
+ this.address = address;
15
+ }
16
+ }
17
+ function settled() {
18
+ let settle = null;
19
+ const promise = new Promise((resolve, reject) => {
20
+ settle = { resolve, reject };
21
+ });
22
+ return { promise, settle: settle };
23
+ }
24
+ function heldBridge() {
25
+ const held = globalThis.houseBridge;
26
+ if (held === undefined)
27
+ throw new Error('house_bridge_absent');
28
+ return held;
29
+ }
30
+ export function connectHouseBridge(source = heldBridge()) {
31
+ const runtime = settled();
32
+ const ceremonies = [];
33
+ const navigations = [];
34
+ const addressListeners = new Set();
35
+ const statusListeners = new Set();
36
+ let outbox = [];
37
+ let held = null;
38
+ let address = null;
39
+ let status = null;
40
+ function send(message) {
41
+ if (held === null)
42
+ outbox?.push(message);
43
+ else
44
+ held.postMessage(message);
45
+ }
46
+ function receive(event) {
47
+ const data = event.data;
48
+ if (data === null || typeof data !== 'object')
49
+ return;
50
+ if (data.house === 'runtime') {
51
+ const [port] = event.ports;
52
+ if (port !== undefined)
53
+ runtime.settle.resolve(port);
54
+ return;
55
+ }
56
+ if (data.house === 'status') {
57
+ status = data.status;
58
+ for (const listener of statusListeners)
59
+ listener(status);
60
+ return;
61
+ }
62
+ if (data.house === 'address') {
63
+ address = String(data.address);
64
+ if (data.navigated === true)
65
+ navigations.shift()?.settle.resolve(address);
66
+ for (const listener of addressListeners)
67
+ listener(address);
68
+ return;
69
+ }
70
+ if (data.house === 'navigate-refused') {
71
+ const refused = data.address;
72
+ const named = typeof refused === 'string' ? refused : null;
73
+ const index = named === null ? 0 : navigations.findIndex((one) => one.address === named);
74
+ const [pending] = navigations.splice(index < 0 ? 0 : index, 1);
75
+ pending?.settle.reject(new HouseNavigationRefused(named));
76
+ return;
77
+ }
78
+ if (data.house === 'ceremony-refused') {
79
+ const refused = data.path;
80
+ const named = typeof refused === 'string' ? refused : null;
81
+ const index = named === null ? 0 : ceremonies.findIndex((one) => one.path === named);
82
+ const [pending] = ceremonies.splice(index < 0 ? 0 : index, 1);
83
+ pending?.settle.reject(new HouseCeremonyRefused(named));
84
+ }
85
+ }
86
+ void source.then((port) => {
87
+ held = port;
88
+ port.addEventListener('message', receive);
89
+ port.start();
90
+ const waiting = outbox ?? [];
91
+ outbox = null;
92
+ for (const message of waiting)
93
+ port.postMessage(message);
94
+ });
95
+ function navigate(mode, asked) {
96
+ const pending = settled();
97
+ navigations.push({ address: asked, settle: pending.settle });
98
+ send({ house: 'navigate', mode, address: asked });
99
+ return pending.promise;
100
+ }
101
+ return {
102
+ runtime: runtime.promise,
103
+ address() {
104
+ return address;
105
+ },
106
+ onAddress(listener) {
107
+ addressListeners.add(listener);
108
+ return () => {
109
+ addressListeners.delete(listener);
110
+ };
111
+ },
112
+ push(asked) {
113
+ return navigate('push', asked);
114
+ },
115
+ replace(asked) {
116
+ return navigate('replace', asked);
117
+ },
118
+ ceremony(path) {
119
+ const pending = settled();
120
+ ceremonies.push({ path, settle: pending.settle });
121
+ send({ house: 'ceremony', path });
122
+ return pending.promise;
123
+ },
124
+ status() {
125
+ return status;
126
+ },
127
+ onStatus(listener) {
128
+ statusListeners.add(listener);
129
+ return () => {
130
+ statusListeners.delete(listener);
131
+ };
132
+ },
133
+ };
134
+ }
package/index.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { connectHouseBridge, HouseCeremonyRefused, HouseNavigationRefused, type HouseBridge, } from './bridge.js';
2
+ export { HOUSE_ADDRESS_MAX_CHARS } from './wire.js';
3
+ export type { HouseBridgeEvent, HouseBridgePort, HouseCredentialState, HouseMessagePort, HouseNavigationMode, HouseStatus, HouseSyncState, } from './wire.js';
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { connectHouseBridge, HouseCeremonyRefused, HouseNavigationRefused, } from './bridge.js';
2
+ export { HOUSE_ADDRESS_MAX_CHARS } from './wire.js';
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@agentshouse/app-bridge",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Renderer-side client of the agents.house App bridge: navigation, ceremonies and status, with an in-page test host.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "engines": {
9
+ "node": ">=24"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./index.d.ts",
14
+ "default": "./index.js"
15
+ },
16
+ "./test-host": {
17
+ "types": "./test-host.d.ts",
18
+ "default": "./test-host.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "*.js",
23
+ "*.d.ts",
24
+ "README.md"
25
+ ]
26
+ }
package/test-host.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { HouseBridgePort, HouseMessagePort, HouseNavigationMode, HouseStatus } from './wire.js';
2
+ export interface HouseBridgeNavigation {
3
+ readonly mode: HouseNavigationMode;
4
+ readonly address: string;
5
+ }
6
+ export interface HouseBridgeTestHost {
7
+ readonly port: Promise<HouseBridgePort>;
8
+ readonly navigations: readonly HouseBridgeNavigation[];
9
+ readonly ceremonies: readonly string[];
10
+ address(): string;
11
+ open(address: string): void;
12
+ publish(status: HouseStatus): void;
13
+ attachRuntime(runtime: HouseMessagePort): void;
14
+ }
15
+ export interface HouseBridgeTestHostOptions {
16
+ readonly address?: string;
17
+ readonly status?: HouseStatus;
18
+ }
19
+ export declare function createHouseBridgeTestHost(options?: HouseBridgeTestHostOptions): HouseBridgeTestHost;
package/test-host.js ADDED
@@ -0,0 +1,106 @@
1
+ import { HOUSE_ADDRESS_MAX_CHARS } from './wire.js';
2
+ const HOST_ORIGIN = 'https://house.invalid';
3
+ function sameOriginPath(value) {
4
+ if (typeof value !== 'string')
5
+ return null;
6
+ if (value.charAt(0) !== '/' || value.slice(0, 2) === '//')
7
+ return null;
8
+ if (value.length > HOUSE_ADDRESS_MAX_CHARS)
9
+ return null;
10
+ const target = new URL(value, HOST_ORIGIN);
11
+ return target.origin === HOST_ORIGIN ? `${target.pathname}${target.search}${target.hash}` : null;
12
+ }
13
+ export function createHouseBridgeTestHost(options = {}) {
14
+ const navigations = [];
15
+ const ceremonies = [];
16
+ const listeners = new Set();
17
+ const queued = [];
18
+ let started = false;
19
+ let address = sameOriginPath(options.address ?? '/') ?? '/';
20
+ function deliver(data, ports = []) {
21
+ const event = { data, ports };
22
+ if (!started) {
23
+ queued.push(event);
24
+ return;
25
+ }
26
+ queueMicrotask(() => {
27
+ for (const listener of listeners)
28
+ listener(event);
29
+ });
30
+ }
31
+ function arrive(target, navigated) {
32
+ address = target;
33
+ deliver({ house: 'address', address, navigated });
34
+ }
35
+ function navigate(mode, asked) {
36
+ const target = sameOriginPath(asked);
37
+ if (target === null) {
38
+ deliver({
39
+ house: 'navigate-refused',
40
+ address: typeof asked === 'string' ? asked : null,
41
+ });
42
+ return;
43
+ }
44
+ navigations.push({ mode: mode === 'replace' ? 'replace' : 'push', address: target });
45
+ arrive(target, true);
46
+ }
47
+ function ceremony(path) {
48
+ const target = sameOriginPath(path);
49
+ if (target === null) {
50
+ deliver({ house: 'ceremony-refused', path: typeof path === 'string' ? path : null });
51
+ return;
52
+ }
53
+ ceremonies.push(target);
54
+ }
55
+ function receive(message) {
56
+ const data = message;
57
+ if (data === null || typeof data !== 'object')
58
+ return;
59
+ if (data.house === 'navigate') {
60
+ navigate(data.mode, data.address);
61
+ }
62
+ else if (data.house === 'ceremony') {
63
+ ceremony(data.path);
64
+ }
65
+ }
66
+ const port = {
67
+ postMessage(message) {
68
+ receive(message);
69
+ },
70
+ addEventListener(_type, listener) {
71
+ listeners.add(listener);
72
+ },
73
+ start() {
74
+ started = true;
75
+ const held = queued.splice(0);
76
+ queueMicrotask(() => {
77
+ for (const event of held) {
78
+ for (const listener of listeners)
79
+ listener(event);
80
+ }
81
+ });
82
+ },
83
+ };
84
+ if (options.status !== undefined)
85
+ deliver({ house: 'status', status: options.status });
86
+ deliver({ house: 'address', address, navigated: false });
87
+ return {
88
+ port: Promise.resolve(port),
89
+ navigations,
90
+ ceremonies,
91
+ address() {
92
+ return address;
93
+ },
94
+ open(asked) {
95
+ const target = sameOriginPath(asked);
96
+ if (target !== null)
97
+ arrive(target, false);
98
+ },
99
+ publish(status) {
100
+ deliver({ house: 'status', status });
101
+ },
102
+ attachRuntime(runtime) {
103
+ deliver({ house: 'runtime' }, [runtime]);
104
+ },
105
+ };
106
+ }
package/wire.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ export interface HouseBridgeEvent {
2
+ readonly data: unknown;
3
+ readonly ports: readonly HouseMessagePort[];
4
+ }
5
+ export interface HouseMessagePort {
6
+ postMessage(message: unknown): void;
7
+ addEventListener(type: 'message', listener: (event: HouseBridgeEvent) => void): void;
8
+ start(): void;
9
+ }
10
+ export type HouseBridgePort = HouseMessagePort;
11
+ export interface HouseSyncState {
12
+ readonly state: string;
13
+ readonly providerUnreachable: boolean;
14
+ }
15
+ export interface HouseCredentialState {
16
+ readonly state: 'none' | 'held' | 'rejected';
17
+ readonly expires_at: number | null;
18
+ }
19
+ export interface HouseStatus {
20
+ readonly runtime: string;
21
+ readonly credential: HouseCredentialState;
22
+ readonly sync: HouseSyncState | null;
23
+ readonly refusal: {
24
+ readonly reason: string;
25
+ } | null;
26
+ readonly loss: string | null;
27
+ readonly release: unknown;
28
+ }
29
+ export type HouseNavigationMode = 'push' | 'replace';
30
+ export declare const HOUSE_ADDRESS_MAX_CHARS = 1000;
package/wire.js ADDED
@@ -0,0 +1 @@
1
+ export const HOUSE_ADDRESS_MAX_CHARS = 1000;