@agentshouse/app-bridge 0.1.0-c3a0f0faaf74b → 0.1.0-c58a055eeaa71

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 CHANGED
@@ -1,22 +1,25 @@
1
1
  # @agentshouse/app-bridge
2
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 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.
3
+ The types of the agents.house App bridge, an accessor and an in-page test host.
4
+ House builds the bridge object in every App it serves and hands it to the page
5
+ before the App's own scripts run; `houseBridge()` returns that object, so an App
6
+ carries no bridge code of its own. It carries eleven families: navigation,
7
+ ceremonies, installation, notifications, status, Site image reads, App release
8
+ code, external calls, App messages and App and Site screenshot reads.
8
9
  [docs/families.md](docs/families.md) describes each and the test host.
9
10
 
10
- The package carries no credential handling and no Runtime family. House builds
11
+ The bridge carries no credential handling and no Runtime family. House builds
11
12
  the MD Runtime client into the page beside the bridge, and `bridge.runtime`
12
13
  resolves to that client, so the App imports and constructs none.
13
14
 
15
+ House's `find_command` "App" is the guide to building an App or a Site.
16
+
14
17
  ## Use
15
18
 
16
19
  ```js
17
- import { connectHouseBridge } from '@agentshouse/app-bridge';
20
+ import { houseBridge } from '@agentshouse/app-bridge';
18
21
 
19
- const bridge = connectHouseBridge();
22
+ const bridge = houseBridge();
20
23
  const md = await bridge.runtime;
21
24
 
22
25
  bridge.onAddress((address) => render(address));
@@ -32,7 +35,7 @@ async function manageSecurity(securityPath) {
32
35
  try {
33
36
  await bridge.ceremony(securityPath);
34
37
  } catch (refusal) {
35
- report(refusal);
38
+ if (refusal.name === 'HouseCeremonyRefused') report(refusal.path);
36
39
  }
37
40
  }
38
41
  ```
@@ -43,11 +46,11 @@ async function manageSecurity(securityPath) {
43
46
  ## Test without a House
44
47
 
45
48
  ```js
46
- import { connectHouseBridge } from '@agentshouse/app-bridge';
49
+ import { houseBridge } from '@agentshouse/app-bridge';
47
50
  import { createHouseBridgeTestHost } from '@agentshouse/app-bridge/test-host';
48
51
 
49
52
  const host = createHouseBridgeTestHost({ address: '/rooms/atlas' });
50
- const bridge = connectHouseBridge(host);
53
+ const bridge = houseBridge();
51
54
 
52
55
  bridge.push('/rooms/atlas/members');
53
56
  host.open('/rooms/atlas');
@@ -0,0 +1,139 @@
1
+ import type { MdRuntimeClient } from '@agentshouse/mdruntime/client';
2
+ type Open<Value extends string> = Value | (string & {});
3
+ export interface HouseSyncState {
4
+ readonly state: string;
5
+ readonly providerUnreachable: boolean;
6
+ }
7
+ export interface HouseCredentialState {
8
+ readonly state: Open<'none' | 'held' | 'rejected'>;
9
+ readonly expiresAt: number | null;
10
+ }
11
+ export type HouseAnalyticsChoice = 'accept' | 'decline';
12
+ export interface HouseAnalytics {
13
+ readonly measures: boolean;
14
+ readonly choice: Open<HouseAnalyticsChoice> | null;
15
+ }
16
+ export interface HouseStatus {
17
+ readonly app: string | null;
18
+ readonly preferences: {
19
+ readonly roomRef: string;
20
+ readonly path: string;
21
+ } | null;
22
+ readonly runtime: string;
23
+ readonly credential: HouseCredentialState;
24
+ readonly sync: HouseSyncState | null;
25
+ readonly refusal: {
26
+ readonly code: string;
27
+ readonly retryable: boolean;
28
+ } | null;
29
+ readonly loss: string | null;
30
+ readonly analytics: HouseAnalytics;
31
+ readonly serviceMode: Open<'open' | 'write-halted'> | null;
32
+ readonly offline: boolean;
33
+ }
34
+ export type HouseNavigationMode = 'push' | 'replace';
35
+ export interface HouseTab {
36
+ readonly title?: string;
37
+ readonly icon?: string;
38
+ }
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 HouseExternalRequest {
63
+ readonly url: string;
64
+ readonly method?: string;
65
+ readonly headers?: Readonly<Record<string, string>>;
66
+ readonly body?: string;
67
+ readonly body_base64?: string;
68
+ readonly account?: string;
69
+ readonly slot?: string;
70
+ }
71
+ export type HouseExternalAnswer = {
72
+ readonly status: number;
73
+ readonly headers: Readonly<Record<string, string | readonly string[]>>;
74
+ } & ({
75
+ readonly body: string;
76
+ } | {
77
+ readonly body_base64: string;
78
+ });
79
+ export interface HouseRefusal {
80
+ readonly error: {
81
+ readonly code: string;
82
+ readonly retryable: boolean;
83
+ };
84
+ }
85
+ export interface HouseAppMessage {
86
+ readonly from: string;
87
+ readonly body: unknown;
88
+ }
89
+ export type HouseInstallation = Open<'accepted' | 'dismissed' | 'manual'>;
90
+ export type HouseNotifications = Open<'on' | 'off' | 'blocked' | 'unavailable'>;
91
+ export interface HouseImageCopy {
92
+ readonly attachment: string;
93
+ readonly copy: string;
94
+ readonly name: string;
95
+ readonly media_type: string;
96
+ }
97
+ export interface HouseCeremonyRefused extends Error {
98
+ readonly name: 'HouseCeremonyRefused';
99
+ readonly code: Open<'house_ceremony_refused'>;
100
+ readonly retryable: boolean;
101
+ readonly path: string | null;
102
+ }
103
+ export interface HouseNavigationRefused extends Error {
104
+ readonly name: 'HouseNavigationRefused';
105
+ readonly code: Open<'house_navigation_refused'>;
106
+ readonly retryable: boolean;
107
+ readonly address: string | null;
108
+ }
109
+ export interface HouseBrowserRefused extends Error {
110
+ readonly code: string;
111
+ readonly retryable: boolean;
112
+ }
113
+ export interface HouseBridge {
114
+ readonly runtime: Promise<MdRuntimeClient>;
115
+ address(): string | null;
116
+ onAddress(listener: (address: string) => void): () => void;
117
+ push(address: string): Promise<string>;
118
+ replace(address: string): Promise<string>;
119
+ ceremony(path: string): Promise<never>;
120
+ install(): Promise<HouseInstallation>;
121
+ notifications(on?: boolean): Promise<HouseNotifications>;
122
+ setBadge(unread: readonly string[]): Promise<void>;
123
+ setTab(tab: HouseTab): void;
124
+ readAttachment(room: string, attachment: string): Promise<HouseImageCopy | null>;
125
+ sourceTop(app: string, release?: string): Promise<HouseSourceTop | HouseRefusal | null>;
126
+ sourceDownload(app: string, release?: string): Promise<HouseSourceDownload | HouseRefusal | null>;
127
+ callExternal(request: HouseExternalRequest): Promise<HouseExternalAnswer | HouseRefusal | null>;
128
+ readScreenshot(app: string): Promise<Blob | null>;
129
+ readSiteScreenshot(site: string): Promise<Blob | null>;
130
+ status(): HouseStatus | null;
131
+ onStatus(listener: (status: HouseStatus) => void): () => void;
132
+ messages(listener: (message: HouseAppMessage) => void): () => void;
133
+ analyticsConsent(choice: HouseAnalyticsChoice): void;
134
+ }
135
+ export interface HouseHeld {
136
+ readonly houseBridge: HouseBridge;
137
+ }
138
+ export declare const houseBridge: () => HouseBridge;
139
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export const houseBridge = () => globalThis.houseBridge;
@@ -1,14 +1,13 @@
1
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';
2
+ import type { HouseAnalyticsChoice, HouseAppMessage, HouseExternalAnswer, HouseExternalRequest, HouseImageCopy, HouseNavigationMode, HouseRefusal, HouseSourceDownload, HouseSourceTop, HouseStatus, HouseTab } from './index.js';
4
3
  export interface HouseBridgeNavigation {
5
4
  readonly mode: HouseNavigationMode;
6
5
  readonly address: string;
7
6
  }
8
- export interface HouseBridgeTestHost extends HouseBridgeSource {
7
+ export interface HouseBridgeTestHost {
9
8
  readonly navigations: readonly HouseBridgeNavigation[];
10
9
  readonly ceremonies: readonly string[];
11
- readonly consents: readonly HouseGaChoice[];
10
+ readonly consents: readonly HouseAnalyticsChoice[];
12
11
  readonly tabs: readonly HouseTab[];
13
12
  readonly externalCalls: readonly HouseExternalRequest[];
14
13
  address(): string;
@@ -0,0 +1,165 @@
1
+ const HOST_ORIGIN = 'https://house.invalid';
2
+ const ADDRESS_MAX_CHARS = 1000;
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 > 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
+ function appAddress(address) {
14
+ return address.startsWith('/') ? address : `/${address}`;
15
+ }
16
+ function ceremonyRefused(path) {
17
+ return Object.assign(new Error('house_ceremony_refused'), {
18
+ name: 'HouseCeremonyRefused',
19
+ code: 'house_ceremony_refused',
20
+ retryable: false,
21
+ path,
22
+ });
23
+ }
24
+ function navigationRefused(address) {
25
+ return Object.assign(new Error('house_navigation_refused'), {
26
+ name: 'HouseNavigationRefused',
27
+ code: 'house_navigation_refused',
28
+ retryable: false,
29
+ address,
30
+ });
31
+ }
32
+ export function createHouseBridgeTestHost(options = {}) {
33
+ const navigations = [];
34
+ const ceremonies = [];
35
+ const consents = [];
36
+ const tabs = [];
37
+ const externalCalls = [];
38
+ const addressListeners = new Set();
39
+ const statusListeners = new Set();
40
+ const messageListeners = new Set();
41
+ let current = appAddress(options.address ?? '/');
42
+ let published = options.status ?? null;
43
+ let shown = null;
44
+ let seen = null;
45
+ let attached = () => { };
46
+ const runtime = new Promise((resolve) => {
47
+ attached = resolve;
48
+ });
49
+ function arrive(target) {
50
+ current = target;
51
+ queueMicrotask(() => {
52
+ shown = target;
53
+ for (const listener of addressListeners)
54
+ listener(target);
55
+ });
56
+ }
57
+ function publish(next) {
58
+ published = next;
59
+ queueMicrotask(() => {
60
+ seen = next;
61
+ for (const listener of statusListeners)
62
+ listener(next);
63
+ });
64
+ }
65
+ function navigate(mode, asked) {
66
+ const target = sameOriginPath(asked);
67
+ if (target === null)
68
+ return Promise.reject(navigationRefused(asked));
69
+ navigations.push({ mode, address: target });
70
+ arrive(target);
71
+ return new Promise((resolve) => {
72
+ queueMicrotask(() => resolve(target));
73
+ });
74
+ }
75
+ const bridge = {
76
+ runtime,
77
+ address: () => shown,
78
+ onAddress(listener) {
79
+ addressListeners.add(listener);
80
+ return () => {
81
+ addressListeners.delete(listener);
82
+ };
83
+ },
84
+ push: (asked) => navigate('push', asked),
85
+ replace: (asked) => navigate('replace', asked),
86
+ ceremony(path) {
87
+ const target = sameOriginPath(path);
88
+ if (target === null)
89
+ return Promise.reject(ceremonyRefused(path));
90
+ ceremonies.push(target);
91
+ return new Promise(() => { });
92
+ },
93
+ install: () => Promise.resolve('manual'),
94
+ notifications: () => Promise.resolve('unavailable'),
95
+ setBadge: () => Promise.resolve(),
96
+ setTab(tab) {
97
+ tabs.push(tab);
98
+ },
99
+ readAttachment(room, attachment) {
100
+ const image = options.images?.find((entry) => entry.room === room && entry.attachment === attachment);
101
+ return Promise.resolve(image === undefined
102
+ ? null
103
+ : {
104
+ attachment: image.attachment,
105
+ copy: image.copy,
106
+ name: image.name,
107
+ media_type: image.media_type,
108
+ });
109
+ },
110
+ sourceTop: (app) => Promise.resolve(options.sources?.find((entry) => entry.top.app_ref === app)?.top ?? null),
111
+ sourceDownload: (app) => Promise.resolve(options.sources?.find((entry) => entry.top.app_ref === app)?.download ?? null),
112
+ callExternal(request) {
113
+ externalCalls.push(request);
114
+ return Promise.resolve(options.external?.(request) ?? null);
115
+ },
116
+ readScreenshot: (app) => Promise.resolve(options.screenshots?.[app] ?? null),
117
+ readSiteScreenshot: (site) => Promise.resolve(options.siteScreenshots?.[site] ?? null),
118
+ status: () => seen,
119
+ onStatus(listener) {
120
+ statusListeners.add(listener);
121
+ return () => {
122
+ statusListeners.delete(listener);
123
+ };
124
+ },
125
+ messages(listener) {
126
+ messageListeners.add(listener);
127
+ return () => {
128
+ messageListeners.delete(listener);
129
+ };
130
+ },
131
+ analyticsConsent(choice) {
132
+ if (published === null || !published.analytics.measures)
133
+ return;
134
+ if (choice !== 'accept' && choice !== 'decline')
135
+ return;
136
+ consents.push(choice);
137
+ publish({ ...published, analytics: { measures: true, choice } });
138
+ },
139
+ };
140
+ Object.defineProperty(globalThis, 'houseBridge', { value: bridge, configurable: true });
141
+ if (published !== null)
142
+ publish(published);
143
+ arrive(current);
144
+ return {
145
+ navigations,
146
+ ceremonies,
147
+ consents,
148
+ tabs,
149
+ externalCalls,
150
+ address: () => current,
151
+ open(asked) {
152
+ arrive(appAddress(asked));
153
+ },
154
+ publish,
155
+ message({ from, body }) {
156
+ queueMicrotask(() => {
157
+ for (const listener of messageListeners)
158
+ listener({ from, body });
159
+ });
160
+ },
161
+ attachRuntime(client) {
162
+ attached(client);
163
+ },
164
+ };
165
+ }
package/docs/families.md CHANGED
@@ -18,28 +18,30 @@
18
18
  `'accepted'` or `'dismissed'` with the User's choice, spending that prompt;
19
19
  otherwise, as on every iPhone and iPad, it resolves `'manual'` and the App
20
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
21
+ call made without a click, rejects with an error named and coded by the
22
+ browser's own error name, with `retryable` false, and stays ready. Call it from a click. The standard
23
23
  `display-mode: standalone` media query tells the App it runs installed.
24
24
  - **Notifications** - House's own panel alone; an App's or a Site's call
25
- rejects with `HouseCeremonyRefused`. `notifications()` answers this browser
25
+ rejects with an error named `HouseCeremonyRefused`, carrying `code` and `retryable`. `notifications()` answers this browser
26
26
  storage's push state: `'on'`, `'off'`, `'blocked'` where the browser refuses
27
27
  notifications, or `'unavailable'` where it has no push, as on an iPhone or
28
28
  iPad outside a Home-screen app. `notifications(true)` asks the browser's
29
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
30
+ answers the state it leaves; a failure of the browser's push service, and
31
+ of `setBadge`, rejects the same way. Call `notifications(true)` from a
32
32
  click. `setBadge(unread)` takes the identifiers of the Human Notifications
33
33
  unread in this storage and sets the icon's badge to their number, or clears
34
34
  it at none, where the browser shows one.
35
- - **Status** - the App reference the runtime client is attached under, sync
36
- state, credential state and observed failure, `ga`:
35
+ - **Status** - the App reference the runtime client is attached under,
36
+ `preferences`: the `roomRef` and Path of the App's own preferences document,
37
+ or `null` until the runtime holds that Room, sync
38
+ state, credential state and its `expiresAt`, observed failure, `analytics`:
37
39
  whether the page measures and the visitor's stored analytics choice, and
38
- `service_mode`: `open`, `write-halted` while House refuses writes, so the
40
+ `serviceMode`: `open`, `write-halted` while House refuses writes, so the
39
41
  App can stop retrying them, or `null` while the runtime holds no House
40
- connection.
41
- The App reports the visitor's choice with `gaConsent('accept')` or
42
- `gaConsent('decline')`; the shell hands it to House's analytics loader, and
42
+ connection. Every status field is camelCase.
43
+ The App reports the visitor's choice with `analyticsConsent('accept')` or
44
+ `analyticsConsent('decline')`; the shell hands it to House's analytics loader, and
43
45
  the choice never reaches House.
44
46
  - **Site image read** - given a Published Room and an original attachment
45
47
  reference, it answers the web copy's path and metadata, or `null` when no
@@ -51,14 +53,14 @@
51
53
  path, its size and its `text`, or its bytes as `base64`. The browser keeps
52
54
  each answer per App and release: a read naming a release it holds answers
53
55
  without asking House, and keeping a newer release drops the earlier one. A
54
- refusal is House's `{ error: { code } }`, `app_release_not_found` for an
56
+ refusal is House's `{ error: { code, retryable } }`, `app_release_not_found` for an
55
57
  App with no release; a read House does not answer, or one that outlived
56
58
  the browser's signed-in User, is `null`.
57
59
  - **External calls** - `callExternal({ url, method, headers, body, slot })`
58
60
  has House's server send an HTTPS request to one of the release's declared
59
61
  external origins and resolves with the service's `status`, `headers` and its
60
62
  body as `body` text or `body_base64`, or a refusal as House's
61
- `{ error: { code } }`, or `null` when House does not answer. House fills in the
63
+ `{ error: { code, retryable } }`, or `null` when House does not answer. House fills in the
62
64
  App and its running release; naming an account slot places the key of the
63
65
  account the User connected to it, which the App never sees, and naming none
64
66
  sends no key. A Site's call names no slot, and the House panel names an
@@ -67,7 +69,7 @@
67
69
  another holder of this App sends to this User's running copies, as `from`,
68
70
  the sender's User id, and `body`, the JSON value they sent, until the
69
71
  function it returns is called. App code sends one through the Runtime's
70
- `call('apps/messages/send', { to, body })` with the recipient's User id and
72
+ `call('message.send', { to, body })` with the recipient's User id and
71
73
  a body of at most 32 KiB of JSON; House fills in the App and relays it only
72
74
  to copies running now, storing nothing. The panel sends none.
73
75
  - **App screenshot read** - given an App reference, `readScreenshot(app)`
@@ -82,23 +84,24 @@
82
84
 
83
85
  ## The test host
84
86
 
85
- The host plays the shell and the worker: it delivers the address the App starts
86
- at and each address `open` plays as Back or Forward through the shell's own
87
- slice below the App's path, admitting nothing, so it delivers `//notes` exactly
88
- as a browser at `/apps/<app>//notes` does. It records every push and replace
89
- and answers each with the address the shell would then show, records an
90
- admitted ceremony, refuses a push, replace or ceremony that is not a
91
- same-origin absolute path, publishes a status, its `service_mode` included,
92
- records in `consents` each
93
- `gaConsent` it takes while that status measures and publishes the status the
94
- choice leaves, records in `tabs` each `setTab` the shell would take and ignores
95
- any other, answers a release code read from the `sources` it was given, or
96
- `null`, answers each `callExternal` from the `external` function it was given, or
97
- `null`, and records the request in `externalCalls`, answers `readScreenshot`
98
- from the `screenshots` it was given by App
99
- reference and `readSiteScreenshot` from the `siteScreenshots` it was given by
100
- Site identifier, answers every `install()` `'manual'`, as a browser with no install
101
- prompt does, answers every `notifications()` `'unavailable'`, as a browser
102
- without push does, settles every `setBadge`, delivers each App message
103
- `message({ from, body })` plays, and hands `bridge.runtime` the MD Runtime
104
- client `attachRuntime(client)` gives it.
87
+ `createHouseBridgeTestHost(options)` plays House in one page: it installs a
88
+ bridge object that `houseBridge()` returns, until the next host replaces it.
89
+ It delivers the address the App starts at and each address `open` plays as
90
+ Back or Forward, admitting nothing, so it delivers `//notes` exactly as a
91
+ browser at `/apps/<app>//notes` does. It records every push and replace and
92
+ answers each with the address the shell would then show, records an admitted
93
+ ceremony, and refuses a push, replace or ceremony that is not a same-origin
94
+ absolute path of at most 1,000 characters with an error named
95
+ `HouseNavigationRefused` or `HouseCeremonyRefused`. It publishes a status,
96
+ its `serviceMode` included, records in `consents` each `analyticsConsent` it
97
+ takes while that status measures and publishes the status the choice leaves,
98
+ records in `tabs` each `setTab`, answers a release code read from the
99
+ `sources` it was given, or `null`, answers each `callExternal` from the
100
+ `external` function it was given, or `null`, and records the request in
101
+ `externalCalls`, answers `readScreenshot` from the `screenshots` it was given
102
+ by App reference and `readSiteScreenshot` from the `siteScreenshots` it was
103
+ given by Site identifier, answers every `install()` `'manual'`, as a browser
104
+ with no install prompt does, answers every `notifications()` `'unavailable'`,
105
+ as a browser without push does, settles every `setBadge`, delivers each App
106
+ message `message({ from, body })` plays, and hands `bridge.runtime` the MD
107
+ Runtime client `attachRuntime(client)` gives it.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agentshouse/app-bridge",
3
- "version": "0.1.0-c3a0f0faaf74b",
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.",
3
+ "version": "0.1.0-c58a055eeaa71",
4
+ "description": "Types of the agents.house App bridge House hands every App: 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 accessor and an in-page test host.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": false,
@@ -10,16 +10,16 @@
10
10
  },
11
11
  "exports": {
12
12
  ".": {
13
- "types": "./dist/client/src/index.d.ts",
14
- "default": "./dist/client/src/index.js"
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
15
  },
16
16
  "./test-host": {
17
- "types": "./dist/client/src/test-host.d.ts",
18
- "default": "./dist/client/src/test-host.js"
17
+ "types": "./dist/test-host.d.ts",
18
+ "default": "./dist/test-host.js"
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@agentshouse/mdruntime": "0.1.0-c1954630fadb6"
22
+ "@agentshouse/mdruntime": "0.1.0-c967d1ca66fee"
23
23
  },
24
24
  "files": [
25
25
  "dist",
@@ -1,46 +0,0 @@
1
- import type { MdRuntimeClient } from '@agentshouse/mdruntime/client';
2
- import type { HouseAppMessage, HouseExternalAnswer, HouseExternalRequest, HouseBridgePort, HouseGaChoice, HouseRefusal, HouseSourceDownload, HouseSourceTop, HouseStatus, HouseTab } from './wire.js';
3
- export declare class HouseCeremonyRefused extends Error {
4
- readonly path: string | null;
5
- constructor(path: string | null);
6
- }
7
- export declare class HouseNavigationRefused extends Error {
8
- readonly address: string | null;
9
- constructor(address: string | null);
10
- }
11
- export interface HouseImageCopy {
12
- readonly attachment: string;
13
- readonly copy: string;
14
- readonly name: string;
15
- readonly media_type: string;
16
- }
17
- type HouseInstallation = 'accepted' | 'dismissed' | 'manual';
18
- type HouseNotifications = 'on' | 'off' | 'blocked' | 'unavailable';
19
- export interface HouseBridgeSource {
20
- readonly port: Promise<HouseBridgePort>;
21
- readonly runtime: Promise<MdRuntimeClient>;
22
- }
23
- export interface HouseBridge {
24
- readonly runtime: Promise<MdRuntimeClient>;
25
- address(): string | null;
26
- onAddress(listener: (address: string) => void): () => void;
27
- push(address: string): Promise<string>;
28
- replace(address: string): Promise<string>;
29
- ceremony(path: string): Promise<never>;
30
- install(): Promise<HouseInstallation>;
31
- notifications(on?: boolean): Promise<HouseNotifications>;
32
- setBadge(unread: readonly string[]): Promise<void>;
33
- setTab(tab: HouseTab): void;
34
- readAttachment(room: string, attachment: string): Promise<HouseImageCopy | null>;
35
- sourceTop(app: string, release?: string): Promise<HouseSourceTop | HouseRefusal | null>;
36
- sourceDownload(app: string, release?: string): Promise<HouseSourceDownload | HouseRefusal | null>;
37
- callExternal(request: HouseExternalRequest): Promise<HouseExternalAnswer | HouseRefusal | null>;
38
- readScreenshot(app: string): Promise<Blob | null>;
39
- readSiteScreenshot(site: string): Promise<Blob | null>;
40
- status(): HouseStatus | null;
41
- onStatus(listener: (status: HouseStatus) => void): () => void;
42
- messages(listener: (message: HouseAppMessage) => void): () => void;
43
- gaConsent(choice: HouseGaChoice): void;
44
- }
45
- export declare function connectHouseBridge(source?: HouseBridgeSource): HouseBridge;
46
- export {};
@@ -1,293 +0,0 @@
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 answered(pending, answer, value) {
18
- if (typeof answer.id !== 'number')
19
- return;
20
- const asked = pending.get(answer.id);
21
- if (asked === undefined)
22
- return;
23
- pending.delete(answer.id);
24
- if (answer.refused === true)
25
- asked.reject(new HouseCeremonyRefused(null));
26
- else if (answer.refusal !== undefined)
27
- asked.reject(new DOMException(answer.refusal.message, answer.refusal.name));
28
- else
29
- asked.resolve(value);
30
- }
31
- function settled() {
32
- let settle = null;
33
- const promise = new Promise((resolve, reject) => {
34
- settle = { resolve, reject };
35
- });
36
- return { promise, settle: settle };
37
- }
38
- function heldBridge() {
39
- const held = globalThis;
40
- if (held.houseBridge === undefined || held.houseRuntime === undefined)
41
- throw new Error('house_bridge_absent');
42
- return { port: held.houseBridge, runtime: held.houseRuntime };
43
- }
44
- export function connectHouseBridge(source = heldBridge()) {
45
- const ceremonies = [];
46
- const navigations = [];
47
- const attachments = new Map();
48
- let nextAttachment = 0;
49
- const sources = new Map();
50
- let nextSource = 0;
51
- const installations = new Map();
52
- let nextInstallation = 0;
53
- const notifications = new Map();
54
- let nextNotifications = 0;
55
- const badges = new Map();
56
- let nextBadge = 0;
57
- const screenshots = new Map();
58
- let nextScreenshot = 0;
59
- const externals = new Map();
60
- let nextExternal = 0;
61
- const addressListeners = new Set();
62
- const statusListeners = new Set();
63
- const messageListeners = new Set();
64
- let outbox = [];
65
- let held = null;
66
- let address = null;
67
- let status = null;
68
- function send(message) {
69
- if (held === null)
70
- outbox?.push(message);
71
- else
72
- held.postMessage(message);
73
- }
74
- function receive(event) {
75
- const data = event.data;
76
- if (data === null || typeof data !== 'object')
77
- return;
78
- if (data.house === 'status') {
79
- status = data.status;
80
- for (const listener of statusListeners)
81
- listener(status);
82
- return;
83
- }
84
- if (data.house === 'app-message') {
85
- const { from, body } = data;
86
- for (const listener of messageListeners)
87
- listener({ from, body });
88
- return;
89
- }
90
- if (data.house === 'address') {
91
- address = String(data.address);
92
- if (data.navigated === true)
93
- navigations.shift()?.settle.resolve(address);
94
- for (const listener of addressListeners)
95
- listener(address);
96
- return;
97
- }
98
- if (data.house === 'navigate-refused') {
99
- const refused = data.address;
100
- const named = typeof refused === 'string' ? refused : null;
101
- const index = named === null ? 0 : navigations.findIndex((one) => one.address === named);
102
- const [pending] = navigations.splice(index < 0 ? 0 : index, 1);
103
- pending?.settle.reject(new HouseNavigationRefused(named));
104
- return;
105
- }
106
- if (data.house === 'attachment') {
107
- const answer = data;
108
- if (typeof answer.id !== 'number')
109
- return;
110
- const pending = attachments.get(answer.id);
111
- if (pending === undefined)
112
- return;
113
- attachments.delete(answer.id);
114
- pending.resolve(answer.image ?? null);
115
- return;
116
- }
117
- if (data.house === 'install') {
118
- const answer = data;
119
- answered(installations, answer, answer.outcome);
120
- return;
121
- }
122
- if (data.house === 'notifications') {
123
- const answer = data;
124
- answered(notifications, answer, answer.state);
125
- return;
126
- }
127
- if (data.house === 'badge') {
128
- answered(badges, data, undefined);
129
- return;
130
- }
131
- if (data.house === 'source') {
132
- const answer = data;
133
- if (typeof answer.id !== 'number')
134
- return;
135
- const pending = sources.get(answer.id);
136
- if (pending === undefined)
137
- return;
138
- sources.delete(answer.id);
139
- pending.resolve(answer.answer ?? null);
140
- return;
141
- }
142
- if (data.house === 'external-call') {
143
- const answer = data;
144
- if (typeof answer.id !== 'number')
145
- return;
146
- const pending = externals.get(answer.id);
147
- if (pending === undefined)
148
- return;
149
- externals.delete(answer.id);
150
- pending.resolve(answer.answer ?? null);
151
- return;
152
- }
153
- if (data.house === 'screenshot') {
154
- const answer = data;
155
- if (typeof answer.id !== 'number')
156
- return;
157
- const pending = screenshots.get(answer.id);
158
- if (pending === undefined)
159
- return;
160
- screenshots.delete(answer.id);
161
- pending.resolve(answer.image ?? null);
162
- return;
163
- }
164
- if (data.house === 'ceremony-refused') {
165
- const refused = data.path;
166
- const named = typeof refused === 'string' ? refused : null;
167
- const index = named === null ? 0 : ceremonies.findIndex((one) => one.path === named);
168
- const [pending] = ceremonies.splice(index < 0 ? 0 : index, 1);
169
- pending?.settle.reject(new HouseCeremonyRefused(named));
170
- }
171
- }
172
- void source.port.then((port) => {
173
- held = port;
174
- port.addEventListener('message', receive);
175
- port.start();
176
- const waiting = outbox ?? [];
177
- outbox = null;
178
- for (const message of waiting)
179
- port.postMessage(message);
180
- });
181
- function readSource(house, app, release) {
182
- const pending = settled();
183
- const id = ++nextSource;
184
- sources.set(id, pending.settle);
185
- send({ house, id, app, release });
186
- return pending.promise;
187
- }
188
- function screenshot(house, asked) {
189
- const pending = settled();
190
- const id = ++nextScreenshot;
191
- screenshots.set(id, pending.settle);
192
- send({ house, id, ...asked });
193
- return pending.promise;
194
- }
195
- function navigate(mode, asked) {
196
- const pending = settled();
197
- navigations.push({ address: asked, settle: pending.settle });
198
- send({ house: 'navigate', mode, address: asked });
199
- return pending.promise;
200
- }
201
- return {
202
- runtime: source.runtime,
203
- address() {
204
- return address;
205
- },
206
- onAddress(listener) {
207
- addressListeners.add(listener);
208
- return () => {
209
- addressListeners.delete(listener);
210
- };
211
- },
212
- push(asked) {
213
- return navigate('push', asked);
214
- },
215
- replace(asked) {
216
- return navigate('replace', asked);
217
- },
218
- ceremony(path) {
219
- const pending = settled();
220
- ceremonies.push({ path, settle: pending.settle });
221
- send({ house: 'ceremony', path });
222
- return pending.promise;
223
- },
224
- install() {
225
- const pending = settled();
226
- const id = ++nextInstallation;
227
- installations.set(id, pending.settle);
228
- send({ house: 'install', id });
229
- return pending.promise;
230
- },
231
- notifications(on) {
232
- const pending = settled();
233
- const id = ++nextNotifications;
234
- notifications.set(id, pending.settle);
235
- send({ house: 'notifications', id, on });
236
- return pending.promise;
237
- },
238
- setBadge(unread) {
239
- const pending = settled();
240
- const id = ++nextBadge;
241
- badges.set(id, pending.settle);
242
- send({ house: 'badge', id, unread });
243
- return pending.promise;
244
- },
245
- setTab(tab) {
246
- send({ house: 'tab', title: tab.title, icon: tab.icon });
247
- },
248
- readAttachment(room, attachment) {
249
- const pending = settled();
250
- const id = ++nextAttachment;
251
- attachments.set(id, pending.settle);
252
- send({ house: 'attachment-read', id, room, attachment });
253
- return pending.promise;
254
- },
255
- sourceTop(app, release) {
256
- return readSource('source-top', app, release);
257
- },
258
- sourceDownload(app, release) {
259
- return readSource('source-download', app, release);
260
- },
261
- callExternal(request) {
262
- const pending = settled();
263
- const id = ++nextExternal;
264
- externals.set(id, pending.settle);
265
- send({ house: 'external-call', id, request });
266
- return pending.promise;
267
- },
268
- readScreenshot(app) {
269
- return screenshot('screenshot-read', { app });
270
- },
271
- readSiteScreenshot(site) {
272
- return screenshot('site-screenshot-read', { site });
273
- },
274
- status() {
275
- return status;
276
- },
277
- onStatus(listener) {
278
- statusListeners.add(listener);
279
- return () => {
280
- statusListeners.delete(listener);
281
- };
282
- },
283
- messages(listener) {
284
- messageListeners.add(listener);
285
- return () => {
286
- messageListeners.delete(listener);
287
- };
288
- },
289
- gaConsent(choice) {
290
- send({ house: 'ga/consent', choice });
291
- },
292
- };
293
- }
@@ -1,3 +0,0 @@
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,2 +0,0 @@
1
- export { connectHouseBridge, HouseCeremonyRefused, HouseNavigationRefused, } from './bridge.js';
2
- export { HOUSE_ADDRESS_MAX_CHARS } from './wire.js';
@@ -1,185 +0,0 @@
1
- import { appAddressOf } from '../../src/apps/remainder.js';
2
- import { tabChange } from '../../src/apps/tab.js';
3
- import { HOUSE_ADDRESS_MAX_CHARS } from './wire.js';
4
- const HOST_ORIGIN = 'https://house.invalid';
5
- function sameOriginPath(value) {
6
- if (typeof value !== 'string')
7
- return null;
8
- if (value.charAt(0) !== '/' || value.slice(0, 2) === '//')
9
- return null;
10
- if (value.length > HOUSE_ADDRESS_MAX_CHARS)
11
- return null;
12
- const target = new URL(value, HOST_ORIGIN);
13
- return target.origin === HOST_ORIGIN ? `${target.pathname}${target.search}${target.hash}` : null;
14
- }
15
- export function createHouseBridgeTestHost(options = {}) {
16
- const navigations = [];
17
- const ceremonies = [];
18
- const consents = [];
19
- const tabs = [];
20
- const externalCalls = [];
21
- const listeners = new Set();
22
- const queued = [];
23
- let started = false;
24
- let address = appAddressOf('/', options.address ?? '/');
25
- let status = options.status ?? null;
26
- let attached = () => { };
27
- const runtime = new Promise((resolve) => {
28
- attached = resolve;
29
- });
30
- function deliver(data) {
31
- const event = { data };
32
- if (!started) {
33
- queued.push(event);
34
- return;
35
- }
36
- queueMicrotask(() => {
37
- for (const listener of listeners)
38
- listener(event);
39
- });
40
- }
41
- function arrive(target, navigated) {
42
- address = target;
43
- deliver({ house: 'address', address, navigated });
44
- }
45
- function navigate(mode, asked) {
46
- const target = sameOriginPath(asked);
47
- if (target === null) {
48
- deliver({
49
- house: 'navigate-refused',
50
- address: typeof asked === 'string' ? asked : null,
51
- });
52
- return;
53
- }
54
- navigations.push({ mode: mode === 'replace' ? 'replace' : 'push', address: target });
55
- arrive(target, true);
56
- }
57
- function ceremony(path) {
58
- const target = sameOriginPath(path);
59
- if (target === null) {
60
- deliver({ house: 'ceremony-refused', path: typeof path === 'string' ? path : null });
61
- return;
62
- }
63
- ceremonies.push(target);
64
- }
65
- function publish(next) {
66
- status = next;
67
- deliver({ house: 'status', status });
68
- }
69
- function consent(choice) {
70
- if (status === null || !status.ga.measures)
71
- return;
72
- if (choice !== 'accept' && choice !== 'decline')
73
- return;
74
- consents.push(choice);
75
- publish({ ...status, ga: { measures: true, choice } });
76
- }
77
- function receive(message) {
78
- const data = message;
79
- if (data === null || typeof data !== 'object')
80
- return;
81
- if (data.house === 'navigate') {
82
- navigate(data.mode, data.address);
83
- }
84
- else if (data.house === 'ceremony') {
85
- ceremony(data.path);
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
- }
96
- else if (data.house === 'tab') {
97
- const change = tabChange(data);
98
- if (change !== null)
99
- tabs.push(change);
100
- }
101
- else if (data.house === 'ga/consent') {
102
- consent(data.choice);
103
- }
104
- else if (data.house === 'source-top' || data.house === 'source-download') {
105
- const asked = data;
106
- const source = options.sources?.find((entry) => entry.top.app_ref === asked.app);
107
- const answer = source === undefined ? null : data.house === 'source-top' ? source.top : source.download;
108
- deliver({ house: 'source', id: asked.id, answer });
109
- }
110
- else if (data.house === 'attachment-read') {
111
- const asked = data;
112
- const image = options.images?.find((entry) => entry.room === asked.room && entry.attachment === asked.attachment);
113
- deliver({
114
- house: 'attachment',
115
- id: asked.id,
116
- image: image === undefined
117
- ? null
118
- : {
119
- attachment: image.attachment,
120
- copy: image.copy,
121
- name: image.name,
122
- media_type: image.media_type,
123
- },
124
- });
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
- }
131
- else if (data.house === 'screenshot-read') {
132
- const asked = data;
133
- const held = typeof asked.app === 'string' ? options.screenshots?.[asked.app] : undefined;
134
- deliver({ house: 'screenshot', id: asked.id, image: held ?? null });
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
- }
141
- }
142
- const port = {
143
- postMessage(message) {
144
- receive(message);
145
- },
146
- addEventListener(_type, listener) {
147
- listeners.add(listener);
148
- },
149
- start() {
150
- started = true;
151
- const held = queued.splice(0);
152
- queueMicrotask(() => {
153
- for (const event of held) {
154
- for (const listener of listeners)
155
- listener(event);
156
- }
157
- });
158
- },
159
- };
160
- if (status !== null)
161
- deliver({ house: 'status', status });
162
- deliver({ house: 'address', address, navigated: false });
163
- return {
164
- port: Promise.resolve(port),
165
- runtime,
166
- navigations,
167
- ceremonies,
168
- consents,
169
- tabs,
170
- externalCalls,
171
- address() {
172
- return address;
173
- },
174
- open(asked) {
175
- arrive(appAddressOf('/', asked), false);
176
- },
177
- publish,
178
- message(message) {
179
- deliver({ house: 'app-message', from: message.from, body: message.body });
180
- },
181
- attachRuntime(client) {
182
- attached(client);
183
- },
184
- };
185
- }
@@ -1,87 +0,0 @@
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: unknown): 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;
25
- readonly runtime: string;
26
- readonly credential: HouseCredentialState;
27
- readonly sync: HouseSyncState | null;
28
- readonly refusal: {
29
- readonly reason: string;
30
- } | null;
31
- readonly loss: string | null;
32
- readonly ga: HouseGa;
33
- readonly service_mode: 'open' | 'write-halted' | null;
34
- }
35
- export type HouseNavigationMode = 'push' | 'replace';
36
- interface HouseRelease {
37
- readonly app_ref: string;
38
- readonly release: string;
39
- }
40
- export interface HouseSourceTop extends HouseRelease {
41
- readonly folders: readonly string[];
42
- readonly files: readonly {
43
- readonly name: string;
44
- readonly size: number;
45
- }[];
46
- readonly readme: string | null;
47
- }
48
- export type HouseSourceFile = {
49
- readonly path: string;
50
- readonly size: number;
51
- } & ({
52
- readonly text: string;
53
- } | {
54
- readonly base64: string;
55
- });
56
- export interface HouseSourceDownload extends HouseRelease {
57
- readonly files: readonly HouseSourceFile[];
58
- }
59
- export type HouseTab = TabChange;
60
- export interface HouseExternalRequest {
61
- readonly url: string;
62
- readonly method?: string;
63
- readonly headers?: Readonly<Record<string, string>>;
64
- readonly body?: string;
65
- readonly body_base64?: string;
66
- readonly account?: string;
67
- readonly slot?: string;
68
- }
69
- export type HouseExternalAnswer = {
70
- readonly status: number;
71
- readonly headers: Readonly<Record<string, string | readonly string[]>>;
72
- } & ({
73
- readonly body: string;
74
- } | {
75
- readonly body_base64: string;
76
- });
77
- export interface HouseRefusal {
78
- readonly error: {
79
- readonly code: string;
80
- };
81
- }
82
- export interface HouseAppMessage {
83
- readonly from: string;
84
- readonly body: unknown;
85
- }
86
- export declare const HOUSE_ADDRESS_MAX_CHARS = 1000;
87
- export {};
@@ -1 +0,0 @@
1
- export const HOUSE_ADDRESS_MAX_CHARS = 1000;
@@ -1,2 +0,0 @@
1
- export declare function appAddressOf(root: string, address: string): string;
2
- export declare function shellAddressOf(root: string, appAddress: string): string;
@@ -1,16 +0,0 @@
1
- function split(address) {
2
- const mark = address.search(/[?#]/u);
3
- return mark < 0
4
- ? { path: address, rest: '' }
5
- : { path: address.slice(0, mark), rest: address.slice(mark) };
6
- }
7
- export function appAddressOf(root, address) {
8
- const { path, rest } = split(address);
9
- const below = root === '/' ? path : path.slice(root.length);
10
- return `${below.startsWith('/') ? below : `/${below}`}${rest}`;
11
- }
12
- export function shellAddressOf(root, appAddress) {
13
- const { path, rest } = split(appAddress);
14
- const below = path === '/' ? '' : path;
15
- return `${`${root === '/' ? '' : root}${below}` || '/'}${rest}`;
16
- }
@@ -1,6 +0,0 @@
1
- export declare const TAB_IMAGE: RegExp;
2
- export interface TabChange {
3
- readonly title?: string;
4
- readonly icon?: string;
5
- }
6
- export declare function tabChange(asked: unknown): TabChange | null;
@@ -1,27 +0,0 @@
1
- const TAB_TITLE_MAX_CHARS = 200;
2
- const TAB_ICON_MAX_CHARS = 1_048_576;
3
- function caseless(text) {
4
- return text.replace(/[a-z]/gu, (letter) => `[${letter}${letter.toUpperCase()}]`);
5
- }
6
- export const TAB_IMAGE = new RegExp(`^${caseless('data:image/')}(?:svg\\+xml|${['png', 'gif', 'webp'].map(caseless).join('|')});${caseless('base64')},[A-Za-z0-9+/]+={0,2}$`, 'u');
7
- function admittedTitle(title) {
8
- return typeof title === 'string' && title.length <= TAB_TITLE_MAX_CHARS;
9
- }
10
- function admittedIcon(icon) {
11
- return typeof icon === 'string' && icon.length <= TAB_ICON_MAX_CHARS && TAB_IMAGE.test(icon);
12
- }
13
- export function tabChange(asked) {
14
- if (asked === null || typeof asked !== 'object')
15
- return null;
16
- const { title, icon } = asked;
17
- if (title === undefined && icon === undefined)
18
- return null;
19
- if (title !== undefined && !admittedTitle(title))
20
- return null;
21
- if (icon !== undefined && !admittedIcon(icon))
22
- return null;
23
- return {
24
- ...(title === undefined ? {} : { title: title }),
25
- ...(icon === undefined ? {} : { icon: icon }),
26
- };
27
- }