@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.
- package/dist/client-device-pending-contract.d.ts +14 -0
- package/dist/client-device-pending-contract.d.ts.map +1 -0
- package/dist/client.d.ts +15 -13
- package/dist/client.d.ts.map +1 -1
- package/dist/device-boot-contract.d.ts +7 -0
- package/dist/device-boot-contract.d.ts.map +1 -0
- package/dist/device-cleanup-contract.d.ts +17 -0
- package/dist/device-cleanup-contract.d.ts.map +1 -0
- package/dist/dialog-contract.d.ts +26 -0
- package/dist/dialog-contract.d.ts.map +1 -0
- package/dist/errors.d.ts +33 -6
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +144 -14
- package/dist/index.d.ts.map +1 -1
- package/dist/message-port-client.d.ts +134 -16
- package/dist/message-port-client.d.ts.map +1 -1
- package/dist/node-app-memory-contract.d.ts +10 -0
- package/dist/node-app-memory-contract.d.ts.map +1 -0
- package/dist/onboarding/discovery-contract.d.ts +61 -34
- package/dist/onboarding/discovery-contract.d.ts.map +1 -1
- package/dist/onboarding/onboarding-contract.d.ts +119 -27
- package/dist/onboarding/onboarding-contract.d.ts.map +1 -1
- package/dist/onboarding/owner-claim-contract.d.ts +50 -0
- package/dist/onboarding/owner-claim-contract.d.ts.map +1 -0
- package/dist/pairing-status-contract.d.ts +40 -0
- package/dist/pairing-status-contract.d.ts.map +1 -0
- package/dist/protocol.d.ts +50 -12
- package/dist/protocol.d.ts.map +1 -1
- package/dist/stage-context.d.ts +31 -16
- package/dist/stage-context.d.ts.map +1 -1
- package/dist/sync-cursors-contract.d.ts +6 -0
- package/dist/sync-cursors-contract.d.ts.map +1 -0
- package/dist/system-notice-contract.d.ts +70 -0
- package/dist/system-notice-contract.d.ts.map +1 -0
- package/dist/transport-contract.d.ts +11 -0
- package/dist/transport-contract.d.ts.map +1 -0
- package/dist/transport.d.ts +2 -2
- package/dist/transport.d.ts.map +1 -1
- package/dist/trust-contract.d.ts +23 -0
- package/dist/trust-contract.d.ts.map +1 -0
- package/package.json +16 -12
- package/src/client-device-pending-contract.js +6 -0
- package/src/client.js +26 -0
- package/src/device-boot-contract.js +4 -0
- package/src/device-cleanup-contract.js +9 -0
- package/src/dialog-contract.js +6 -0
- package/src/errors.js +111 -0
- package/src/index.js +194 -0
- package/src/message-port-client.js +628 -0
- package/src/node-app-memory-contract.js +10 -0
- package/src/onboarding/discovery-contract.js +83 -0
- package/src/onboarding/onboarding-contract.js +106 -0
- package/src/onboarding/owner-claim-contract.js +59 -0
- package/src/pairing-status-contract.js +36 -0
- package/src/protocol.js +758 -0
- package/src/stage-context.js +27 -0
- package/src/sync-cursors-contract.js +3 -0
- package/src/system-notice-contract.js +64 -0
- package/src/transport-contract.js +11 -0
- package/src/transport.js +6 -0
- package/src/trust-contract.js +36 -0
- package/dist/client.js +0 -2
- package/dist/client.js.map +0 -1
- package/dist/errors.js +0 -57
- package/dist/errors.js.map +0 -1
- package/dist/index.js +0 -7
- package/dist/index.js.map +0 -1
- package/dist/message-port-client.js +0 -365
- package/dist/message-port-client.js.map +0 -1
- package/dist/onboarding/discovery-contract.js +0 -42
- package/dist/onboarding/discovery-contract.js.map +0 -1
- package/dist/onboarding/onboarding-contract.js +0 -37
- package/dist/onboarding/onboarding-contract.js.map +0 -1
- package/dist/protocol.js +0 -295
- package/dist/protocol.js.map +0 -1
- package/dist/stage-context.js +0 -2
- package/dist/stage-context.js.map +0 -1
- package/dist/transport.js +0 -2
- 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 */
|