@econ-v1/rpc 7.2.10 → 7.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +26 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/describe-client-error.d.ts.map +1 -1
- package/dist/grants-contract.d.ts +7 -0
- package/dist/grants-contract.d.ts.map +1 -0
- package/dist/host-context-contract.d.ts +26 -0
- package/dist/host-context-contract.d.ts.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/message-port-client.d.ts +22 -4
- package/dist/message-port-client.d.ts.map +1 -1
- package/dist/model-picker-contract.d.ts +12 -4
- package/dist/model-picker-contract.d.ts.map +1 -1
- package/dist/protocol.d.ts +29 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/stage-context.d.ts +17 -1
- package/dist/stage-context.d.ts.map +1 -1
- package/dist/subscription-state.d.ts +35 -0
- package/dist/subscription-state.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/client.js +21 -2
- package/src/describe-client-error.js +28 -1
- package/src/grants-contract.js +8 -0
- package/src/host-context-contract.js +60 -0
- package/src/index.js +10 -0
- package/src/message-port-client.js +170 -29
- package/src/model-picker-contract.js +10 -4
- package/src/protocol.js +44 -1
- package/src/stage-context.js +6 -2
- package/src/subscription-state.js +74 -0
|
@@ -12,19 +12,42 @@
|
|
|
12
12
|
*/
|
|
13
13
|
const DOMAIN_COPY = {
|
|
14
14
|
"access-code-invalid": { message: "That access code wasn't accepted.", showRetry: false },
|
|
15
|
+
approval_denied: { message: "That action wasn't approved.", showRetry: false },
|
|
15
16
|
capability_not_declared: { message: "This app asked for something it isn't allowed to use.", showRetry: false },
|
|
16
17
|
capability_unavailable: { message: "This app isn't running on your node.", showRetry: true },
|
|
17
18
|
child_unavailable: { message: "This part of the app isn't available right now.", showRetry: false },
|
|
18
19
|
"code-already-bound": { message: "That access code has already been used.", showRetry: false },
|
|
19
20
|
conflict: { message: "Someone else changed this first.", showRetry: true },
|
|
21
|
+
forbidden: { message: "This app isn't allowed to do that.", showRetry: false },
|
|
22
|
+
invalid_payload: { message: "Some of those details weren't accepted.", showRetry: false },
|
|
20
23
|
invalid_request: { message: "Your node couldn't understand that request.", showRetry: false },
|
|
21
24
|
not_found: { message: "That isn't here any more.", showRetry: false },
|
|
25
|
+
scope_unavailable: { message: "There's nothing to look at there right now.", showRetry: true },
|
|
26
|
+
script_timeout: { message: "That script took too long and was stopped.", showRetry: false },
|
|
27
|
+
script_unavailable: { message: "Scripts can't run in this tab right now.", showRetry: true },
|
|
22
28
|
stage_composition_not_enabled: { message: "This screen can't open other apps here.", showRetry: false },
|
|
29
|
+
timeout: { message: "That took too long. Please try again.", showRetry: true },
|
|
23
30
|
unauthorized: { message: "This device isn't allowed to do that.", showRetry: false },
|
|
31
|
+
unknown_command: { message: "Your node doesn't support that action yet.", showRetry: false },
|
|
24
32
|
unsupported_query: { message: "Your node doesn't support this yet.", showRetry: false },
|
|
25
33
|
validation_failed: { message: "Some of those details weren't accepted.", showRetry: false },
|
|
26
34
|
};
|
|
27
35
|
|
|
36
|
+
/**
|
|
37
|
+
* A `forbidden` error names why in `details.reason`; each reason gets its own copy so the user
|
|
38
|
+
* learns what to do, not just that they were refused. An unrecognised reason falls back to the
|
|
39
|
+
* generic `forbidden` copy.
|
|
40
|
+
*
|
|
41
|
+
* @type {Readonly<Record<string, { readonly message: string, readonly showRetry: boolean }>>}
|
|
42
|
+
*/
|
|
43
|
+
const FORBIDDEN_REASON_COPY = {
|
|
44
|
+
grant_request_invalid: { message: "That permission request has expired. Ask again to continue.", showRetry: false },
|
|
45
|
+
not_exposed: { message: "That isn't available to apps.", showRetry: false },
|
|
46
|
+
not_granted: { message: "This app hasn't been given permission for that yet.", showRetry: false },
|
|
47
|
+
owner_only: { message: "Only the owner's paired device can do that.", showRetry: false },
|
|
48
|
+
reserved_key: { message: "That setting is managed by your node and can't be changed here.", showRetry: false },
|
|
49
|
+
};
|
|
50
|
+
|
|
28
51
|
/**
|
|
29
52
|
* Maps any thrown value to what the UI should do with it.
|
|
30
53
|
*
|
|
@@ -51,7 +74,11 @@ export function describeClientError(error) {
|
|
|
51
74
|
}
|
|
52
75
|
|
|
53
76
|
if (kind === "domain" && typeof code === "string") {
|
|
54
|
-
const
|
|
77
|
+
const details = /** @type {{ details?: unknown }} */ (error)?.details;
|
|
78
|
+
const reason = code === "forbidden" && typeof details === "object" && details !== null
|
|
79
|
+
? /** @type {{ reason?: unknown }} */ (details).reason
|
|
80
|
+
: undefined;
|
|
81
|
+
const copy = (typeof reason === "string" ? FORBIDDEN_REASON_COPY[reason] : undefined) ?? DOMAIN_COPY[code];
|
|
55
82
|
if (copy !== undefined) {
|
|
56
83
|
return Object.freeze({ ...copy, surface: /** @type {const} */ ("inline") });
|
|
57
84
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one grant the shell can record today: page inspection (console, network, page structure
|
|
5
|
+
* and owner-approved scripts). The kernel's grant policy and the shell's consent dialog and
|
|
6
|
+
* settings page all name it through this constant so the id cannot drift between them.
|
|
7
|
+
*/
|
|
8
|
+
export const DEVTOOLS_GRANT = "devtools.inspect";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {{ type: string, id: string, label?: string }} HostContextRef
|
|
5
|
+
* @typedef {{ screen: string, entity?: HostContextRef, selection?: HostContextRef[], summary?: string, facts?: Record<string, string | number | boolean | null> }} HostContextView
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Serialized size limit of one `ctx.host.context.set` view (spec §4.5.2). */
|
|
9
|
+
export const HOST_CONTEXT_MAX_BYTES = 4096;
|
|
10
|
+
const VIEW_KEYS = new Set(["screen", "entity", "selection", "summary", "facts"]);
|
|
11
|
+
const REF_KEYS = new Set(["type", "id", "label"]);
|
|
12
|
+
|
|
13
|
+
/** UTF-8 length without `TextEncoder`, which this platform-free package cannot assume.
|
|
14
|
+
* @param {string} text */
|
|
15
|
+
function utf8Length(text) {
|
|
16
|
+
let bytes = 0;
|
|
17
|
+
for (const char of text) {
|
|
18
|
+
const code = /** @type {number} */ (char.codePointAt(0));
|
|
19
|
+
bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
|
|
20
|
+
}
|
|
21
|
+
return bytes;
|
|
22
|
+
}
|
|
23
|
+
/** @param {unknown} value @returns {value is HostContextRef} */
|
|
24
|
+
function isRef(value) {
|
|
25
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
26
|
+
const ref = /** @type {Record<string, unknown>} */ (value);
|
|
27
|
+
return Object.keys(ref).every((key) => REF_KEYS.has(key)) && typeof ref.type === "string" && typeof ref.id === "string"
|
|
28
|
+
&& (ref.label === undefined || typeof ref.label === "string");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The one rule set for a stage-reported view. The shell checks it before reporting and the
|
|
33
|
+
* kernel checks it again on receipt, so neither trusts the other's copy. Returns a detached
|
|
34
|
+
* JSON copy on success (caller getters are read once) or the reason it was rejected.
|
|
35
|
+
* @param {unknown} value @returns {string | HostContextView}
|
|
36
|
+
*/
|
|
37
|
+
export function validateHostContextView(value) {
|
|
38
|
+
try {
|
|
39
|
+
const json = JSON.stringify(value);
|
|
40
|
+
if (utf8Length(json) > HOST_CONTEXT_MAX_BYTES) return `view exceeds ${HOST_CONTEXT_MAX_BYTES} bytes`;
|
|
41
|
+
value = JSON.parse(json);
|
|
42
|
+
} catch { return "view must be JSON"; }
|
|
43
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "view must be an object";
|
|
44
|
+
const view = /** @type {Record<string, unknown>} */ (value);
|
|
45
|
+
const unknown = Object.keys(view).find((key) => !VIEW_KEYS.has(key));
|
|
46
|
+
if (unknown !== undefined) return `view has unknown key ${unknown}`;
|
|
47
|
+
if (typeof view.screen !== "string" || view.screen.length === 0 || view.screen.length > 64) return "screen must be 1–64 chars";
|
|
48
|
+
if (view.summary !== undefined && (typeof view.summary !== "string" || view.summary.length > 280)) return "summary must be ≤ 280 chars";
|
|
49
|
+
if (view.entity !== undefined && !isRef(view.entity)) return "entity must be { type, id, label? } strings";
|
|
50
|
+
if (view.selection !== undefined) {
|
|
51
|
+
if (!Array.isArray(view.selection) || view.selection.length > 20) return "selection must have ≤ 20 items";
|
|
52
|
+
if (!view.selection.every(isRef)) return "selection items must be { type, id, label? } strings";
|
|
53
|
+
}
|
|
54
|
+
if (view.facts !== undefined) {
|
|
55
|
+
if (view.facts === null || typeof view.facts !== "object" || Array.isArray(view.facts)) return "facts must be an object";
|
|
56
|
+
const entries = Object.entries(view.facts);
|
|
57
|
+
if (entries.length > 32 || entries.some(([, v]) => v !== null && !["string", "number", "boolean"].includes(typeof v))) return "facts must have ≤ 32 primitive values";
|
|
58
|
+
}
|
|
59
|
+
return /** @type {HostContextView} */ (view);
|
|
60
|
+
}
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
/** @typedef {import("./client.js").PreparedStage} PreparedStage */
|
|
9
9
|
/** @typedef {import("./client.js").StageRpcSession} StageRpcSession */
|
|
10
10
|
/** @typedef {import("./client.js").Unsubscribe} Unsubscribe */
|
|
11
|
+
/** @typedef {import("./client.js").EventSubscribeOptions} EventSubscribeOptions */
|
|
12
|
+
/** @typedef {import("./client.js").EventSubscription} EventSubscription */
|
|
13
|
+
/** @typedef {import("./client.js").SubscriptionState} SubscriptionState */
|
|
11
14
|
/** @typedef {import("@econ-v1/domain").ClientError} ClientError */
|
|
12
15
|
/** @typedef {import("@econ-v1/domain").DomainEvent} DomainEvent */
|
|
13
16
|
/** @typedef {import("@econ-v1/domain").DomainErrorCode} DomainErrorCode */
|
|
@@ -201,9 +204,12 @@ export {
|
|
|
201
204
|
serializeClientError,
|
|
202
205
|
} from "./errors.js";
|
|
203
206
|
export { DEFAULT_REQUEST_TIMEOUT_MS, KernelRpcTimeoutError, MessagePortKernelRpcClient } from "./message-port-client.js";
|
|
207
|
+
export { SubscriptionStateCell } from "./subscription-state.js";
|
|
204
208
|
export { NODE_APP_LIFECYCLE_COMMAND, NODE_APP_MEMORY_CAPTURE_COMMAND, NODE_APP_MEMORY_DETAIL_QUERY, NODE_APP_MEMORY_DOWNLOAD_COMMAND, NODE_APP_MEMORY_LIST_QUERY, NODE_APP_MEMORY_OVERVIEW_QUERY, NODE_APP_MEMORY_STATUS_COMMAND } from "./node-app-memory-contract.js";
|
|
205
209
|
export { APPS_LIFECYCLE_PIN_COMMAND, APPS_LIFECYCLE_QUERY } from "./apps-lifecycle-contract.js";
|
|
206
210
|
export { CLIENT_SERVICES_RETRY_COMMAND } from "./client-services-contract.js";
|
|
211
|
+
export { DEVTOOLS_GRANT } from "./grants-contract.js";
|
|
212
|
+
export { HOST_CONTEXT_MAX_BYTES, validateHostContextView } from "./host-context-contract.js";
|
|
207
213
|
export { L402_ENFORCEMENT_QUERY, L402_ENFORCEMENT_ROW_ID, L402_ENFORCEMENT_SET_COMMAND } from "./l402-enforcement-contract.js";
|
|
208
214
|
export { isRouteMode, MODEL_PICKER_CATALOG_QUERY, MODEL_PICKER_CATALOG_ROW_ID, MODEL_PICKER_RECENTS_LIMIT, MODEL_PICKER_RECENTS_SETTING, MODEL_PICKER_STAGE_ID, ROUTE_MODES, WEB_SEARCH_AUTO } from "./model-picker-contract.js";
|
|
209
215
|
export {
|
|
@@ -276,3 +282,7 @@ export { CHILD_ACTIONS, CHILD_STATE_CODES, describeChildState, isTransientChildS
|
|
|
276
282
|
/** @typedef {import("./child-state-contract.js").ChildAction} ChildAction */
|
|
277
283
|
export { createNodeHealthQuery, NODE_HEALTH_QUERY } from "./node-health-query.js";
|
|
278
284
|
export { createResponsivenessTracker, EMA_HOLD_MS } from "./node-responsiveness.js";
|
|
285
|
+
/** @typedef {import("@econ-v1/ports").DevtoolsScriptContext} DevtoolsScriptContext */
|
|
286
|
+
/** @typedef {import("@econ-v1/ports").DevtoolsScriptHostPort} DevtoolsScriptHostPort */
|
|
287
|
+
/** @typedef {import("./host-context-contract.js").HostContextRef} HostContextRef */
|
|
288
|
+
/** @typedef {import("./host-context-contract.js").HostContextView} HostContextView */
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import {
|
|
3
3
|
DomainError,
|
|
4
|
+
SyncError,
|
|
4
5
|
TransportError,
|
|
5
6
|
} from "@econ-v1/domain";
|
|
6
7
|
|
|
7
8
|
import { isJsonValue } from "./errors.js";
|
|
8
9
|
import { EMA_REFERENCE_TIMEOUT_MS, slowBarMs } from "./node-responsiveness.js";
|
|
10
|
+
import { SubscriptionStateCell } from "./subscription-state.js";
|
|
9
11
|
import {
|
|
10
12
|
deserializeRpcEvent,
|
|
11
13
|
deserializeRpcResponse,
|
|
@@ -20,11 +22,18 @@ import {
|
|
|
20
22
|
/** @typedef {import("./client.js").PreparedStage} PreparedStage */
|
|
21
23
|
/** @typedef {import("./client.js").StageRpcSession} StageRpcSession */
|
|
22
24
|
/** @typedef {import("./client.js").Unsubscribe} Unsubscribe */
|
|
25
|
+
/** @typedef {import("./client.js").EventSubscribeOptions} EventSubscribeOptions */
|
|
26
|
+
/** @typedef {import("./client.js").EventSubscription} EventSubscription */
|
|
27
|
+
/** @typedef {import("./client.js").SubscriptionState} SubscriptionState */
|
|
23
28
|
/** @typedef {import("./protocol.js").RpcRequest} RpcRequest */
|
|
24
29
|
/** @typedef {RpcRequest extends infer TRequest ? TRequest extends RpcRequest ? Omit<TRequest, "id"> : never : never} RpcRequestWithoutId */
|
|
25
30
|
/** @typedef {import("./transport.js").RpcTransport} RpcTransport */
|
|
26
31
|
/** @typedef {{ readonly reject: (error: unknown) => void, readonly resolve: (value: JsonValue) => void, readonly sessionId: string | undefined, readonly slowBarId: ReturnType<typeof globalThis.setTimeout>, readonly startedAtMs: number, readonly timeoutId: ReturnType<typeof globalThis.setTimeout>, readonly timeoutMs: number }} PendingRequest */
|
|
27
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* `cell` counts the group's own generations rather than the kernel's, because a new kernel
|
|
34
|
+
* (a leader-tab failover replays this subscription onto it) starts its count again.
|
|
35
|
+
* @typedef {{ readonly cell: SubscriptionStateCell, readonly eventType: KernelEventType, readonly listeners: Set<(event: DomainEvent) => void>, readonly pendingReady: Set<(error: TransportError) => void>, refusal?: unknown, readonly sessionId: string | undefined, readonly subscriptionId: string }} EventGroup
|
|
36
|
+
*/
|
|
28
37
|
/** @typedef {{ readonly errorListeners: Set<(error: { readonly message: string }) => void>, readonly listeners: Set<(diff: import("@econ-v1/domain").QueryDiff<JsonValue>) => void>, readonly name: QueryName, readonly params: JsonValue, retryTimer: ReturnType<typeof globalThis.setTimeout> | undefined, readonly sessionId: string | undefined, subscriptionId: string }} LiveQueryGroup */
|
|
29
38
|
/** @typedef {{ markSlow(id: string): void, settle(id: string, elapsedMs: number, answered: boolean, timeoutMs: number): void }} RpcResponsivenessObserver */
|
|
30
39
|
/** @typedef {{ readonly monotonicNow?: () => number, readonly now?: () => string, readonly observer?: RpcResponsivenessObserver, readonly requestTimeoutMs?: number }} MessagePortKernelRpcClientOptions */
|
|
@@ -169,6 +178,8 @@ export class MessagePortKernelRpcClient {
|
|
|
169
178
|
/** @type {() => void} */
|
|
170
179
|
#unsubscribeMessages;
|
|
171
180
|
#closed = false;
|
|
181
|
+
/** @type {((name: string, payload: JsonValue) => Promise<JsonValue>) | undefined} */
|
|
182
|
+
#uiRequestHandler;
|
|
172
183
|
#nextRequestId = 1;
|
|
173
184
|
#nextSubscriptionId = 1;
|
|
174
185
|
|
|
@@ -288,10 +299,11 @@ export class MessagePortKernelRpcClient {
|
|
|
288
299
|
* @template {KernelEventType} T
|
|
289
300
|
* @param {T} type
|
|
290
301
|
* @param {(event: import("@econ-v1/domain").KernelEventByType<T>) => void} listener
|
|
291
|
-
* @
|
|
302
|
+
* @param {EventSubscribeOptions} [options]
|
|
303
|
+
* @returns {EventSubscription}
|
|
292
304
|
*/
|
|
293
|
-
subscribe(type, listener) {
|
|
294
|
-
return this.#subscribeEvent(undefined, type, listener);
|
|
305
|
+
subscribe(type, listener, options) {
|
|
306
|
+
return this.#subscribeEvent(undefined, type, listener, options);
|
|
295
307
|
}
|
|
296
308
|
|
|
297
309
|
/** @param {string} sessionId */
|
|
@@ -322,6 +334,42 @@ export class MessagePortKernelRpcClient {
|
|
|
322
334
|
}));
|
|
323
335
|
}
|
|
324
336
|
|
|
337
|
+
/** @template T @param {string} sessionId @param {string} name @param {unknown} payload @returns {Promise<T>} */
|
|
338
|
+
commandStage(sessionId, name, payload) {
|
|
339
|
+
return /** @type {Promise<T>} */ (this.#request({ name, payload: jsonValue(payload, "command payload"), sessionId, type: "stage.command" }));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** @param {(name: string, payload: JsonValue) => Promise<JsonValue>} handler @returns {Unsubscribe} */
|
|
343
|
+
onUiRequest(handler) {
|
|
344
|
+
this.#uiRequestHandler = handler;
|
|
345
|
+
return () => { if (this.#uiRequestHandler === handler) this.#uiRequestHandler = undefined; };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** @param {{ readonly name: string, readonly payload: JsonValue, readonly requestId: string }} event */
|
|
349
|
+
async #answerUiRequest(event) {
|
|
350
|
+
const handler = this.#uiRequestHandler;
|
|
351
|
+
/** @type {RpcRequestWithoutId} */
|
|
352
|
+
let response;
|
|
353
|
+
try {
|
|
354
|
+
if (handler === undefined) throw new TransportError({ code: "unavailable", message: "No shell is handling UI requests in this tab" });
|
|
355
|
+
const value = jsonValue(await handler(event.name, event.payload), "UI response value");
|
|
356
|
+
response = { ok: true, requestId: event.requestId, type: "ui.response", value };
|
|
357
|
+
} catch (error) {
|
|
358
|
+
const clientError = error instanceof DomainError || error instanceof SyncError || error instanceof TransportError
|
|
359
|
+
? error
|
|
360
|
+
: new DomainError({ code: "invalid_request", message: "UI request handler failed" });
|
|
361
|
+
response = { error: clientError, ok: false, requestId: event.requestId, type: "ui.response" };
|
|
362
|
+
}
|
|
363
|
+
if (this.#closed || this.#transport.state !== "open") return;
|
|
364
|
+
const id = `request-${this.#nextRequestId}`;
|
|
365
|
+
this.#nextRequestId += 1;
|
|
366
|
+
try {
|
|
367
|
+
this.#transport.send(serializeRpcRequest(/** @type {RpcRequest} */ ({ ...response, id })));
|
|
368
|
+
} catch {
|
|
369
|
+
// UI responses are fire-and-forget; a closed transport cannot receive them.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
325
373
|
/**
|
|
326
374
|
* @template T
|
|
327
375
|
* @param {string} sessionId
|
|
@@ -368,10 +416,11 @@ export class MessagePortKernelRpcClient {
|
|
|
368
416
|
* @param {string} sessionId
|
|
369
417
|
* @param {T} type
|
|
370
418
|
* @param {(event: import("@econ-v1/domain").KernelEventByType<T>) => void} listener
|
|
371
|
-
* @
|
|
419
|
+
* @param {EventSubscribeOptions} [options]
|
|
420
|
+
* @returns {EventSubscription}
|
|
372
421
|
*/
|
|
373
|
-
subscribeStage(sessionId, type, listener) {
|
|
374
|
-
return this.#subscribeEvent(sessionId, type, listener);
|
|
422
|
+
subscribeStage(sessionId, type, listener, options) {
|
|
423
|
+
return this.#subscribeEvent(sessionId, type, listener, options);
|
|
375
424
|
}
|
|
376
425
|
|
|
377
426
|
#handleClose() {
|
|
@@ -381,6 +430,11 @@ export class MessagePortKernelRpcClient {
|
|
|
381
430
|
this.#unsubscribeClose();
|
|
382
431
|
const error = new TransportError({ code: "connection_lost", message: "Kernel RPC port closed" });
|
|
383
432
|
for (const requestId of [...this.#pending.keys()]) this.#takePending(requestId, false)?.reject(error);
|
|
433
|
+
// Tell each subscription it went quiet, instead of leaving its listeners silent.
|
|
434
|
+
for (const group of this.#eventGroupsByKey.values()) {
|
|
435
|
+
group.cell.report("lost");
|
|
436
|
+
this.#cancelEventGroupReady(group, error);
|
|
437
|
+
}
|
|
384
438
|
this.#eventGroupsByKey.clear();
|
|
385
439
|
this.#eventGroupsBySubscription.clear();
|
|
386
440
|
// A pending re-subscribe timer must not outlive the port it would retry on — otherwise a
|
|
@@ -429,6 +483,10 @@ export class MessagePortKernelRpcClient {
|
|
|
429
483
|
|
|
430
484
|
try {
|
|
431
485
|
const event = deserializeRpcEvent(message);
|
|
486
|
+
if (event.type === "ui.request") {
|
|
487
|
+
void this.#answerUiRequest(event);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
432
490
|
if (event.type === "kernel.event") {
|
|
433
491
|
const group = this.#eventGroupsBySubscription.get(event.subscriptionId);
|
|
434
492
|
if (group === undefined || group.eventType !== event.event.type) return;
|
|
@@ -448,6 +506,11 @@ export class MessagePortKernelRpcClient {
|
|
|
448
506
|
for (const listener of [...group.errorListeners]) this.#notify(() => listener({ message: failureMessage }));
|
|
449
507
|
return;
|
|
450
508
|
}
|
|
509
|
+
if (event.type === "subscription.state") {
|
|
510
|
+
const group = this.#eventGroupsBySubscription.get(event.subscriptionId);
|
|
511
|
+
if (group !== undefined) group.cell.report(event.state.status, event.state.confirmed, event.state.reason);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
451
514
|
const group = this.#liveGroupsBySubscription.get(event.subscriptionId);
|
|
452
515
|
if (group === undefined) return;
|
|
453
516
|
for (const listener of [...group.listeners]) this.#notify(() => listener(event.diff));
|
|
@@ -495,6 +558,8 @@ export class MessagePortKernelRpcClient {
|
|
|
495
558
|
#removeSessionSubscriptions(sessionId) {
|
|
496
559
|
for (const [key, group] of [...this.#eventGroupsByKey]) {
|
|
497
560
|
if (group.sessionId === sessionId) {
|
|
561
|
+
group.cell.report("lost");
|
|
562
|
+
this.#cancelEventGroupReady(group, new TransportError({ code: "connection_lost", message: "Stage RPC session closed" }));
|
|
498
563
|
this.#eventGroupsByKey.delete(key);
|
|
499
564
|
this.#eventGroupsBySubscription.delete(group.subscriptionId);
|
|
500
565
|
}
|
|
@@ -559,32 +624,53 @@ export class MessagePortKernelRpcClient {
|
|
|
559
624
|
* @param {string | undefined} sessionId
|
|
560
625
|
* @param {T} eventType
|
|
561
626
|
* @param {(event: import("@econ-v1/domain").KernelEventByType<T>) => void} listener
|
|
562
|
-
* @
|
|
627
|
+
* @param {EventSubscribeOptions} [options]
|
|
628
|
+
* @returns {EventSubscription}
|
|
563
629
|
*/
|
|
564
|
-
#subscribeEvent(sessionId, eventType, listener) {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
const subscriptionId = this.#newSubscriptionId();
|
|
569
|
-
group = { eventType, listeners: new Set(), sessionId, subscriptionId };
|
|
570
|
-
this.#eventGroupsByKey.set(key, group);
|
|
571
|
-
this.#eventGroupsBySubscription.set(subscriptionId, group);
|
|
572
|
-
const request = sessionId === undefined
|
|
573
|
-
? /** @type {const} */ ({ eventType, subscriptionId, type: "event.subscribe" })
|
|
574
|
-
: /** @type {const} */ ({ eventType, sessionId, subscriptionId, type: "stage.event.subscribe" });
|
|
575
|
-
void this.#request(request).catch((error) => {
|
|
576
|
-
this.#dropEventGroup(key, /** @type {EventGroup} */ (group));
|
|
577
|
-
warnSubscriptionRejected("event", eventType, sessionId, error);
|
|
578
|
-
});
|
|
630
|
+
#subscribeEvent(sessionId, eventType, listener, options) {
|
|
631
|
+
if (this.#closed || this.#transport.state !== "open") {
|
|
632
|
+
if (!this.#closed) this.#handleClose();
|
|
633
|
+
return this.#lostEventSubscription(options);
|
|
579
634
|
}
|
|
635
|
+
const key = `${sessionId ?? "host"}:event:${eventType}`;
|
|
636
|
+
const group = this.#eventGroupsByKey.get(key) ?? this.#openEventGroup(key, sessionId, eventType);
|
|
637
|
+
const { cell } = group;
|
|
580
638
|
const storedListener = /** @type {(event: DomainEvent) => void} */ (listener);
|
|
581
639
|
group.listeners.add(storedListener);
|
|
640
|
+
const unwatch = options?.onState && cell.watch(options.onState);
|
|
641
|
+
// This handle's own `ready`: a group that joined while lost or pending must wait for
|
|
642
|
+
// its next `live`, not reuse one from before the gap.
|
|
643
|
+
/** @type {((error: TransportError) => void) | undefined} */
|
|
644
|
+
let cancelReady;
|
|
645
|
+
/** @type {Promise<SubscriptionState>} */
|
|
646
|
+
const ready = new Promise((resolve, reject) => {
|
|
647
|
+
const off = cell.watch((state) => {
|
|
648
|
+
if (state.status === "live") resolve(state);
|
|
649
|
+
else if (state.status === "refused") reject(group.refusal ?? new TransportError({ code: "unavailable", message: "The home node refused this subscription" }));
|
|
650
|
+
else return;
|
|
651
|
+
off();
|
|
652
|
+
group.pendingReady.delete(cancel);
|
|
653
|
+
cancelReady = undefined;
|
|
654
|
+
});
|
|
655
|
+
const cancel = (/** @type {TransportError} */ error) => {
|
|
656
|
+
off();
|
|
657
|
+
group.pendingReady.delete(cancel);
|
|
658
|
+
cancelReady = undefined;
|
|
659
|
+
reject(error);
|
|
660
|
+
};
|
|
661
|
+
group.pendingReady.add(cancel);
|
|
662
|
+
cancelReady = cancel;
|
|
663
|
+
});
|
|
664
|
+
// A holder that never looks at `ready` must not surface a refusal as unhandled.
|
|
665
|
+
ready.catch(() => undefined);
|
|
582
666
|
let active = true;
|
|
583
|
-
|
|
667
|
+
const unsubscribe = () => {
|
|
584
668
|
if (!active) return;
|
|
585
669
|
active = false;
|
|
586
|
-
|
|
587
|
-
|
|
670
|
+
unwatch?.();
|
|
671
|
+
cancelReady?.(new TransportError({ code: "connection_lost", message: "Subscription handle closed" }));
|
|
672
|
+
group.listeners.delete(storedListener);
|
|
673
|
+
if (group.listeners.size === 0) {
|
|
588
674
|
this.#dropEventGroup(key, group);
|
|
589
675
|
const request = sessionId === undefined
|
|
590
676
|
? /** @type {const} */ ({ subscriptionId: group.subscriptionId, type: "event.unsubscribe" })
|
|
@@ -592,6 +678,49 @@ export class MessagePortKernelRpcClient {
|
|
|
592
678
|
void this.#request(request).catch(() => undefined);
|
|
593
679
|
}
|
|
594
680
|
};
|
|
681
|
+
return /** @type {EventSubscription} */ (Object.defineProperty(Object.assign(unsubscribe, { ready }), "state", { get: () => cell.state }));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** @param {EventSubscribeOptions | undefined} options @returns {EventSubscription} */
|
|
685
|
+
#lostEventSubscription(options) {
|
|
686
|
+
const cell = new SubscriptionStateCell();
|
|
687
|
+
cell.report("lost");
|
|
688
|
+
const unwatch = options?.onState && cell.watch(options.onState);
|
|
689
|
+
const ready = Promise.reject(new TransportError({ code: "connection_lost", message: "Kernel RPC port is closed" }));
|
|
690
|
+
ready.catch(() => undefined);
|
|
691
|
+
const unsubscribe = () => unwatch?.();
|
|
692
|
+
return /** @type {EventSubscription} */ (Object.defineProperty(Object.assign(unsubscribe, { ready }), "state", { get: () => cell.state }));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* @param {string} key
|
|
697
|
+
* @param {string | undefined} sessionId
|
|
698
|
+
* @param {KernelEventType} eventType
|
|
699
|
+
* @returns {EventGroup}
|
|
700
|
+
*/
|
|
701
|
+
#openEventGroup(key, sessionId, eventType) {
|
|
702
|
+
const subscriptionId = this.#newSubscriptionId();
|
|
703
|
+
/** @type {EventGroup} */
|
|
704
|
+
const group = { cell: new SubscriptionStateCell(), eventType, listeners: new Set(), pendingReady: new Set(), sessionId, subscriptionId };
|
|
705
|
+
this.#eventGroupsByKey.set(key, group);
|
|
706
|
+
this.#eventGroupsBySubscription.set(subscriptionId, group);
|
|
707
|
+
const request = sessionId === undefined
|
|
708
|
+
? /** @type {const} */ ({ eventType, subscriptionId, type: "event.subscribe" })
|
|
709
|
+
: /** @type {const} */ ({ eventType, sessionId, subscriptionId, type: "stage.event.subscribe" });
|
|
710
|
+
void this.#request(request).then((reply) => {
|
|
711
|
+
if (this.#eventGroupsByKey.get(key) !== group) return;
|
|
712
|
+
// `{ subscriptionState: 1 }` means state events follow, before or after this
|
|
713
|
+
// reply. `null` means none will come for this subscription (an older kernel, or
|
|
714
|
+
// a type a stage session adapter serves): live, but unconfirmed.
|
|
715
|
+
if (reply === null && !group.cell.state) group.cell.report("live");
|
|
716
|
+
}, (error) => {
|
|
717
|
+
if (this.#eventGroupsByKey.get(key) !== group) return;
|
|
718
|
+
group.refusal = error;
|
|
719
|
+
group.cell.report("refused");
|
|
720
|
+
this.#dropEventGroup(key, group);
|
|
721
|
+
warnSubscriptionRejected("event", eventType, sessionId, error);
|
|
722
|
+
});
|
|
723
|
+
return group;
|
|
595
724
|
}
|
|
596
725
|
|
|
597
726
|
/**
|
|
@@ -710,6 +839,11 @@ export class MessagePortKernelRpcClient {
|
|
|
710
839
|
this.#eventGroupsBySubscription.delete(group.subscriptionId);
|
|
711
840
|
}
|
|
712
841
|
|
|
842
|
+
/** @param {EventGroup} group @param {TransportError} error */
|
|
843
|
+
#cancelEventGroupReady(group, error) {
|
|
844
|
+
for (const cancel of [...group.pendingReady]) cancel(error);
|
|
845
|
+
}
|
|
846
|
+
|
|
713
847
|
/**
|
|
714
848
|
* @param {string} key
|
|
715
849
|
* @param {LiveQueryGroup} group
|
|
@@ -752,6 +886,12 @@ class BoundStageRpcSession {
|
|
|
752
886
|
this.#client.closeStageSession(this.#sessionId);
|
|
753
887
|
}
|
|
754
888
|
|
|
889
|
+
/** @template T @param {string} name @param {unknown} payload @returns {Promise<T>} */
|
|
890
|
+
command(name, payload) {
|
|
891
|
+
this.#assertOpen();
|
|
892
|
+
return this.#client.commandStage(this.#sessionId, name, payload);
|
|
893
|
+
}
|
|
894
|
+
|
|
755
895
|
/**
|
|
756
896
|
* @template T
|
|
757
897
|
* @param {string} capability
|
|
@@ -802,11 +942,12 @@ class BoundStageRpcSession {
|
|
|
802
942
|
* @template {KernelEventType} T
|
|
803
943
|
* @param {T} type
|
|
804
944
|
* @param {(event: import("@econ-v1/domain").KernelEventByType<T>) => void} listener
|
|
805
|
-
* @
|
|
945
|
+
* @param {EventSubscribeOptions} [options]
|
|
946
|
+
* @returns {EventSubscription}
|
|
806
947
|
*/
|
|
807
|
-
subscribe(type, listener) {
|
|
948
|
+
subscribe(type, listener, options) {
|
|
808
949
|
this.#assertOpen();
|
|
809
|
-
return this.#client.subscribeStage(this.#sessionId, type, listener);
|
|
950
|
+
return this.#client.subscribeStage(this.#sessionId, type, listener, options);
|
|
810
951
|
}
|
|
811
952
|
|
|
812
953
|
#assertOpen() {
|
|
@@ -48,15 +48,21 @@ export const ROUTE_MODES = /** @type {const} */ (["auto", "public", "private"]);
|
|
|
48
48
|
* absent means this node runs it on its own key ("My Store"). */
|
|
49
49
|
/** @typedef {{ readonly name: string, readonly peerNodeId?: string }} ModelRef */
|
|
50
50
|
|
|
51
|
-
/** What the stage already knows, so the overlay opens on it instead of a default.
|
|
52
|
-
|
|
51
|
+
/** What the stage already knows, so the overlay opens on it instead of a default.
|
|
52
|
+
* `onSelect` hears each Select at once, while the overlay stays open for the chips:
|
|
53
|
+
* the pick carries `model` and the chips as they stand. The promise still resolves
|
|
54
|
+
* when the overlay closes, with the final pick, so a stage that applies both must
|
|
55
|
+
* treat the same model twice as one choice. A host before it ignores `onSelect`. */
|
|
56
|
+
/** @typedef {{ readonly current?: ModelRef, readonly routeMode?: RouteMode, readonly webSearch?: WebSearchChoice, readonly onSelect?: (pick: ModelPick) => void }} ModelPickRequest */
|
|
53
57
|
|
|
54
58
|
/** `model` is present only when the owner pressed Select on a row; absent means
|
|
55
59
|
* "keep whatever model the stage already had" — the owner changed only the
|
|
56
60
|
* web-search chip (or nothing but still closed with a chip change). `label` is
|
|
57
61
|
* the catalog's `picker_label ?? name`, so a pill or chip shows the same words
|
|
58
|
-
* the overlay did.
|
|
59
|
-
|
|
62
|
+
* the overlay did. `storeName` is the seller's store as the overlay named it
|
|
63
|
+
* (this node's own `mine.name`, or the peer's), so a chip can say whose store
|
|
64
|
+
* sells the model. */
|
|
65
|
+
/** @typedef {{ readonly model?: ModelRef, readonly label?: string, readonly storeName?: string, readonly routeMode: RouteMode, readonly webSearch: WebSearchChoice }} ModelPick */
|
|
60
66
|
|
|
61
67
|
/** Resolves `undefined` when the owner closes without choosing. Rejects with a
|
|
62
68
|
* `DomainError` `conflict` while another `open()` is still pending — the overlay
|
package/src/protocol.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/** @import { AppEventPayload, AppEventType, ClientError, CoreKernelEventType, DomainEvent, JsonObject, JsonValue, KernelEventPayload, QueryDiff, QueryName } from '@econ-v1/domain' */
|
|
4
4
|
/** @import { NodeReachability } from './transport-contract.js' */
|
|
5
|
+
/** @import { SubscriptionState } from './client.js' */
|
|
5
6
|
/** @typedef {{ readonly occurredAt: string; readonly payload: { readonly state?: string }; readonly type: "device-cleanup.changed" }} DeviceCleanupDomainEvent */
|
|
6
7
|
/** @typedef {DomainEvent | DeviceCleanupDomainEvent} ProtocolDomainEvent */
|
|
7
8
|
/** @typedef {CoreKernelEventType | "device-cleanup.changed"} ProtocolCoreKernelEventType */
|
|
@@ -36,6 +37,12 @@ export function serializeRpcRequest(request) {
|
|
|
36
37
|
return stringify({ appName: request.appName, id: request.id, type: request.type });
|
|
37
38
|
case "stage.open-session":
|
|
38
39
|
return stringify({ appName: request.appName, id: request.id, manifestSha256: request.manifestSha256, ...(request.surfaceId === undefined ? {} : { surfaceId: request.surfaceId }), type: request.type });
|
|
40
|
+
case "stage.command":
|
|
41
|
+
return stringify({ id: request.id, name: request.name, payload: request.payload, sessionId: request.sessionId, type: request.type });
|
|
42
|
+
case "ui.response":
|
|
43
|
+
return request.ok
|
|
44
|
+
? stringify({ id: request.id, ok: true, requestId: request.requestId, type: request.type, value: request.value })
|
|
45
|
+
: stringify({ error: serializeClientError(request.error), id: request.id, ok: false, requestId: request.requestId, type: request.type });
|
|
39
46
|
case "stage.query":
|
|
40
47
|
return stringify({ id: request.id, name: request.name, params: request.params, sessionId: request.sessionId, type: request.type });
|
|
41
48
|
case "stage.live-query.subscribe":
|
|
@@ -74,6 +81,13 @@ export function deserializeRpcRequest(serialized) {
|
|
|
74
81
|
const surfaceId = optionalStringField(value, "surfaceId");
|
|
75
82
|
return { appName: stringField(value, "appName"), id, manifestSha256: stringField(value, "manifestSha256"), ...(surfaceId === undefined ? {} : { surfaceId }), type };
|
|
76
83
|
}
|
|
84
|
+
case "stage.command": return { id, name: versionedNameField(value, "name"), payload: jsonField(value, "payload"), sessionId: stringField(value, "sessionId"), type };
|
|
85
|
+
case "ui.response": {
|
|
86
|
+
const requestId = stringField(value, "requestId");
|
|
87
|
+
if (value.ok === true) return { id, ok: true, requestId, type, value: jsonField(value, "value") };
|
|
88
|
+
if (value.ok === false && isSerializableClientError(value.error)) return { error: deserializeClientError(value.error), id, ok: false, requestId, type };
|
|
89
|
+
throw new TypeError("Invalid ui.response");
|
|
90
|
+
}
|
|
77
91
|
case "stage.query": return { id, name: queryNameField(value), params: jsonField(value, "params"), sessionId: stringField(value, "sessionId"), type };
|
|
78
92
|
case "stage.live-query.subscribe": return { id, name: queryNameField(value), params: jsonField(value, "params"), sessionId: stringField(value, "sessionId"), subscriptionId: stringField(value, "subscriptionId"), type };
|
|
79
93
|
case "stage.live-query.unsubscribe": return { id, sessionId: stringField(value, "sessionId"), subscriptionId: stringField(value, "subscriptionId"), type };
|
|
@@ -123,10 +137,14 @@ export function deserializeRpcResponse(serialized) {
|
|
|
123
137
|
* @returns {string}
|
|
124
138
|
*/
|
|
125
139
|
export function serializeRpcEvent(event) {
|
|
140
|
+
if (event.type === "ui.request")
|
|
141
|
+
return stringify({ name: event.name, payload: event.payload, requestId: event.requestId, type: event.type });
|
|
126
142
|
if (event.type === "live-query.changed")
|
|
127
143
|
return stringify({ diff: projectQueryDiff(event.diff), subscriptionId: event.subscriptionId, type: event.type });
|
|
128
144
|
if (event.type === "live-query.failed")
|
|
129
145
|
return stringify({ message: event.message, subscriptionId: event.subscriptionId, type: event.type });
|
|
146
|
+
if (event.type === "subscription.state")
|
|
147
|
+
return stringify({ state: event.state, subscriptionId: event.subscriptionId, type: event.type });
|
|
130
148
|
return stringify({ event: projectDomainEvent(event.event), subscriptionId: event.subscriptionId, type: event.type });
|
|
131
149
|
}
|
|
132
150
|
/**
|
|
@@ -135,6 +153,8 @@ export function serializeRpcEvent(event) {
|
|
|
135
153
|
*/
|
|
136
154
|
export function deserializeRpcEvent(serialized) {
|
|
137
155
|
const value = parseRecord(serialized);
|
|
156
|
+
if (value.type === "ui.request")
|
|
157
|
+
return { name: versionedNameField(value, "name"), payload: jsonField(value, "payload"), requestId: stringField(value, "requestId"), type: value.type };
|
|
138
158
|
const subscriptionId = stringField(value, "subscriptionId");
|
|
139
159
|
if (value.type === "live-query.changed")
|
|
140
160
|
return { diff: queryDiffField(value), subscriptionId, type: value.type };
|
|
@@ -142,8 +162,26 @@ export function deserializeRpcEvent(serialized) {
|
|
|
142
162
|
return { message: stringField(value, "message"), subscriptionId, type: value.type };
|
|
143
163
|
if (value.type === "kernel.event")
|
|
144
164
|
return { event: domainEventField(value), subscriptionId, type: value.type };
|
|
165
|
+
// Sent to one event subscription only (ADR-039). An older client throws below
|
|
166
|
+
// and drops it, which is the fallback it already had.
|
|
167
|
+
if (value.type === "subscription.state")
|
|
168
|
+
return { state: subscriptionStateField(value), subscriptionId, type: value.type };
|
|
145
169
|
throw new TypeError("Unknown RPC event type");
|
|
146
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* @param {JsonObject} value
|
|
173
|
+
* @returns {SubscriptionState}
|
|
174
|
+
*/
|
|
175
|
+
function subscriptionStateField(value) {
|
|
176
|
+
const { confirmed, generation, reason, status } = objectField(value, "state");
|
|
177
|
+
if (typeof confirmed !== "boolean" || !Number.isSafeInteger(generation) || /** @type {number} */ (generation) < 0 ||
|
|
178
|
+
!["live", "lost", "pending", "refused"].includes(/** @type {string} */ (status)) ||
|
|
179
|
+
!(reason === undefined || ["resubscribed", "source_restarted", "source_unavailable", "subscribed"].includes(/** @type {string} */ (reason)))) {
|
|
180
|
+
throw new TypeError("Expected a subscription state");
|
|
181
|
+
}
|
|
182
|
+
// Every field was checked against the closed vocabulary just above; this drops any other.
|
|
183
|
+
return /** @type {SubscriptionState} */ ({ confirmed, generation, ...(reason === undefined ? {} : { reason }), status });
|
|
184
|
+
}
|
|
147
185
|
/**
|
|
148
186
|
* @param {unknown} value
|
|
149
187
|
* @returns {string}
|
|
@@ -864,6 +902,9 @@ function entityActionField(value) {
|
|
|
864
902
|
}
|
|
865
903
|
/**
|
|
866
904
|
* @typedef {| { readonly id: string; readonly name: string; readonly payload: JsonValue; readonly type: "command" }
|
|
905
|
+
* | { readonly id: string; readonly name: string; readonly payload: JsonValue; readonly sessionId: string; readonly type: "stage.command" }
|
|
906
|
+
* | { readonly id: string; readonly ok: true; readonly requestId: string; readonly type: "ui.response"; readonly value: JsonValue }
|
|
907
|
+
* | { readonly error: ClientError; readonly id: string; readonly ok: false; readonly requestId: string; readonly type: "ui.response" }
|
|
867
908
|
* | { readonly id: string; readonly name: QueryName; readonly params: JsonValue; readonly type: "query" }
|
|
868
909
|
* | { readonly id: string; readonly name: QueryName; readonly params: JsonValue; readonly subscriptionId: string; readonly type: "live-query.subscribe" }
|
|
869
910
|
* | { readonly id: string; readonly subscriptionId: string; readonly type: "live-query.unsubscribe" }
|
|
@@ -887,6 +928,8 @@ function entityActionField(value) {
|
|
|
887
928
|
*/
|
|
888
929
|
/**
|
|
889
930
|
* @typedef {| { readonly diff: QueryDiff<JsonValue>; readonly subscriptionId: string; readonly type: "live-query.changed" }
|
|
931
|
+
* | { readonly name: string; readonly payload: JsonValue; readonly requestId: string; readonly type: "ui.request" }
|
|
890
932
|
* | { readonly message: string; readonly subscriptionId: string; readonly type: "live-query.failed" }
|
|
891
|
-
* | { readonly event: ProtocolDomainEvent; readonly subscriptionId: string; readonly type: "kernel.event" }
|
|
933
|
+
* | { readonly event: ProtocolDomainEvent; readonly subscriptionId: string; readonly type: "kernel.event" }
|
|
934
|
+
* | { readonly state: SubscriptionState; readonly subscriptionId: string; readonly type: "subscription.state" }} RpcEvent
|
|
892
935
|
*/
|
package/src/stage-context.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
/** @typedef {import("@econ-v1/domain").KernelEventType} KernelEventType */
|
|
4
4
|
/** @typedef {import("@econ-v1/domain").QueryName} QueryName */
|
|
5
5
|
/** @typedef {import("./client.js").Unsubscribe} Unsubscribe */
|
|
6
|
+
/** @typedef {import("./client.js").EventSubscribeOptions} EventSubscribeOptions */
|
|
7
|
+
/** @typedef {import("./client.js").EventSubscription} EventSubscription */
|
|
6
8
|
/** @typedef {import("./dialog-contract.js").DialogCenter} DialogCenter */
|
|
7
9
|
/** @typedef {import("./model-picker-contract.js").ModelPickerCenter} ModelPickerCenter */
|
|
8
10
|
/** @typedef {import("./system-notice-contract.js").SystemNoticeCenter} SystemNoticeCenter */
|
|
@@ -18,15 +20,17 @@
|
|
|
18
20
|
* readonly app: { readonly name: string, readonly version: string },
|
|
19
21
|
* readonly events: {
|
|
20
22
|
* publish<T extends KernelEventType>(type: T, payload: import("@econ-v1/domain").KernelEventPayload<T>): Promise<void>,
|
|
21
|
-
* subscribe<T extends KernelEventType>(type: T, listener: (event: import("@econ-v1/domain").KernelEventByType<T>) => void):
|
|
23
|
+
* subscribe<T extends KernelEventType>(type: T, listener: (event: import("@econ-v1/domain").KernelEventByType<T>) => void, options?: EventSubscribeOptions): EventSubscription
|
|
22
24
|
* },
|
|
23
25
|
* invokeCapability<T>(capability: string, payload: unknown): Promise<T>,
|
|
24
26
|
* liveQuery<T>(name: QueryName, params: unknown, listener: (diff: import("@econ-v1/domain").QueryDiff<T>) => void): Unsubscribe,
|
|
27
|
+
* command?: <T>(name: string, payload?: unknown) => Promise<T>,
|
|
25
28
|
* query<T>(name: QueryName, params: unknown): Promise<T>,
|
|
26
29
|
* readonly composition?: StageComposition,
|
|
27
30
|
* readonly stage: StageHost
|
|
28
31
|
* }} StageContextShared */
|
|
29
|
-
/** @typedef {{
|
|
32
|
+
/** @typedef {{ open(): void, close(): void, readonly isOpen: boolean, onVisibilityChange(cb: (open: boolean) => void): Unsubscribe, setStatus(text: string): void, setBadge(count: number): void }} AssistantPanelHost */
|
|
33
|
+
/** @typedef {{ navigate(path: string): void, readonly root: Element, readonly context: { set(view: object): void, clear(): void }, readonly panel?: AssistantPanelHost }} StageHostShared */
|
|
30
34
|
/** @typedef {StageContextShared & { readonly host: StageHostShared & { readonly uiApi: 1 } }} StageContextV1 */
|
|
31
35
|
/** @typedef {StageContextShared & { readonly host: StageHostShared & { readonly dialogs: DialogCenter, readonly modelPicker: ModelPickerCenter, readonly systemNotices: SystemNoticeCenter, readonly uiApi: 2 } }} StageContextV2 */
|
|
32
36
|
/** @typedef {StageContextV1 | StageContextV2} StageContext */
|