@econ-v1/rpc 6.6.0 → 6.10.0

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.
Files changed (79) hide show
  1. package/dist/client-device-pending-contract.d.ts +14 -0
  2. package/dist/client-device-pending-contract.d.ts.map +1 -0
  3. package/dist/client.d.ts +15 -13
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/device-boot-contract.d.ts +7 -0
  6. package/dist/device-boot-contract.d.ts.map +1 -0
  7. package/dist/device-cleanup-contract.d.ts +17 -0
  8. package/dist/device-cleanup-contract.d.ts.map +1 -0
  9. package/dist/dialog-contract.d.ts +26 -0
  10. package/dist/dialog-contract.d.ts.map +1 -0
  11. package/dist/errors.d.ts +33 -6
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/index.d.ts +144 -14
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/message-port-client.d.ts +134 -16
  16. package/dist/message-port-client.d.ts.map +1 -1
  17. package/dist/node-app-memory-contract.d.ts +10 -0
  18. package/dist/node-app-memory-contract.d.ts.map +1 -0
  19. package/dist/onboarding/discovery-contract.d.ts +61 -34
  20. package/dist/onboarding/discovery-contract.d.ts.map +1 -1
  21. package/dist/onboarding/onboarding-contract.d.ts +119 -27
  22. package/dist/onboarding/onboarding-contract.d.ts.map +1 -1
  23. package/dist/onboarding/owner-claim-contract.d.ts +50 -0
  24. package/dist/onboarding/owner-claim-contract.d.ts.map +1 -0
  25. package/dist/pairing-status-contract.d.ts +40 -0
  26. package/dist/pairing-status-contract.d.ts.map +1 -0
  27. package/dist/protocol.d.ts +50 -12
  28. package/dist/protocol.d.ts.map +1 -1
  29. package/dist/stage-context.d.ts +31 -16
  30. package/dist/stage-context.d.ts.map +1 -1
  31. package/dist/sync-cursors-contract.d.ts +6 -0
  32. package/dist/sync-cursors-contract.d.ts.map +1 -0
  33. package/dist/system-notice-contract.d.ts +70 -0
  34. package/dist/system-notice-contract.d.ts.map +1 -0
  35. package/dist/transport-contract.d.ts +11 -0
  36. package/dist/transport-contract.d.ts.map +1 -0
  37. package/dist/transport.d.ts +2 -2
  38. package/dist/transport.d.ts.map +1 -1
  39. package/dist/trust-contract.d.ts +23 -0
  40. package/dist/trust-contract.d.ts.map +1 -0
  41. package/package.json +16 -12
  42. package/src/client-device-pending-contract.js +6 -0
  43. package/src/client.js +26 -0
  44. package/src/device-boot-contract.js +4 -0
  45. package/src/device-cleanup-contract.js +9 -0
  46. package/src/dialog-contract.js +6 -0
  47. package/src/errors.js +111 -0
  48. package/src/index.js +194 -0
  49. package/src/message-port-client.js +628 -0
  50. package/src/node-app-memory-contract.js +10 -0
  51. package/src/onboarding/discovery-contract.js +83 -0
  52. package/src/onboarding/onboarding-contract.js +106 -0
  53. package/src/onboarding/owner-claim-contract.js +59 -0
  54. package/src/pairing-status-contract.js +36 -0
  55. package/src/protocol.js +758 -0
  56. package/src/stage-context.js +27 -0
  57. package/src/sync-cursors-contract.js +3 -0
  58. package/src/system-notice-contract.js +64 -0
  59. package/src/transport-contract.js +11 -0
  60. package/src/transport.js +6 -0
  61. package/src/trust-contract.js +36 -0
  62. package/dist/client.js +0 -2
  63. package/dist/client.js.map +0 -1
  64. package/dist/errors.js +0 -57
  65. package/dist/errors.js.map +0 -1
  66. package/dist/index.js +0 -7
  67. package/dist/index.js.map +0 -1
  68. package/dist/message-port-client.js +0 -365
  69. package/dist/message-port-client.js.map +0 -1
  70. package/dist/onboarding/discovery-contract.js +0 -42
  71. package/dist/onboarding/discovery-contract.js.map +0 -1
  72. package/dist/onboarding/onboarding-contract.js +0 -37
  73. package/dist/onboarding/onboarding-contract.js.map +0 -1
  74. package/dist/protocol.js +0 -295
  75. package/dist/protocol.js.map +0 -1
  76. package/dist/stage-context.js +0 -2
  77. package/dist/stage-context.js.map +0 -1
  78. package/dist/transport.js +0 -2
  79. package/dist/transport.js.map +0 -1
@@ -0,0 +1,628 @@
1
+ // @ts-check
2
+ import {
3
+ DomainError,
4
+ TransportError,
5
+ } from "@econ-v1/domain";
6
+
7
+ import { isJsonValue } from "./errors.js";
8
+ import {
9
+ deserializeRpcEvent,
10
+ deserializeRpcResponse,
11
+ serializeRpcRequest,
12
+ } from "./protocol.js";
13
+
14
+ /** @typedef {import("@econ-v1/domain").DomainEvent} DomainEvent */
15
+ /** @typedef {import("@econ-v1/domain").JsonValue} JsonValue */
16
+ /** @typedef {import("@econ-v1/domain").KernelEventType} KernelEventType */
17
+ /** @typedef {import("@econ-v1/domain").QueryName} QueryName */
18
+ /** @typedef {import("./client.js").KernelRpcClient} KernelRpcClient */
19
+ /** @typedef {import("./client.js").PreparedStage} PreparedStage */
20
+ /** @typedef {import("./client.js").StageRpcSession} StageRpcSession */
21
+ /** @typedef {import("./client.js").Unsubscribe} Unsubscribe */
22
+ /** @typedef {import("./protocol.js").RpcRequest} RpcRequest */
23
+ /** @typedef {RpcRequest extends infer TRequest ? TRequest extends RpcRequest ? Omit<TRequest, "id"> : never : never} RpcRequestWithoutId */
24
+ /** @typedef {import("./transport.js").RpcTransport} RpcTransport */
25
+ /** @typedef {{ readonly reject: (error: unknown) => void, readonly resolve: (value: JsonValue) => void, readonly sessionId: string | undefined, readonly timeoutId: ReturnType<typeof globalThis.setTimeout> }} PendingRequest */
26
+ /** @typedef {{ readonly eventType: KernelEventType, readonly listeners: Set<(event: DomainEvent) => void>, readonly sessionId: string | undefined, readonly subscriptionId: string }} EventGroup */
27
+ /** @typedef {{ readonly listeners: Set<(diff: import("@econ-v1/domain").QueryDiff<JsonValue>) => void>, readonly name: QueryName, readonly params: JsonValue, readonly sessionId: string | undefined, readonly subscriptionId: string }} LiveQueryGroup */
28
+ /** @typedef {{ readonly now?: () => string, readonly requestTimeoutMs?: number }} MessagePortKernelRpcClientOptions */
29
+
30
+ /**
31
+ * A subscribe request the kernel refused. The group is dropped either way,
32
+ * but reporting the refusal keeps a silent subscription outage observable.
33
+ *
34
+ * @param {"event" | "live-query"} kind
35
+ * @param {string} resource
36
+ * @param {string | undefined} sessionId
37
+ * @param {unknown} error
38
+ */
39
+ function warnSubscriptionRejected(kind, resource, sessionId, error) {
40
+ const reason = error instanceof Error ? error.message : String(error);
41
+ const owner = sessionId === undefined ? "shell" : `stage session ${sessionId}`;
42
+ console.warn(
43
+ `[kernel-rpc] ${kind} subscription to "${resource}" was rejected for the ${owner} ` +
44
+ `and has been dropped — nothing will be delivered: ${reason}`,
45
+ );
46
+ }
47
+
48
+ /**
49
+ * A single kernel RPC request exceeded its local response deadline. This is deliberately
50
+ * distinct from connection loss: the transport remains usable and callers may retry.
51
+ */
52
+ export class KernelRpcTimeoutError extends Error {
53
+ /** @readonly @type {string} */
54
+ commandName;
55
+ /** @readonly @type {string} */
56
+ correlationId;
57
+ /** @readonly @type {number} */
58
+ timeoutMs;
59
+
60
+ /**
61
+ * @param {string} commandName
62
+ * @param {string} correlationId
63
+ * @param {number} timeoutMs
64
+ */
65
+ constructor(commandName, correlationId, timeoutMs) {
66
+ super(`Kernel RPC "${commandName}" (${correlationId}) timed out after ${timeoutMs}ms; the request may be retried`);
67
+ this.name = "KernelRpcTimeoutError";
68
+ this.commandName = commandName;
69
+ this.correlationId = correlationId;
70
+ this.timeoutMs = timeoutMs;
71
+ }
72
+ }
73
+
74
+ // Kernel RPC is local, but stage work can cross browser/worker scheduling boundaries.
75
+ // Thirty seconds bounds dropped fire-and-forget messages without penalizing slow devices.
76
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
77
+
78
+ /** @implements {KernelRpcClient} */
79
+ export class MessagePortKernelRpcClient {
80
+ /** @type {Map<string, EventGroup>} */
81
+ #eventGroupsByKey = new Map();
82
+ /** @type {Map<string, EventGroup>} */
83
+ #eventGroupsBySubscription = new Map();
84
+ /** @type {Map<string, LiveQueryGroup>} */
85
+ #liveGroupsByKey = new Map();
86
+ /** @type {Map<string, LiveQueryGroup>} */
87
+ #liveGroupsBySubscription = new Map();
88
+ /** @type {() => string} */
89
+ #now;
90
+ /** @type {Map<string, PendingRequest>} */
91
+ #pending = new Map();
92
+ /** @type {number} */
93
+ #requestTimeoutMs;
94
+ /** @type {RpcTransport} */
95
+ #transport;
96
+ /** @type {() => void} */
97
+ #unsubscribeClose;
98
+ /** @type {() => void} */
99
+ #unsubscribeMessages;
100
+ #closed = false;
101
+ #nextRequestId = 1;
102
+ #nextSubscriptionId = 1;
103
+
104
+ /**
105
+ * @param {RpcTransport} transport
106
+ * @param {MessagePortKernelRpcClientOptions} [options]
107
+ */
108
+ constructor(transport, options = {}) {
109
+ this.#transport = transport;
110
+ this.#now = options.now ?? (() => new Date().toISOString());
111
+ this.#requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
112
+ this.#unsubscribeMessages = transport.subscribe((message) => this.#handleMessage(message));
113
+ this.#unsubscribeClose = transport.subscribeClose(() => this.#handleClose());
114
+ }
115
+
116
+ close() {
117
+ if (this.#closed) return;
118
+ this.#transport.close();
119
+ this.#handleClose();
120
+ }
121
+
122
+ /**
123
+ * @template T
124
+ * @param {string} name
125
+ * @param {unknown} payload
126
+ * @returns {Promise<T>}
127
+ */
128
+ async command(name, payload) {
129
+ return /** @type {T} */ (await this.#request({ name, payload: jsonValue(payload, "command payload"), type: "command" }));
130
+ }
131
+
132
+ /**
133
+ * @template T
134
+ * @param {QueryName} name
135
+ * @param {unknown} params
136
+ * @param {(diff: import("@econ-v1/domain").QueryDiff<T>) => void} listener
137
+ * @returns {Unsubscribe}
138
+ */
139
+ liveQuery(name, params, listener) {
140
+ return this.#subscribeLiveQuery(undefined, name, jsonValue(params, "live-query params"), listener);
141
+ }
142
+
143
+ /**
144
+ * @param {string} appName
145
+ * @param {string} manifestSha256
146
+ * @returns {Promise<StageRpcSession>}
147
+ */
148
+ async openStageSession(appName, manifestSha256) {
149
+ const value = await this.#request({ appName, manifestSha256, type: "stage.open-session" });
150
+ if (!isRecord(value) || typeof value.sessionId !== "string") {
151
+ throw new DomainError({ code: "invalid_request", message: "Invalid stage session response" });
152
+ }
153
+ return new BoundStageRpcSession(this, value.sessionId);
154
+ }
155
+
156
+ /**
157
+ * @param {string} appName
158
+ * @returns {Promise<PreparedStage>}
159
+ */
160
+ async prepareStage(appName) {
161
+ const value = await this.#request({ appName, type: "stage.prepare" });
162
+ if (
163
+ !isRecord(value) ||
164
+ typeof value.appName !== "string" ||
165
+ typeof value.entryUrl !== "string" ||
166
+ typeof value.manifestSha256 !== "string" ||
167
+ typeof value.version !== "string"
168
+ ) {
169
+ throw new DomainError({ code: "invalid_request", message: "Invalid prepared stage response" });
170
+ }
171
+ return {
172
+ appName: value.appName,
173
+ entryUrl: value.entryUrl,
174
+ manifestSha256: value.manifestSha256,
175
+ version: value.version,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * @template {KernelEventType} T
181
+ * @param {T} type
182
+ * @param {import("@econ-v1/domain").KernelEventPayload<T>} payload
183
+ * @returns {Promise<void>}
184
+ */
185
+ publish(type, payload) {
186
+ const event = /** @type {DomainEvent} */ (jsonValue({ occurredAt: this.#now(), payload, type }, "domain event"));
187
+ return this.#request({ event, type: "event.publish" }).then(() => undefined);
188
+ }
189
+
190
+ /**
191
+ * @template T
192
+ * @param {QueryName} name
193
+ * @param {unknown} params
194
+ * @returns {Promise<T>}
195
+ */
196
+ async query(name, params) {
197
+ return /** @type {T} */ (await this.#request({ name, params: jsonValue(params, "query params"), type: "query" }));
198
+ }
199
+
200
+ /**
201
+ * @template {KernelEventType} T
202
+ * @param {T} type
203
+ * @param {(event: import("@econ-v1/domain").KernelEventByType<T>) => void} listener
204
+ * @returns {Unsubscribe}
205
+ */
206
+ subscribe(type, listener) {
207
+ return this.#subscribeEvent(undefined, type, listener);
208
+ }
209
+
210
+ /** @param {string} sessionId */
211
+ closeStageSession(sessionId) {
212
+ const error = new TransportError({ code: "connection_lost", message: "Stage RPC session closed" });
213
+ for (const [requestId, pending] of [...this.#pending]) {
214
+ if (pending.sessionId === sessionId) {
215
+ this.#takePending(requestId)?.reject(error);
216
+ }
217
+ }
218
+ this.#removeSessionSubscriptions(sessionId);
219
+ void this.#request({ sessionId, type: "stage.close-session" }).catch(() => undefined);
220
+ }
221
+
222
+ /**
223
+ * @template T
224
+ * @param {string} sessionId
225
+ * @param {string} capability
226
+ * @param {unknown} payload
227
+ * @returns {Promise<T>}
228
+ */
229
+ invokeStageCapability(sessionId, capability, payload) {
230
+ return /** @type {Promise<T>} */ (this.#request({
231
+ capability,
232
+ payload: jsonValue(payload, "capability payload"),
233
+ sessionId,
234
+ type: "stage.capability.invoke",
235
+ }));
236
+ }
237
+
238
+ /**
239
+ * @template T
240
+ * @param {string} sessionId
241
+ * @param {QueryName} name
242
+ * @param {unknown} params
243
+ * @param {(diff: import("@econ-v1/domain").QueryDiff<T>) => void} listener
244
+ * @returns {Unsubscribe}
245
+ */
246
+ liveQueryStage(sessionId, name, params, listener) {
247
+ return this.#subscribeLiveQuery(sessionId, name, jsonValue(params, "live-query params"), listener);
248
+ }
249
+
250
+ /**
251
+ * @template {KernelEventType} T
252
+ * @param {string} sessionId
253
+ * @param {T} type
254
+ * @param {import("@econ-v1/domain").KernelEventPayload<T>} payload
255
+ * @returns {Promise<void>}
256
+ */
257
+ publishStage(sessionId, type, payload) {
258
+ const event = /** @type {DomainEvent} */ (jsonValue({ occurredAt: this.#now(), payload, type }, "domain event"));
259
+ return this.#request({ event, sessionId, type: "stage.event.publish" }).then(() => undefined);
260
+ }
261
+
262
+ /**
263
+ * @template T
264
+ * @param {string} sessionId
265
+ * @param {QueryName} name
266
+ * @param {unknown} params
267
+ * @returns {Promise<T>}
268
+ */
269
+ queryStage(sessionId, name, params) {
270
+ return /** @type {Promise<T>} */ (this.#request({
271
+ name,
272
+ params: jsonValue(params, "query params"),
273
+ sessionId,
274
+ type: "stage.query",
275
+ }));
276
+ }
277
+
278
+ /**
279
+ * @template {KernelEventType} T
280
+ * @param {string} sessionId
281
+ * @param {T} type
282
+ * @param {(event: import("@econ-v1/domain").KernelEventByType<T>) => void} listener
283
+ * @returns {Unsubscribe}
284
+ */
285
+ subscribeStage(sessionId, type, listener) {
286
+ return this.#subscribeEvent(sessionId, type, listener);
287
+ }
288
+
289
+ #handleClose() {
290
+ if (this.#closed) return;
291
+ this.#closed = true;
292
+ this.#unsubscribeMessages();
293
+ this.#unsubscribeClose();
294
+ const error = new TransportError({ code: "connection_lost", message: "Kernel RPC port closed" });
295
+ for (const requestId of [...this.#pending.keys()]) this.#takePending(requestId)?.reject(error);
296
+ this.#eventGroupsByKey.clear();
297
+ this.#eventGroupsBySubscription.clear();
298
+ this.#liveGroupsByKey.clear();
299
+ this.#liveGroupsBySubscription.clear();
300
+ }
301
+
302
+ /** @param {string} message */
303
+ #handleMessage(message) {
304
+ /** @type {unknown} */
305
+ let envelope;
306
+ try {
307
+ envelope = JSON.parse(message);
308
+ } catch {
309
+ return;
310
+ }
311
+
312
+ if (isRecord(envelope) && "id" in envelope) {
313
+ const responseId = typeof envelope.id === "string" ? envelope.id : undefined;
314
+ try {
315
+ const response = deserializeRpcResponse(message);
316
+ const pending = this.#takePending(response.id);
317
+ if (pending === undefined) return;
318
+ if (response.ok) pending.resolve(response.value);
319
+ else pending.reject(response.error);
320
+ return;
321
+ } catch {
322
+ if (responseId !== undefined) {
323
+ const pending = this.#takePending(responseId);
324
+ if (pending !== undefined) {
325
+ pending.reject(new DomainError({ code: "invalid_request", message: "Malformed kernel RPC response" }));
326
+ }
327
+ }
328
+ return;
329
+ }
330
+ }
331
+
332
+ try {
333
+ const event = deserializeRpcEvent(message);
334
+ if (event.type === "kernel.event") {
335
+ const group = this.#eventGroupsBySubscription.get(event.subscriptionId);
336
+ if (group === undefined || group.eventType !== event.event.type) return;
337
+ // The protocol decoder accepts the current device-cleanup core event even when a
338
+ // consumer resolves an older published domain declaration. Runtime listeners still
339
+ // receive the same domain-event shape; keep this boundary explicit until declarations
340
+ // are regenerated from the current source package.
341
+ for (const listener of [...group.listeners]) {
342
+ this.#notify(() => listener(/** @type {DomainEvent} */ (event.event)));
343
+ }
344
+ return;
345
+ }
346
+ const group = this.#liveGroupsBySubscription.get(event.subscriptionId);
347
+ if (group === undefined) return;
348
+ for (const listener of [...group.listeners]) this.#notify(() => listener(event.diff));
349
+ } catch {
350
+ // Malformed or stale port messages are isolated from valid pending requests.
351
+ }
352
+ }
353
+
354
+ /** @param {() => void} listener */
355
+ #notify(listener) {
356
+ try {
357
+ listener();
358
+ } catch {
359
+ // One UI listener must not suppress later listeners on the same RPC subscription.
360
+ }
361
+ }
362
+
363
+ /**
364
+ * @param {string} requestId
365
+ * @returns {PendingRequest | undefined}
366
+ */
367
+ #takePending(requestId) {
368
+ const pending = this.#pending.get(requestId);
369
+ if (pending === undefined) return undefined;
370
+ this.#pending.delete(requestId);
371
+ globalThis.clearTimeout(pending.timeoutId);
372
+ return pending;
373
+ }
374
+
375
+ /** @param {string} sessionId */
376
+ #removeSessionSubscriptions(sessionId) {
377
+ for (const [key, group] of [...this.#eventGroupsByKey]) {
378
+ if (group.sessionId === sessionId) {
379
+ this.#eventGroupsByKey.delete(key);
380
+ this.#eventGroupsBySubscription.delete(group.subscriptionId);
381
+ }
382
+ }
383
+ for (const [key, group] of [...this.#liveGroupsByKey]) {
384
+ if (group.sessionId === sessionId) {
385
+ this.#liveGroupsByKey.delete(key);
386
+ this.#liveGroupsBySubscription.delete(group.subscriptionId);
387
+ }
388
+ }
389
+ }
390
+
391
+ /**
392
+ * @param {RpcRequestWithoutId} request
393
+ * @returns {Promise<JsonValue>}
394
+ */
395
+ #request(request) {
396
+ if (this.#closed || this.#transport.state !== "open") {
397
+ return Promise.reject(new TransportError({ code: "connection_lost", message: "Kernel RPC port is closed" }));
398
+ }
399
+ const id = `request-${this.#nextRequestId}`;
400
+ this.#nextRequestId += 1;
401
+ return new Promise((resolve, reject) => {
402
+ const sessionId = "sessionId" in request ? request.sessionId : undefined;
403
+ const commandName = "name" in request
404
+ ? request.name
405
+ : "capability" in request ? request.capability : request.type;
406
+ const timeoutId = globalThis.setTimeout(() => {
407
+ this.#takePending(id)?.reject(new KernelRpcTimeoutError(commandName, id, this.#requestTimeoutMs));
408
+ }, this.#requestTimeoutMs);
409
+ this.#pending.set(id, { reject, resolve, sessionId, timeoutId });
410
+ try {
411
+ this.#transport.send(serializeRpcRequest(/** @type {RpcRequest} */ ({ ...request, id })));
412
+ } catch (error) {
413
+ const sendError = error instanceof TransportError
414
+ ? error
415
+ : new TransportError({ code: "connection_lost", message: "Kernel RPC send failed" });
416
+ this.#takePending(id)?.reject(sendError);
417
+ }
418
+ });
419
+ }
420
+
421
+ /**
422
+ * @template {KernelEventType} T
423
+ * @param {string | undefined} sessionId
424
+ * @param {T} eventType
425
+ * @param {(event: import("@econ-v1/domain").KernelEventByType<T>) => void} listener
426
+ * @returns {Unsubscribe}
427
+ */
428
+ #subscribeEvent(sessionId, eventType, listener) {
429
+ const key = `${sessionId ?? "host"}:event:${eventType}`;
430
+ let group = this.#eventGroupsByKey.get(key);
431
+ if (group === undefined) {
432
+ const subscriptionId = this.#newSubscriptionId();
433
+ group = { eventType, listeners: new Set(), sessionId, subscriptionId };
434
+ this.#eventGroupsByKey.set(key, group);
435
+ this.#eventGroupsBySubscription.set(subscriptionId, group);
436
+ const request = sessionId === undefined
437
+ ? /** @type {const} */ ({ eventType, subscriptionId, type: "event.subscribe" })
438
+ : /** @type {const} */ ({ eventType, sessionId, subscriptionId, type: "stage.event.subscribe" });
439
+ void this.#request(request).catch((error) => {
440
+ this.#dropEventGroup(key, /** @type {EventGroup} */ (group));
441
+ warnSubscriptionRejected("event", eventType, sessionId, error);
442
+ });
443
+ }
444
+ const storedListener = /** @type {(event: DomainEvent) => void} */ (listener);
445
+ group.listeners.add(storedListener);
446
+ let active = true;
447
+ return () => {
448
+ if (!active) return;
449
+ active = false;
450
+ group?.listeners.delete(storedListener);
451
+ if (group !== undefined && group.listeners.size === 0) {
452
+ this.#dropEventGroup(key, group);
453
+ const request = sessionId === undefined
454
+ ? /** @type {const} */ ({ subscriptionId: group.subscriptionId, type: "event.unsubscribe" })
455
+ : /** @type {const} */ ({ sessionId, subscriptionId: group.subscriptionId, type: "stage.event.unsubscribe" });
456
+ void this.#request(request).catch(() => undefined);
457
+ }
458
+ };
459
+ }
460
+
461
+ /**
462
+ * @template T
463
+ * @param {string | undefined} sessionId
464
+ * @param {QueryName} name
465
+ * @param {JsonValue} params
466
+ * @param {(diff: import("@econ-v1/domain").QueryDiff<T>) => void} listener
467
+ * @returns {Unsubscribe}
468
+ */
469
+ #subscribeLiveQuery(sessionId, name, params, listener) {
470
+ const key = `${sessionId ?? "host"}:live:${name}:${JSON.stringify(params)}`;
471
+ let group = this.#liveGroupsByKey.get(key);
472
+ if (group === undefined) {
473
+ const subscriptionId = this.#newSubscriptionId();
474
+ group = { listeners: new Set(), name, params, sessionId, subscriptionId };
475
+ this.#liveGroupsByKey.set(key, group);
476
+ this.#liveGroupsBySubscription.set(subscriptionId, group);
477
+ const request = sessionId === undefined
478
+ ? /** @type {const} */ ({ name, params, subscriptionId, type: "live-query.subscribe" })
479
+ : /** @type {const} */ ({ name, params, sessionId, subscriptionId, type: "stage.live-query.subscribe" });
480
+ void this.#request(request).catch((error) => {
481
+ this.#dropLiveGroup(key, /** @type {LiveQueryGroup} */ (group));
482
+ warnSubscriptionRejected("live-query", name, sessionId, error);
483
+ });
484
+ }
485
+ const storedListener = /** @type {(diff: import("@econ-v1/domain").QueryDiff<JsonValue>) => void} */ (listener);
486
+ group.listeners.add(storedListener);
487
+ let active = true;
488
+ return () => {
489
+ if (!active) return;
490
+ active = false;
491
+ group?.listeners.delete(storedListener);
492
+ if (group !== undefined && group.listeners.size === 0) {
493
+ this.#dropLiveGroup(key, group);
494
+ const request = sessionId === undefined
495
+ ? /** @type {const} */ ({ subscriptionId: group.subscriptionId, type: "live-query.unsubscribe" })
496
+ : /** @type {const} */ ({ sessionId, subscriptionId: group.subscriptionId, type: "stage.live-query.unsubscribe" });
497
+ void this.#request(request).catch(() => undefined);
498
+ }
499
+ };
500
+ }
501
+
502
+ /**
503
+ * @param {string} key
504
+ * @param {EventGroup} group
505
+ */
506
+ #dropEventGroup(key, group) {
507
+ if (this.#eventGroupsByKey.get(key) === group) this.#eventGroupsByKey.delete(key);
508
+ this.#eventGroupsBySubscription.delete(group.subscriptionId);
509
+ }
510
+
511
+ /**
512
+ * @param {string} key
513
+ * @param {LiveQueryGroup} group
514
+ */
515
+ #dropLiveGroup(key, group) {
516
+ if (this.#liveGroupsByKey.get(key) === group) this.#liveGroupsByKey.delete(key);
517
+ this.#liveGroupsBySubscription.delete(group.subscriptionId);
518
+ }
519
+
520
+ /** @returns {string} */
521
+ #newSubscriptionId() {
522
+ const id = `subscription-${this.#nextSubscriptionId}`;
523
+ this.#nextSubscriptionId += 1;
524
+ return id;
525
+ }
526
+ }
527
+
528
+ /** @implements {StageRpcSession} */
529
+ class BoundStageRpcSession {
530
+ /** @type {MessagePortKernelRpcClient} */
531
+ #client;
532
+ /** @type {string} */
533
+ #sessionId;
534
+ #closed = false;
535
+
536
+ /**
537
+ * @param {MessagePortKernelRpcClient} client
538
+ * @param {string} sessionId
539
+ */
540
+ constructor(client, sessionId) {
541
+ this.#client = client;
542
+ this.#sessionId = sessionId;
543
+ }
544
+
545
+ close() {
546
+ if (this.#closed) return;
547
+ this.#closed = true;
548
+ this.#client.closeStageSession(this.#sessionId);
549
+ }
550
+
551
+ /**
552
+ * @template T
553
+ * @param {string} capability
554
+ * @param {unknown} payload
555
+ * @returns {Promise<T>}
556
+ */
557
+ invokeCapability(capability, payload) {
558
+ this.#assertOpen();
559
+ return this.#client.invokeStageCapability(this.#sessionId, capability, payload);
560
+ }
561
+
562
+ /**
563
+ * @template T
564
+ * @param {QueryName} name
565
+ * @param {unknown} params
566
+ * @param {(diff: import("@econ-v1/domain").QueryDiff<T>) => void} listener
567
+ * @returns {Unsubscribe}
568
+ */
569
+ liveQuery(name, params, listener) {
570
+ this.#assertOpen();
571
+ return this.#client.liveQueryStage(this.#sessionId, name, params, listener);
572
+ }
573
+
574
+ /**
575
+ * @template {KernelEventType} T
576
+ * @param {T} type
577
+ * @param {import("@econ-v1/domain").KernelEventPayload<T>} payload
578
+ * @returns {Promise<void>}
579
+ */
580
+ publish(type, payload) {
581
+ this.#assertOpen();
582
+ return this.#client.publishStage(this.#sessionId, type, payload);
583
+ }
584
+
585
+ /**
586
+ * @template T
587
+ * @param {QueryName} name
588
+ * @param {unknown} params
589
+ * @returns {Promise<T>}
590
+ */
591
+ query(name, params) {
592
+ this.#assertOpen();
593
+ return this.#client.queryStage(this.#sessionId, name, params);
594
+ }
595
+
596
+ /**
597
+ * @template {KernelEventType} T
598
+ * @param {T} type
599
+ * @param {(event: import("@econ-v1/domain").KernelEventByType<T>) => void} listener
600
+ * @returns {Unsubscribe}
601
+ */
602
+ subscribe(type, listener) {
603
+ this.#assertOpen();
604
+ return this.#client.subscribeStage(this.#sessionId, type, listener);
605
+ }
606
+
607
+ #assertOpen() {
608
+ if (this.#closed) throw new TransportError({ code: "connection_lost", message: "Stage RPC session is closed" });
609
+ }
610
+ }
611
+
612
+ /**
613
+ * @param {unknown} value
614
+ * @param {string} label
615
+ * @returns {JsonValue}
616
+ */
617
+ function jsonValue(value, label) {
618
+ if (isJsonValue(value)) return value;
619
+ throw new DomainError({ code: "validation_failed", message: `${label} must be JSON-serializable` });
620
+ }
621
+
622
+ /**
623
+ * @param {unknown} value
624
+ * @returns {value is Readonly<Record<string, unknown>>}
625
+ */
626
+ function isRecord(value) {
627
+ return typeof value === "object" && value !== null && !Array.isArray(value);
628
+ }
@@ -0,0 +1,10 @@
1
+ // @ts-check
2
+
3
+ export const NODE_APP_MEMORY_LIST_QUERY = "node-app-memory.list.v1";
4
+ export const NODE_APP_MEMORY_DETAIL_QUERY = "node-app-memory.detail.v1";
5
+ export const NODE_APP_MEMORY_OVERVIEW_QUERY = "node-app-memory.overview.v1";
6
+ export const NODE_APP_MEMORY_CAPTURE_COMMAND = "node-app-memory.capture.v1";
7
+ export const NODE_APP_MEMORY_STATUS_COMMAND = "node-app-memory.status.v1";
8
+ export const NODE_APP_MEMORY_DOWNLOAD_COMMAND = "node-app-memory.download.v1";
9
+
10
+ /** @typedef {{ readonly id: string }} NodeAppMemoryDetailQueryParams */