@agentshouse/app-bridge 0.1.0-alpha.8 → 0.1.0-c07e1058c2cee

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anton (trof3039)
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
@@ -2,61 +2,24 @@
2
2
 
3
3
  The renderer-side client of the agents.house App bridge. An App served by House
4
4
  receives one message port before its own scripts run; this package is that
5
- port's one listener. It carries six families:
5
+ port's one listener. It carries eleven families: navigation, ceremonies,
6
+ installation, notifications, status, Site image reads, App release code,
7
+ external calls, App messages and App and Site screenshot reads.
8
+ [docs/families.md](docs/families.md) describes each and the test host.
6
9
 
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. The App also
10
- sets its tab's title and icon with `setTab({ title, icon })`, either or both:
11
- a title of at most 200 characters and an icon given as a base64 `data:` URL
12
- of an SVG, PNG, GIF or WebP image of at most 1,048,576 characters. The shell
13
- shows them over the App's display name, or a Site's title, and its tab icon
14
- until the next load, and ignores a request outside those bounds.
15
- - **Ceremony** - the App asks the shell to leave for one of House's own
16
- purpose-bound authenticated pages. The shell navigates only to a same-origin
17
- absolute path, carries its current address as the return target, and the page
18
- returns there when it completes. An App names an address House gave it in a
19
- reading and composes none.
20
- - **Status** - the App reference the Runtime port is attached under, sync
21
- state, credential state, release availability and observed failure, `ga`:
22
- whether the page measures and the visitor's stored analytics choice, and
23
- `service_mode`: `open`, `write-halted` while House refuses writes, so the
24
- App can stop retrying them, or `null` while the runtime holds no House
25
- connection.
26
- The App reports the visitor's choice with `gaConsent('accept')` or
27
- `gaConsent('decline')`; the shell hands it to House's analytics loader, and
28
- the choice never reaches House.
29
- - **Site image read** - given a Published Room and an original attachment
30
- reference, it answers the web copy's path and metadata, or `null` when no
31
- copy exists. The original bytes never cross this bridge at a Site.
32
- - **App release code** - `sourceTop(app, release)` answers the top level of
33
- the latest successful release of an App the User may use: its folders, its
34
- files with their sizes and its root README's text, or `null` for none;
35
- `sourceDownload(app, release)` answers every file of that release with its
36
- path, its size and its `text`, or its bytes as `base64`. The browser keeps
37
- each answer per App and release: a read naming a release it holds answers
38
- without asking House, and keeping a newer release drops the earlier one. A
39
- refusal is House's `{ error: { code } }`, `app_release_not_found` for an
40
- App with no release; a read House does not answer, or one that outlived
41
- the browser's signed-in User, is `null`.
42
- - **App screenshot read** - given an App reference, `readScreenshot(app)`
43
- answers the image this browser captured of that App's screen the first time
44
- it ran its current release, as a PNG `Blob` at most 1200 pixels wide, or
45
- `null` when this browser holds none. The image stays in the browser; a Site
46
- holds none.
10
+ The package carries no credential handling and no Runtime family. House builds
11
+ the MD Runtime client into the page beside the bridge, and `bridge.runtime`
12
+ resolves to that client, so the App imports and constructs none.
47
13
 
48
- The package carries no credential handling and no Runtime family. It surfaces
49
- the dedicated Runtime port the worker transfers over the bridge as a plain
50
- message port, which the App hands to the MD Runtime client entry.
14
+ House's `find_command` "App" is the guide to building an App or a Site.
51
15
 
52
16
  ## Use
53
17
 
54
18
  ```js
55
19
  import { connectHouseBridge } from '@agentshouse/app-bridge';
56
- import { connectMdRuntime } from '@agentshouse/mdruntime/client';
57
20
 
58
21
  const bridge = connectHouseBridge();
59
- const md = connectMdRuntime(await bridge.runtime);
22
+ const md = await bridge.runtime;
60
23
 
61
24
  bridge.onAddress((address) => render(address));
62
25
  bridge.onStatus((status) => showSync(status.sync));
@@ -76,9 +39,8 @@ async function manageSecurity(securityPath) {
76
39
  }
77
40
  ```
78
41
 
79
- `bridge.runtime` resolves with a structural `HouseMessagePort`. A consumer whose
80
- Runtime client entry asks for the DOM `MessagePort` type passes it through
81
- directly at run time and casts at the type level.
42
+ `bridge.runtime` resolves to the `MdRuntimeClient` of
43
+ `@agentshouse/mdruntime/client`, whose README describes its members.
82
44
 
83
45
  ## Test without a House
84
46
 
@@ -87,22 +49,8 @@ import { connectHouseBridge } from '@agentshouse/app-bridge';
87
49
  import { createHouseBridgeTestHost } from '@agentshouse/app-bridge/test-host';
88
50
 
89
51
  const host = createHouseBridgeTestHost({ address: '/rooms/atlas' });
90
- const bridge = connectHouseBridge(host.port);
52
+ const bridge = connectHouseBridge(host);
91
53
 
92
54
  bridge.push('/rooms/atlas/members');
93
55
  host.open('/rooms/atlas');
94
56
  ```
95
-
96
- The host plays the shell and the worker: it delivers the address the App starts
97
- at and each address `open` plays as Back or Forward through the shell's own
98
- slice below the App's path, admitting nothing, so it delivers `//notes` exactly
99
- as a browser at `/apps/<app>//notes` does. It records every push and replace
100
- and answers each with the address the shell would then show, records an
101
- admitted ceremony, refuses a push, replace or ceremony that is not a
102
- same-origin absolute path, publishes a status, its `service_mode` included,
103
- records in `consents` each
104
- `gaConsent` it takes while that status measures and publishes the status the
105
- choice leaves, records in `tabs` each `setTab` the shell would take and ignores
106
- any other, answers a release code read from the `sources` it was given, or
107
- `null`, answers `readScreenshot` from the `screenshots` it was given by App
108
- reference, and attaches a Runtime port.
@@ -0,0 +1,38 @@
1
+ import type { MdRuntimeClient } from '@agentshouse/mdruntime/client';
2
+ import type { HouseAppMessage, HouseExternalAnswer, HouseExternalRequest, HouseBridgePort, HouseGaChoice, HouseImageCopy, HouseInstallation, HouseNotifications, HouseRefusal, HouseSourceDownload, HouseSourceTop, HouseStatus, HouseTab } from './wire.js';
3
+ export type { HouseImageCopy } from './wire.js';
4
+ export declare class HouseCeremonyRefused extends Error {
5
+ readonly path: string | null;
6
+ constructor(path: string | null);
7
+ }
8
+ export declare class HouseNavigationRefused extends Error {
9
+ readonly address: string | null;
10
+ constructor(address: string | null);
11
+ }
12
+ export interface HouseBridgeSource {
13
+ readonly port: Promise<HouseBridgePort>;
14
+ readonly runtime: Promise<MdRuntimeClient>;
15
+ }
16
+ export interface HouseBridge {
17
+ readonly runtime: Promise<MdRuntimeClient>;
18
+ address(): string | null;
19
+ onAddress(listener: (address: string) => void): () => void;
20
+ push(address: string): Promise<string>;
21
+ replace(address: string): Promise<string>;
22
+ ceremony(path: string): Promise<never>;
23
+ install(): Promise<HouseInstallation>;
24
+ notifications(on?: boolean): Promise<HouseNotifications>;
25
+ setBadge(unread: readonly string[]): Promise<void>;
26
+ setTab(tab: HouseTab): void;
27
+ readAttachment(room: string, attachment: string): Promise<HouseImageCopy | null>;
28
+ sourceTop(app: string, release?: string): Promise<HouseSourceTop | HouseRefusal | null>;
29
+ sourceDownload(app: string, release?: string): Promise<HouseSourceDownload | HouseRefusal | null>;
30
+ callExternal(request: HouseExternalRequest): Promise<HouseExternalAnswer | HouseRefusal | null>;
31
+ readScreenshot(app: string): Promise<Blob | null>;
32
+ readSiteScreenshot(site: string): Promise<Blob | null>;
33
+ status(): HouseStatus | null;
34
+ onStatus(listener: (status: HouseStatus) => void): () => void;
35
+ messages(listener: (message: HouseAppMessage) => void): () => void;
36
+ gaConsent(choice: HouseGaChoice): void;
37
+ }
38
+ export declare function connectHouseBridge(source?: HouseBridgeSource): HouseBridge;
@@ -14,6 +14,46 @@ export class HouseNavigationRefused extends Error {
14
14
  this.address = address;
15
15
  }
16
16
  }
17
+ function pendingAnswer(pending, answer) {
18
+ const asked = pending.get(answer.id);
19
+ if (asked === undefined)
20
+ return null;
21
+ pending.delete(answer.id);
22
+ if (answer.refused === true) {
23
+ asked.reject(new HouseCeremonyRefused(null));
24
+ return null;
25
+ }
26
+ if (answer.refusal !== undefined) {
27
+ asked.reject(new DOMException(answer.refusal.message, answer.refusal.name));
28
+ return null;
29
+ }
30
+ return asked;
31
+ }
32
+ const FRAME_HOUSES = [
33
+ 'runtime',
34
+ 'status',
35
+ 'screenshot',
36
+ 'address',
37
+ 'ceremony-refused',
38
+ 'navigate-refused',
39
+ 'attachment',
40
+ 'source',
41
+ 'external-call',
42
+ 'install',
43
+ 'notifications',
44
+ 'badge',
45
+ 'app-message',
46
+ ];
47
+ function frameMessage(value) {
48
+ if (value === null || typeof value !== 'object')
49
+ return null;
50
+ const message = value;
51
+ if (typeof message.house !== 'string')
52
+ return null;
53
+ if (!FRAME_HOUSES.includes(message.house))
54
+ return null;
55
+ return value;
56
+ }
17
57
  function settled() {
18
58
  let settle = null;
19
59
  const promise = new Promise((resolve, reject) => {
@@ -22,23 +62,31 @@ function settled() {
22
62
  return { promise, settle: settle };
23
63
  }
24
64
  function heldBridge() {
25
- const held = globalThis.houseBridge;
26
- if (held === undefined)
65
+ const held = globalThis;
66
+ if (held.houseBridge === undefined || held.houseRuntime === undefined)
27
67
  throw new Error('house_bridge_absent');
28
- return held;
68
+ return { port: held.houseBridge, runtime: held.houseRuntime };
29
69
  }
30
70
  export function connectHouseBridge(source = heldBridge()) {
31
- const runtime = settled();
32
71
  const ceremonies = [];
33
72
  const navigations = [];
34
73
  const attachments = new Map();
35
74
  let nextAttachment = 0;
36
75
  const sources = new Map();
37
76
  let nextSource = 0;
77
+ const installations = new Map();
78
+ let nextInstallation = 0;
79
+ const notifications = new Map();
80
+ let nextNotifications = 0;
81
+ const badges = new Map();
82
+ let nextBadge = 0;
38
83
  const screenshots = new Map();
39
84
  let nextScreenshot = 0;
85
+ const externals = new Map();
86
+ let nextExternal = 0;
40
87
  const addressListeners = new Set();
41
88
  const statusListeners = new Set();
89
+ const messageListeners = new Set();
42
90
  let outbox = [];
43
91
  let held = null;
44
92
  let address = null;
@@ -50,21 +98,21 @@ export function connectHouseBridge(source = heldBridge()) {
50
98
  held.postMessage(message);
51
99
  }
52
100
  function receive(event) {
53
- const data = event.data;
54
- if (data === null || typeof data !== 'object')
55
- return;
56
- if (data.house === 'runtime') {
57
- const [port] = event.ports;
58
- if (port !== undefined)
59
- runtime.settle.resolve(port);
101
+ const data = frameMessage(event.data);
102
+ if (data === null)
60
103
  return;
61
- }
62
104
  if (data.house === 'status') {
63
105
  status = data.status;
64
106
  for (const listener of statusListeners)
65
107
  listener(status);
66
108
  return;
67
109
  }
110
+ if (data.house === 'app-message') {
111
+ const { from, body } = data;
112
+ for (const listener of messageListeners)
113
+ listener({ from, body });
114
+ return;
115
+ }
68
116
  if (data.house === 'address') {
69
117
  address = String(data.address);
70
118
  if (data.navigated === true)
@@ -82,36 +130,59 @@ export function connectHouseBridge(source = heldBridge()) {
82
130
  return;
83
131
  }
84
132
  if (data.house === 'attachment') {
85
- const answer = data;
86
- if (typeof answer.id !== 'number')
133
+ if (typeof data.id !== 'number')
87
134
  return;
88
- const pending = attachments.get(answer.id);
135
+ const pending = attachments.get(data.id);
89
136
  if (pending === undefined)
90
137
  return;
91
- attachments.delete(answer.id);
92
- pending.resolve(answer.image ?? null);
138
+ attachments.delete(data.id);
139
+ pending.resolve(data.image ?? null);
140
+ return;
141
+ }
142
+ if (data.house === 'install') {
143
+ const pending = pendingAnswer(installations, data);
144
+ if (pending !== null && 'outcome' in data)
145
+ pending.resolve(data.outcome);
146
+ return;
147
+ }
148
+ if (data.house === 'notifications') {
149
+ const pending = pendingAnswer(notifications, data);
150
+ if (pending !== null && 'state' in data)
151
+ pending.resolve(data.state);
152
+ return;
153
+ }
154
+ if (data.house === 'badge') {
155
+ pendingAnswer(badges, data)?.resolve();
93
156
  return;
94
157
  }
95
158
  if (data.house === 'source') {
96
- const answer = data;
97
- if (typeof answer.id !== 'number')
159
+ if (typeof data.id !== 'number')
160
+ return;
161
+ const pending = sources.get(data.id);
162
+ if (pending === undefined)
163
+ return;
164
+ sources.delete(data.id);
165
+ pending.resolve(data.answer ?? null);
166
+ return;
167
+ }
168
+ if (data.house === 'external-call') {
169
+ if (typeof data.id !== 'number')
98
170
  return;
99
- const pending = sources.get(answer.id);
171
+ const pending = externals.get(data.id);
100
172
  if (pending === undefined)
101
173
  return;
102
- sources.delete(answer.id);
103
- pending.resolve(answer.answer ?? null);
174
+ externals.delete(data.id);
175
+ pending.resolve(data.answer ?? null);
104
176
  return;
105
177
  }
106
178
  if (data.house === 'screenshot') {
107
- const answer = data;
108
- if (typeof answer.id !== 'number')
179
+ if (typeof data.id !== 'number')
109
180
  return;
110
- const pending = screenshots.get(answer.id);
181
+ const pending = screenshots.get(data.id);
111
182
  if (pending === undefined)
112
183
  return;
113
- screenshots.delete(answer.id);
114
- pending.resolve(answer.image ?? null);
184
+ screenshots.delete(data.id);
185
+ pending.resolve(data.image ?? null);
115
186
  return;
116
187
  }
117
188
  if (data.house === 'ceremony-refused') {
@@ -122,7 +193,7 @@ export function connectHouseBridge(source = heldBridge()) {
122
193
  pending?.settle.reject(new HouseCeremonyRefused(named));
123
194
  }
124
195
  }
125
- void source.then((port) => {
196
+ void source.port.then((port) => {
126
197
  held = port;
127
198
  port.addEventListener('message', receive);
128
199
  port.start();
@@ -138,6 +209,16 @@ export function connectHouseBridge(source = heldBridge()) {
138
209
  send({ house, id, app, release });
139
210
  return pending.promise;
140
211
  }
212
+ function screenshot(house, asked) {
213
+ const pending = settled();
214
+ const id = ++nextScreenshot;
215
+ screenshots.set(id, pending.settle);
216
+ if (house === 'screenshot-read' && 'app' in asked)
217
+ send({ house, id, app: asked.app });
218
+ if (house === 'site-screenshot-read' && 'site' in asked)
219
+ send({ house, id, site: asked.site });
220
+ return pending.promise;
221
+ }
141
222
  function navigate(mode, asked) {
142
223
  const pending = settled();
143
224
  navigations.push({ address: asked, settle: pending.settle });
@@ -145,7 +226,7 @@ export function connectHouseBridge(source = heldBridge()) {
145
226
  return pending.promise;
146
227
  }
147
228
  return {
148
- runtime: runtime.promise,
229
+ runtime: source.runtime,
149
230
  address() {
150
231
  return address;
151
232
  },
@@ -167,6 +248,27 @@ export function connectHouseBridge(source = heldBridge()) {
167
248
  send({ house: 'ceremony', path });
168
249
  return pending.promise;
169
250
  },
251
+ install() {
252
+ const pending = settled();
253
+ const id = ++nextInstallation;
254
+ installations.set(id, pending.settle);
255
+ send({ house: 'install', id });
256
+ return pending.promise;
257
+ },
258
+ notifications(on) {
259
+ const pending = settled();
260
+ const id = ++nextNotifications;
261
+ notifications.set(id, pending.settle);
262
+ send({ house: 'notifications', id, on });
263
+ return pending.promise;
264
+ },
265
+ setBadge(unread) {
266
+ const pending = settled();
267
+ const id = ++nextBadge;
268
+ badges.set(id, pending.settle);
269
+ send({ house: 'badge', id, unread });
270
+ return pending.promise;
271
+ },
170
272
  setTab(tab) {
171
273
  send({ house: 'tab', title: tab.title, icon: tab.icon });
172
274
  },
@@ -183,13 +285,19 @@ export function connectHouseBridge(source = heldBridge()) {
183
285
  sourceDownload(app, release) {
184
286
  return readSource('source-download', app, release);
185
287
  },
186
- readScreenshot(app) {
288
+ callExternal(request) {
187
289
  const pending = settled();
188
- const id = ++nextScreenshot;
189
- screenshots.set(id, pending.settle);
190
- send({ house: 'screenshot-read', id, app });
290
+ const id = ++nextExternal;
291
+ externals.set(id, pending.settle);
292
+ send({ house: 'external-call', id, request });
191
293
  return pending.promise;
192
294
  },
295
+ readScreenshot(app) {
296
+ return screenshot('screenshot-read', { app });
297
+ },
298
+ readSiteScreenshot(site) {
299
+ return screenshot('site-screenshot-read', { site });
300
+ },
193
301
  status() {
194
302
  return status;
195
303
  },
@@ -199,6 +307,12 @@ export function connectHouseBridge(source = heldBridge()) {
199
307
  statusListeners.delete(listener);
200
308
  };
201
309
  },
310
+ messages(listener) {
311
+ messageListeners.add(listener);
312
+ return () => {
313
+ messageListeners.delete(listener);
314
+ };
315
+ },
202
316
  gaConsent(choice) {
203
317
  send({ house: 'ga/consent', choice });
204
318
  },
@@ -0,0 +1,3 @@
1
+ export { connectHouseBridge, HouseCeremonyRefused, HouseNavigationRefused, type HouseBridge, type HouseBridgeSource, type HouseImageCopy, } from './bridge.js';
2
+ export { HOUSE_ADDRESS_MAX_CHARS } from './wire.js';
3
+ export type { HouseAppMessage, HouseBridgeEvent, HouseBridgePort, HouseCredentialState, HouseExternalAnswer, HouseExternalRequest, HouseGa, HouseGaChoice, HouseNavigationMode, HouseRefusal, HouseSourceDownload, HouseSourceFile, HouseSourceTop, HouseStatus, HouseSyncState, } from './wire.js';
@@ -1,19 +1,21 @@
1
- import type { HouseImageCopy } from './bridge.js';
2
- import type { HouseBridgePort, HouseGaChoice, HouseMessagePort, HouseNavigationMode, HouseSourceDownload, HouseSourceTop, HouseStatus, HouseTab } from './wire.js';
1
+ import type { MdRuntimeClient } from '@agentshouse/mdruntime/client';
2
+ import type { HouseBridgeSource, HouseImageCopy } from './bridge.js';
3
+ import type { HouseAppMessage, HouseExternalAnswer, HouseExternalRequest, HouseGaChoice, HouseNavigationMode, HouseRefusal, HouseSourceDownload, HouseSourceTop, HouseStatus, HouseTab } from './wire.js';
3
4
  export interface HouseBridgeNavigation {
4
5
  readonly mode: HouseNavigationMode;
5
6
  readonly address: string;
6
7
  }
7
- export interface HouseBridgeTestHost {
8
- readonly port: Promise<HouseBridgePort>;
8
+ export interface HouseBridgeTestHost extends HouseBridgeSource {
9
9
  readonly navigations: readonly HouseBridgeNavigation[];
10
10
  readonly ceremonies: readonly string[];
11
11
  readonly consents: readonly HouseGaChoice[];
12
12
  readonly tabs: readonly HouseTab[];
13
+ readonly externalCalls: readonly HouseExternalRequest[];
13
14
  address(): string;
14
15
  open(address: string): void;
15
16
  publish(status: HouseStatus): void;
16
- attachRuntime(runtime: HouseMessagePort): void;
17
+ message(message: HouseAppMessage): void;
18
+ attachRuntime(runtime: MdRuntimeClient): void;
17
19
  }
18
20
  export interface HouseBridgeTestHostOptions {
19
21
  readonly address?: string;
@@ -26,5 +28,7 @@ export interface HouseBridgeTestHostOptions {
26
28
  readonly download: HouseSourceDownload;
27
29
  }[];
28
30
  readonly screenshots?: Readonly<Record<string, Blob>>;
31
+ readonly siteScreenshots?: Readonly<Record<string, Blob>>;
32
+ readonly external?: (request: HouseExternalRequest) => HouseExternalAnswer | HouseRefusal | null | Promise<HouseExternalAnswer | HouseRefusal | null>;
29
33
  }
30
34
  export declare function createHouseBridgeTestHost(options?: HouseBridgeTestHostOptions): HouseBridgeTestHost;
@@ -17,13 +17,18 @@ export function createHouseBridgeTestHost(options = {}) {
17
17
  const ceremonies = [];
18
18
  const consents = [];
19
19
  const tabs = [];
20
+ const externalCalls = [];
20
21
  const listeners = new Set();
21
22
  const queued = [];
22
23
  let started = false;
23
24
  let address = appAddressOf('/', options.address ?? '/');
24
25
  let status = options.status ?? null;
25
- function deliver(data, ports = []) {
26
- const event = { data, ports };
26
+ let attached = () => { };
27
+ const runtime = new Promise((resolve) => {
28
+ attached = resolve;
29
+ });
30
+ function deliver(data) {
31
+ const event = { data };
27
32
  if (!started) {
28
33
  queued.push(event);
29
34
  return;
@@ -79,6 +84,15 @@ export function createHouseBridgeTestHost(options = {}) {
79
84
  else if (data.house === 'ceremony') {
80
85
  ceremony(data.path);
81
86
  }
87
+ else if (data.house === 'install') {
88
+ deliver({ house: 'install', id: data.id, outcome: 'manual' });
89
+ }
90
+ else if (data.house === 'notifications') {
91
+ deliver({ house: 'notifications', id: data.id, state: 'unavailable' });
92
+ }
93
+ else if (data.house === 'badge') {
94
+ deliver({ house: 'badge', id: data.id });
95
+ }
82
96
  else if (data.house === 'tab') {
83
97
  const change = tabChange(data);
84
98
  if (change !== null)
@@ -109,11 +123,21 @@ export function createHouseBridgeTestHost(options = {}) {
109
123
  },
110
124
  });
111
125
  }
126
+ else if (data.house === 'external-call') {
127
+ const asked = data;
128
+ externalCalls.push(asked.request);
129
+ void Promise.resolve(options.external?.(asked.request) ?? null).then((answer) => deliver({ house: 'external-call', id: asked.id, answer }));
130
+ }
112
131
  else if (data.house === 'screenshot-read') {
113
132
  const asked = data;
114
133
  const held = typeof asked.app === 'string' ? options.screenshots?.[asked.app] : undefined;
115
134
  deliver({ house: 'screenshot', id: asked.id, image: held ?? null });
116
135
  }
136
+ else if (data.house === 'site-screenshot-read') {
137
+ const asked = data;
138
+ const held = typeof asked.site === 'string' ? options.siteScreenshots?.[asked.site] : undefined;
139
+ deliver({ house: 'screenshot', id: asked.id, image: held ?? null });
140
+ }
117
141
  }
118
142
  const port = {
119
143
  postMessage(message) {
@@ -138,10 +162,12 @@ export function createHouseBridgeTestHost(options = {}) {
138
162
  deliver({ house: 'address', address, navigated: false });
139
163
  return {
140
164
  port: Promise.resolve(port),
165
+ runtime,
141
166
  navigations,
142
167
  ceremonies,
143
168
  consents,
144
169
  tabs,
170
+ externalCalls,
145
171
  address() {
146
172
  return address;
147
173
  },
@@ -149,8 +175,11 @@ export function createHouseBridgeTestHost(options = {}) {
149
175
  arrive(appAddressOf('/', asked), false);
150
176
  },
151
177
  publish,
152
- attachRuntime(runtime) {
153
- deliver({ house: 'runtime' }, [runtime]);
178
+ message(message) {
179
+ deliver({ house: 'app-message', from: message.from, body: message.body });
180
+ },
181
+ attachRuntime(client) {
182
+ attached(client);
154
183
  },
155
184
  };
156
185
  }
@@ -0,0 +1,215 @@
1
+ import type { TabChange } from '../../src/apps/tab.js';
2
+ export interface HouseBridgeEvent {
3
+ readonly data: unknown;
4
+ }
5
+ export interface HouseBridgePort {
6
+ postMessage(message: HouseFrameRequest): void;
7
+ addEventListener(type: 'message', listener: (event: HouseBridgeEvent) => void): void;
8
+ start(): void;
9
+ }
10
+ export interface HouseSyncState {
11
+ readonly state: string;
12
+ readonly providerUnreachable: boolean;
13
+ }
14
+ export interface HouseCredentialState {
15
+ readonly state: 'none' | 'held' | 'rejected';
16
+ readonly expires_at: number | null;
17
+ }
18
+ export type HouseGaChoice = 'accept' | 'decline';
19
+ export interface HouseGa {
20
+ readonly measures: boolean;
21
+ readonly choice: HouseGaChoice | null;
22
+ }
23
+ export interface HouseStatus {
24
+ readonly app: string | null;
25
+ readonly preferences: {
26
+ readonly room_ref: string;
27
+ readonly path: string;
28
+ } | null;
29
+ readonly runtime: string;
30
+ readonly credential: HouseCredentialState;
31
+ readonly sync: HouseSyncState | null;
32
+ readonly refusal: {
33
+ readonly reason: string;
34
+ } | null;
35
+ readonly loss: string | null;
36
+ readonly ga: HouseGa;
37
+ readonly service_mode: 'open' | 'write-halted' | null;
38
+ readonly offline: boolean;
39
+ }
40
+ export type HouseNavigationMode = 'push' | 'replace';
41
+ interface HouseRelease {
42
+ readonly app_ref: string;
43
+ readonly release: string;
44
+ }
45
+ export interface HouseSourceTop extends HouseRelease {
46
+ readonly folders: readonly string[];
47
+ readonly files: readonly {
48
+ readonly name: string;
49
+ readonly size: number;
50
+ }[];
51
+ readonly readme: string | null;
52
+ }
53
+ export type HouseSourceFile = {
54
+ readonly path: string;
55
+ readonly size: number;
56
+ } & ({
57
+ readonly text: string;
58
+ } | {
59
+ readonly base64: string;
60
+ });
61
+ export interface HouseSourceDownload extends HouseRelease {
62
+ readonly files: readonly HouseSourceFile[];
63
+ }
64
+ export type HouseTab = TabChange;
65
+ export interface HouseExternalRequest {
66
+ readonly url: string;
67
+ readonly method?: string;
68
+ readonly headers?: Readonly<Record<string, string>>;
69
+ readonly body?: string;
70
+ readonly body_base64?: string;
71
+ readonly account?: string;
72
+ readonly slot?: string;
73
+ }
74
+ export type HouseExternalAnswer = {
75
+ readonly status: number;
76
+ readonly headers: Readonly<Record<string, string | readonly string[]>>;
77
+ } & ({
78
+ readonly body: string;
79
+ } | {
80
+ readonly body_base64: string;
81
+ });
82
+ export interface HouseRefusal {
83
+ readonly error: {
84
+ readonly code: string;
85
+ };
86
+ }
87
+ export interface HouseAppMessage {
88
+ readonly from: string;
89
+ readonly body: unknown;
90
+ }
91
+ export type HouseInstallation = 'accepted' | 'dismissed' | 'manual';
92
+ export type HouseNotifications = 'on' | 'off' | 'blocked' | 'unavailable';
93
+ export interface HouseImageCopy {
94
+ readonly attachment: string;
95
+ readonly copy: string;
96
+ readonly name: string;
97
+ readonly media_type: string;
98
+ }
99
+ export interface HouseFrameRefusal {
100
+ readonly name: string;
101
+ readonly message: string;
102
+ }
103
+ export type HouseFrameRequest = {
104
+ readonly house: 'navigate';
105
+ readonly mode: HouseNavigationMode;
106
+ readonly address: string;
107
+ } | ({
108
+ readonly house: 'tab';
109
+ } & HouseTab) | {
110
+ readonly house: 'ceremony';
111
+ readonly path: string;
112
+ } | {
113
+ readonly house: 'install';
114
+ readonly id: number;
115
+ } | {
116
+ readonly house: 'notifications';
117
+ readonly id: number;
118
+ readonly on?: boolean;
119
+ } | {
120
+ readonly house: 'badge';
121
+ readonly id: number;
122
+ readonly unread: readonly string[];
123
+ } | {
124
+ readonly house: 'attachment-read';
125
+ readonly id: number;
126
+ readonly room: string;
127
+ readonly attachment: string;
128
+ } | {
129
+ readonly house: 'source-top' | 'source-download';
130
+ readonly id: number;
131
+ readonly app: string;
132
+ readonly release?: string;
133
+ } | {
134
+ readonly house: 'external-call';
135
+ readonly id: number;
136
+ readonly request: HouseExternalRequest;
137
+ } | {
138
+ readonly house: 'screenshot-read';
139
+ readonly id: number;
140
+ readonly app: string;
141
+ } | {
142
+ readonly house: 'site-screenshot-read';
143
+ readonly id: number;
144
+ readonly site: string;
145
+ } | {
146
+ readonly house: 'ga/consent';
147
+ readonly choice: HouseGaChoice;
148
+ };
149
+ export type HouseInstallAnswer = {
150
+ readonly outcome: HouseInstallation;
151
+ } | {
152
+ readonly refusal: HouseFrameRefusal;
153
+ };
154
+ export type HouseNotificationAnswer = {
155
+ readonly refused: true;
156
+ } | {
157
+ readonly refusal: HouseFrameRefusal;
158
+ } | {
159
+ readonly state: HouseNotifications;
160
+ };
161
+ export type HouseBadgeAnswer = {
162
+ readonly refused: true;
163
+ } | {
164
+ readonly refusal: HouseFrameRefusal;
165
+ } | {
166
+ readonly refused?: never;
167
+ readonly refusal?: never;
168
+ };
169
+ export type HouseFrameMessage = {
170
+ readonly house: 'runtime';
171
+ } | {
172
+ readonly house: 'status';
173
+ readonly status: HouseStatus;
174
+ } | {
175
+ readonly house: 'screenshot';
176
+ readonly id: number;
177
+ readonly image: Blob | null;
178
+ } | {
179
+ readonly house: 'address';
180
+ readonly address: string;
181
+ readonly navigated: boolean;
182
+ } | {
183
+ readonly house: 'ceremony-refused';
184
+ readonly path: string | null;
185
+ } | {
186
+ readonly house: 'navigate-refused';
187
+ readonly address: string | null;
188
+ } | {
189
+ readonly house: 'attachment';
190
+ readonly id: number;
191
+ readonly image: HouseImageCopy | null;
192
+ } | {
193
+ readonly house: 'source';
194
+ readonly id: number;
195
+ readonly answer: HouseSourceTop | HouseSourceDownload | HouseRefusal | null;
196
+ } | {
197
+ readonly house: 'external-call';
198
+ readonly id: number;
199
+ readonly answer: HouseExternalAnswer | HouseRefusal | null;
200
+ } | ({
201
+ readonly house: 'install';
202
+ readonly id: number;
203
+ } & HouseInstallAnswer) | ({
204
+ readonly house: 'notifications';
205
+ readonly id: number;
206
+ } & HouseNotificationAnswer) | ({
207
+ readonly house: 'badge';
208
+ readonly id: number;
209
+ } & HouseBadgeAnswer) | {
210
+ readonly house: 'app-message';
211
+ readonly from: string;
212
+ readonly body: unknown;
213
+ };
214
+ export declare const HOUSE_ADDRESS_MAX_CHARS = 1000;
215
+ export {};
@@ -12,5 +12,5 @@ export function appAddressOf(root, address) {
12
12
  export function shellAddressOf(root, appAddress) {
13
13
  const { path, rest } = split(appAddress);
14
14
  const below = path === '/' ? '' : path;
15
- return `${root === '/' ? '' : root}${below}${rest}` || '/';
15
+ return `${`${root === '/' ? '' : root}${below}` || '/'}${rest}`;
16
16
  }
@@ -0,0 +1,106 @@
1
+ # The App bridge's families
2
+
3
+ - **Navigation** - the part of the shell's URL below the App's path, with its
4
+ query and fragment. The shell delivers it when the App starts and whenever the
5
+ User navigates; the App asks the shell to push or replace it. The App also
6
+ sets its tab's title and icon with `setTab({ title, icon })`, either or both:
7
+ a title of at most 200 characters and an icon given as a base64 `data:` URL
8
+ of an SVG, PNG, GIF or WebP image of at most 1,048,576 characters. The shell
9
+ shows them over the App's display name, or a Site's title, and its tab icon
10
+ until the next load, and ignores a request outside those bounds.
11
+ - **Ceremony** - the App asks the shell to leave for one of House's own
12
+ purpose-bound authenticated pages. The shell navigates only to a same-origin
13
+ absolute path, carries its current address as the return target, and the page
14
+ returns there when it completes. An App names an address House gave it in a
15
+ reading and composes none.
16
+ - **Installation** - `install()` offers the page as a Home-screen app. Where the
17
+ browser has an install prompt ready the shell shows it and the call resolves
18
+ `'accepted'` or `'dismissed'` with the User's choice, spending that prompt;
19
+ otherwise, as on every iPhone and iPad, it resolves `'manual'` and the App
20
+ shows its own instructions. A prompt the browser refuses to show, as for a
21
+ call made without a click, rejects with the browser's own `DOMException`
22
+ and stays ready. Call it from a click. The standard
23
+ `display-mode: standalone` media query tells the App it runs installed.
24
+ - **Notifications** - House's own panel alone; an App's or a Site's call
25
+ rejects with `HouseCeremonyRefused`. `notifications()` answers this browser
26
+ storage's push state: `'on'`, `'off'`, `'blocked'` where the browser refuses
27
+ notifications, or `'unavailable'` where it has no push, as on an iPhone or
28
+ iPad outside a Home-screen app. `notifications(true)` asks the browser's
29
+ permission and subscribes, `notifications(false)` unsubscribes, and each
30
+ answers the state it leaves; a failure of the browser's push service rejects
31
+ with the browser's own `DOMException`. Call `notifications(true)` from a
32
+ click. `setBadge(unread)` takes the identifiers of the Human Notifications
33
+ unread in this storage and sets the icon's badge to their number, or clears
34
+ it at none, where the browser shows one.
35
+ - **Status** - the App reference the runtime client is attached under,
36
+ `preferences`: the `room_ref` and Path of the App's own preferences document,
37
+ or `null` until the runtime holds that Room, sync
38
+ state, credential state and observed failure, `ga`:
39
+ whether the page measures and the visitor's stored analytics choice, and
40
+ `service_mode`: `open`, `write-halted` while House refuses writes, so the
41
+ App can stop retrying them, or `null` while the runtime holds no House
42
+ connection.
43
+ The App reports the visitor's choice with `gaConsent('accept')` or
44
+ `gaConsent('decline')`; the shell hands it to House's analytics loader, and
45
+ the choice never reaches House.
46
+ - **Site image read** - given a Published Room and an original attachment
47
+ reference, it answers the web copy's path and metadata, or `null` when no
48
+ copy exists. The original bytes never cross this bridge at a Site.
49
+ - **App release code** - `sourceTop(app, release)` answers the top level of
50
+ the latest successful release of an App the User may use: its folders, its
51
+ files with their sizes and its root README's text, or `null` for none;
52
+ `sourceDownload(app, release)` answers every file of that release with its
53
+ path, its size and its `text`, or its bytes as `base64`. The browser keeps
54
+ each answer per App and release: a read naming a release it holds answers
55
+ without asking House, and keeping a newer release drops the earlier one. A
56
+ refusal is House's `{ error: { code } }`, `app_release_not_found` for an
57
+ App with no release; a read House does not answer, or one that outlived
58
+ the browser's signed-in User, is `null`.
59
+ - **External calls** - `callExternal({ url, method, headers, body, slot })`
60
+ has House's server send an HTTPS request to one of the release's declared
61
+ external origins and resolves with the service's `status`, `headers` and its
62
+ body as `body` text or `body_base64`, or a refusal as House's
63
+ `{ error: { code } }`, or `null` when House does not answer. House fills in the
64
+ App and its running release; naming an account slot places the key of the
65
+ account the User connected to it, which the App never sees, and naming none
66
+ sends no key. A Site's call names no slot, and the House panel names an
67
+ `account` instead. `body_base64` replaces `body` for bytes.
68
+ - **App messages** - `messages(listener)` hands the listener each message
69
+ another holder of this App sends to this User's running copies, as `from`,
70
+ the sender's User id, and `body`, the JSON value they sent, until the
71
+ function it returns is called. App code sends one through the Runtime's
72
+ `call('apps/messages/send', { to, body })` with the recipient's User id and
73
+ a body of at most 32 KiB of JSON; House fills in the App and relays it only
74
+ to copies running now, storing nothing. The panel sends none.
75
+ - **App screenshot read** - given an App reference, `readScreenshot(app)`
76
+ answers the image this browser captured of that App's screen the first time
77
+ it ran its current release, as a PNG `Blob` at most 1200 pixels wide, or
78
+ `null` when this browser holds none. The image stays in the browser; a Site
79
+ holds none.
80
+ - **Site screenshot read** - given one of the User's Site identifiers,
81
+ `readSiteScreenshot(site)` answers the image House captured of that Site
82
+ after its latest release, as a JPEG `Blob`, or `null` when House holds none
83
+ or does not answer. A Site's own renderer reads none.
84
+
85
+ ## The test host
86
+
87
+ The host plays the shell and the worker: it delivers the address the App starts
88
+ at and each address `open` plays as Back or Forward through the shell's own
89
+ slice below the App's path, admitting nothing, so it delivers `//notes` exactly
90
+ as a browser at `/apps/<app>//notes` does. It records every push and replace
91
+ and answers each with the address the shell would then show, records an
92
+ admitted ceremony, refuses a push, replace or ceremony that is not a
93
+ same-origin absolute path, publishes a status, its `service_mode` included,
94
+ records in `consents` each
95
+ `gaConsent` it takes while that status measures and publishes the status the
96
+ choice leaves, records in `tabs` each `setTab` the shell would take and ignores
97
+ any other, answers a release code read from the `sources` it was given, or
98
+ `null`, answers each `callExternal` from the `external` function it was given, or
99
+ `null`, and records the request in `externalCalls`, answers `readScreenshot`
100
+ from the `screenshots` it was given by App
101
+ reference and `readSiteScreenshot` from the `siteScreenshots` it was given by
102
+ Site identifier, answers every `install()` `'manual'`, as a browser with no install
103
+ prompt does, answers every `notifications()` `'unavailable'`, as a browser
104
+ without push does, settles every `setBadge`, delivers each App message
105
+ `message({ from, body })` plays, and hands `bridge.runtime` the MD Runtime
106
+ client `attachRuntime(client)` gives it.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@agentshouse/app-bridge",
3
- "version": "0.1.0-alpha.8",
4
- "description": "Renderer-side client of the agents.house App bridge: navigation, the tab title and icon, ceremonies, status, Site image reads, App release code and App screenshot reads, with an in-page test host.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.0-c07e1058c2cee",
4
+ "description": "Renderer-side client of the agents.house App bridge: navigation, the tab title and icon, ceremonies, installation, notifications and the icon badge, status, Site image reads, App release code, App and Site screenshot reads, and App messages, with an in-page test host.",
5
+ "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": false,
8
8
  "engines": {
@@ -10,21 +10,21 @@
10
10
  },
11
11
  "exports": {
12
12
  ".": {
13
- "types": "./client/src/index.d.ts",
14
- "default": "./client/src/index.js"
13
+ "types": "./dist/client/src/index.d.ts",
14
+ "default": "./dist/client/src/index.js"
15
15
  },
16
16
  "./test-host": {
17
- "types": "./client/src/test-host.d.ts",
18
- "default": "./client/src/test-host.js"
17
+ "types": "./dist/client/src/test-host.d.ts",
18
+ "default": "./dist/client/src/test-host.js"
19
19
  }
20
20
  },
21
+ "dependencies": {
22
+ "@agentshouse/mdruntime": "0.1.0-c7274d650bd05"
23
+ },
21
24
  "files": [
22
- "client/src/*.js",
23
- "client/src/*.d.ts",
24
- "src/apps/remainder.js",
25
- "src/apps/remainder.d.ts",
26
- "src/apps/tab.js",
27
- "src/apps/tab.d.ts",
28
- "README.md"
25
+ "dist",
26
+ "docs",
27
+ "README.md",
28
+ "LICENSE"
29
29
  ]
30
30
  }
@@ -1,32 +0,0 @@
1
- import type { HouseBridgePort, HouseGaChoice, HouseMessagePort, HouseSourceDownload, HouseSourceRefusal, HouseSourceTop, HouseStatus, HouseTab } 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 HouseImageCopy {
11
- readonly attachment: string;
12
- readonly copy: string;
13
- readonly name: string;
14
- readonly media_type: string;
15
- }
16
- export interface HouseBridge {
17
- readonly runtime: Promise<HouseMessagePort>;
18
- address(): string | null;
19
- onAddress(listener: (address: string) => void): () => void;
20
- push(address: string): Promise<string>;
21
- replace(address: string): Promise<string>;
22
- ceremony(path: string): Promise<never>;
23
- setTab(tab: HouseTab): void;
24
- readAttachment(room: string, attachment: string): Promise<HouseImageCopy | null>;
25
- sourceTop(app: string, release?: string): Promise<HouseSourceTop | HouseSourceRefusal | null>;
26
- sourceDownload(app: string, release?: string): Promise<HouseSourceDownload | HouseSourceRefusal | null>;
27
- readScreenshot(app: string): Promise<Blob | null>;
28
- status(): HouseStatus | null;
29
- onStatus(listener: (status: HouseStatus) => void): () => void;
30
- gaConsent(choice: HouseGaChoice): void;
31
- }
32
- export declare function connectHouseBridge(source?: Promise<HouseBridgePort>): HouseBridge;
@@ -1,3 +0,0 @@
1
- export { connectHouseBridge, HouseCeremonyRefused, HouseNavigationRefused, type HouseBridge, type HouseImageCopy, } from './bridge.js';
2
- export { HOUSE_ADDRESS_MAX_CHARS } from './wire.js';
3
- export type { HouseBridgeEvent, HouseBridgePort, HouseCredentialState, HouseGa, HouseGaChoice, HouseMessagePort, HouseNavigationMode, HouseSourceDownload, HouseSourceFile, HouseSourceRefusal, HouseSourceTop, HouseStatus, HouseSyncState, } from './wire.js';
@@ -1,69 +0,0 @@
1
- import type { TabChange } from '../../src/apps/tab.js';
2
- export interface HouseBridgeEvent {
3
- readonly data: unknown;
4
- readonly ports: readonly HouseMessagePort[];
5
- }
6
- export interface HouseMessagePort {
7
- postMessage(message: unknown): void;
8
- addEventListener(type: 'message', listener: (event: HouseBridgeEvent) => void): void;
9
- start(): void;
10
- }
11
- export type HouseBridgePort = HouseMessagePort;
12
- export interface HouseSyncState {
13
- readonly state: string;
14
- readonly providerUnreachable: boolean;
15
- }
16
- export interface HouseCredentialState {
17
- readonly state: 'none' | 'held' | 'rejected';
18
- readonly expires_at: number | null;
19
- }
20
- export type HouseGaChoice = 'accept' | 'decline';
21
- export interface HouseGa {
22
- readonly measures: boolean;
23
- readonly choice: HouseGaChoice | null;
24
- }
25
- export interface HouseStatus {
26
- readonly app: string;
27
- readonly runtime: string;
28
- readonly credential: HouseCredentialState;
29
- readonly sync: HouseSyncState | null;
30
- readonly refusal: {
31
- readonly reason: string;
32
- } | null;
33
- readonly loss: string | null;
34
- readonly release: unknown;
35
- readonly ga: HouseGa;
36
- readonly service_mode: 'open' | 'write-halted' | null;
37
- }
38
- export type HouseNavigationMode = 'push' | 'replace';
39
- interface HouseRelease {
40
- readonly app_ref: string;
41
- readonly release: string;
42
- }
43
- export interface HouseSourceTop extends HouseRelease {
44
- readonly folders: readonly string[];
45
- readonly files: readonly {
46
- readonly name: string;
47
- readonly size: number;
48
- }[];
49
- readonly readme: string | null;
50
- }
51
- export type HouseSourceFile = {
52
- readonly path: string;
53
- readonly size: number;
54
- } & ({
55
- readonly text: string;
56
- } | {
57
- readonly base64: string;
58
- });
59
- export interface HouseSourceDownload extends HouseRelease {
60
- readonly files: readonly HouseSourceFile[];
61
- }
62
- export interface HouseSourceRefusal {
63
- readonly error: {
64
- readonly code: string;
65
- };
66
- }
67
- export type HouseTab = TabChange;
68
- export declare const HOUSE_ADDRESS_MAX_CHARS = 1000;
69
- export {};
File without changes
File without changes
File without changes
File without changes
File without changes