@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 +68 -0
- package/bridge.d.ts +20 -0
- package/bridge.js +134 -0
- package/index.d.ts +3 -0
- package/index.js +2 -0
- package/package.json +26 -0
- package/test-host.d.ts +19 -0
- package/test-host.js +106 -0
- package/wire.d.ts +30 -0
- package/wire.js +1 -0
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
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;
|