bunite-core 0.9.0 → 0.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/package.json +1 -1
- package/src/host/core/App.ts +1 -1
- package/src/host/core/BrowserView.ts +18 -12
- package/src/host/core/BrowserWindow.ts +12 -11
- package/src/host/serveWeb.ts +33 -9
- package/src/preload/runtime.built.js +1 -1
- package/src/preload/runtime.ts +8 -10
- package/src/rpc/error.ts +25 -15
- package/src/rpc/framework.ts +12 -12
- package/src/rpc/index.ts +13 -4
- package/src/rpc/peer.ts +542 -159
- package/src/rpc/renderer.ts +16 -18
- package/src/rpc/schema.ts +30 -47
- package/src/rpc/wire.ts +18 -4
- package/src/rpc/hash.ts +0 -142
package/src/rpc/peer.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type CapDef,
|
|
3
3
|
type Schema,
|
|
4
|
-
type
|
|
4
|
+
type SchemaRoots,
|
|
5
5
|
type ClientOf,
|
|
6
6
|
type ImplOf,
|
|
7
|
-
type
|
|
7
|
+
type ImplsOf,
|
|
8
8
|
type CallCtx,
|
|
9
9
|
type Attestation,
|
|
10
10
|
type ExportedCap,
|
|
@@ -12,14 +12,13 @@ import {
|
|
|
12
12
|
type AnyCapToken,
|
|
13
13
|
type Stream as StreamType,
|
|
14
14
|
type DisposalSpec,
|
|
15
|
-
call,
|
|
16
|
-
cap,
|
|
17
|
-
defineCap,
|
|
18
15
|
isCallDef,
|
|
19
16
|
isStreamDef,
|
|
20
17
|
isCapRef,
|
|
21
18
|
isCapArray,
|
|
22
19
|
isCapRecord,
|
|
20
|
+
isCapDef,
|
|
21
|
+
isSchema,
|
|
23
22
|
} from "./schema";
|
|
24
23
|
import { RuntimeCap, frameworkTypeIdOf } from "./framework";
|
|
25
24
|
import {
|
|
@@ -30,12 +29,22 @@ import {
|
|
|
30
29
|
type CancelFrame,
|
|
31
30
|
type DropFrame,
|
|
32
31
|
type HelloFrame,
|
|
32
|
+
type CapRevokedFrame,
|
|
33
33
|
type CallMeta,
|
|
34
34
|
CapRef,
|
|
35
35
|
DEFAULT_MAX_BYTES,
|
|
36
36
|
PROTOCOL_VERSION,
|
|
37
|
+
FRAMEWORK_NAME_PREFIX,
|
|
38
|
+
BOOTSTRAP_METHOD,
|
|
37
39
|
} from "./wire";
|
|
38
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
IpcError,
|
|
42
|
+
type IpcStatus,
|
|
43
|
+
type IpcCode,
|
|
44
|
+
type FailedPreconditionReason,
|
|
45
|
+
type ResourceExhaustedReason,
|
|
46
|
+
type AlreadyExistsReason,
|
|
47
|
+
} from "./error";
|
|
39
48
|
|
|
40
49
|
export const USER_ROOTS_CAP_ID = 0;
|
|
41
50
|
export const RUNTIME_CAP_ID = 1;
|
|
@@ -46,6 +55,7 @@ export const FIRST_USER_CAP_ID = 2;
|
|
|
46
55
|
export const FIRST_USER_TYPE_ID = 128;
|
|
47
56
|
|
|
48
57
|
export const MAX_CAPS_PER_CONNECTION = 1024;
|
|
58
|
+
export const MAX_IN_FLIGHT_CALLS_PER_CONNECTION = 1024;
|
|
49
59
|
|
|
50
60
|
const DEFAULT_DEADLINE_GRACE_MS = 500;
|
|
51
61
|
const DEFAULT_STREAM_INITIAL_CREDIT = 32;
|
|
@@ -101,7 +111,11 @@ export class CapTable {
|
|
|
101
111
|
|
|
102
112
|
allocate(entry: Omit<CapTableEntry, "capId">): CapTableEntry {
|
|
103
113
|
if (this.entries.size >= this.capLimit) {
|
|
104
|
-
throw new IpcError({
|
|
114
|
+
throw new IpcError({
|
|
115
|
+
code: "resource_exhausted",
|
|
116
|
+
message: `cap-table limit ${this.capLimit}`,
|
|
117
|
+
details: { reason: "max_caps_per_connection" as ResourceExhaustedReason },
|
|
118
|
+
});
|
|
105
119
|
}
|
|
106
120
|
let capId = this.nextCapId++;
|
|
107
121
|
while (this.entries.has(capId)) capId = this.nextCapId++;
|
|
@@ -123,6 +137,11 @@ export class CapTable {
|
|
|
123
137
|
return false;
|
|
124
138
|
}
|
|
125
139
|
|
|
140
|
+
delete(capId: number): boolean {
|
|
141
|
+
if (capId < FIRST_USER_CAP_ID) return false;
|
|
142
|
+
return this.entries.delete(capId);
|
|
143
|
+
}
|
|
144
|
+
|
|
126
145
|
clear(): void {
|
|
127
146
|
this.entries.clear();
|
|
128
147
|
this.nextCapId = FIRST_USER_CAP_ID;
|
|
@@ -143,14 +162,32 @@ export interface Transport {
|
|
|
143
162
|
close(): void;
|
|
144
163
|
}
|
|
145
164
|
|
|
165
|
+
export type Policy = (name: string, attestation: Attestation) => boolean | Promise<boolean>;
|
|
166
|
+
|
|
167
|
+
export type IfExists = "throw" | "replace" | "skip";
|
|
168
|
+
|
|
169
|
+
export interface ServeHandle extends Disposable {
|
|
170
|
+
readonly names: readonly string[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface ConnectionEvents {
|
|
174
|
+
bootstrap: { name: string; version?: string; attestation: Attestation; result: "ok" | "denied" | "not_found" | "version_mismatch" | "invalid_argument" | "resource_exhausted" | "internal"; capId?: number };
|
|
175
|
+
call: { capId: number; capName?: string; method: string; callId: number; durationMs?: number; result: "ok" | "cancelled" | IpcCode };
|
|
176
|
+
stream: { capId: number; capName?: string; method: string; callId: number; event: "start" | "end" | "cancel" | "error"; count?: number };
|
|
177
|
+
revoke: { capIds: number[]; reason: "unserve" | "replace" };
|
|
178
|
+
error: { phase: string; error: Error };
|
|
179
|
+
}
|
|
180
|
+
|
|
146
181
|
export interface Connection {
|
|
147
|
-
bootstrap<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
182
|
+
bootstrap<C extends CapDef<any, any>>(cap: C): Promise<ClientOf<C>>;
|
|
183
|
+
bootstrap<R extends SchemaRoots>(schema: Schema<R>): Promise<{ [K in keyof R]: ClientOf<R[K]> }>;
|
|
184
|
+
serve<C extends CapDef<any, any>>(cap: C, impl: ImplOf<C>, opts?: { ifExists?: IfExists }): ServeHandle;
|
|
185
|
+
serveAll<R extends SchemaRoots>(schema: Schema<R>, impls: ImplsOf<R>, opts?: { ifExists?: IfExists }): ServeHandle;
|
|
186
|
+
unserve(target: CapDef<any, any> | ServeHandle): void;
|
|
187
|
+
replace<C extends CapDef<any, any>>(cap: C, impl: ImplOf<C>): void;
|
|
152
188
|
runtime(): ClientOf<typeof RuntimeCap>;
|
|
153
189
|
releaseRef(proxy: unknown): void;
|
|
190
|
+
on<K extends keyof ConnectionEvents>(event: K, handler: (e: ConnectionEvents[K]) => void): () => void;
|
|
154
191
|
onClose(handler: () => void): () => void;
|
|
155
192
|
readonly closed: boolean;
|
|
156
193
|
}
|
|
@@ -162,9 +199,11 @@ export interface ConnectionOptions {
|
|
|
162
199
|
features?: string[];
|
|
163
200
|
maxBytes?: number;
|
|
164
201
|
capLimit?: number;
|
|
202
|
+
maxInFlightCalls?: number;
|
|
165
203
|
peerId?: string;
|
|
166
204
|
attestation?: Attestation;
|
|
167
205
|
runtime?: ImplOf<typeof RuntimeCap>;
|
|
206
|
+
policy?: Policy;
|
|
168
207
|
}
|
|
169
208
|
|
|
170
209
|
export interface PendingCall {
|
|
@@ -173,18 +212,25 @@ export interface PendingCall {
|
|
|
173
212
|
abort: AbortController;
|
|
174
213
|
decodeReturn?: ReturnDecoder;
|
|
175
214
|
timer?: ReturnType<typeof setTimeout>;
|
|
215
|
+
startedAt?: number;
|
|
216
|
+
capId?: number;
|
|
217
|
+
capName?: string;
|
|
218
|
+
method?: string;
|
|
219
|
+
kind?: "call" | "stream";
|
|
176
220
|
}
|
|
177
221
|
|
|
178
222
|
type ReturnDecoder = (raw: unknown) => unknown;
|
|
179
223
|
|
|
224
|
+
// Default is conservative: "untrusted" until the host explicitly hands a real attestation
|
|
225
|
+
// (engine-mined for native, derived from req origin for web). Policy hooks should treat absence as deny-able.
|
|
180
226
|
const DEFAULT_ATTESTATION: Attestation = {
|
|
181
|
-
origin: "
|
|
182
|
-
topOrigin: "
|
|
227
|
+
origin: "",
|
|
228
|
+
topOrigin: "",
|
|
183
229
|
partition: "default",
|
|
184
|
-
isAppRes:
|
|
185
|
-
isMainFrame:
|
|
230
|
+
isAppRes: false,
|
|
231
|
+
isMainFrame: false,
|
|
186
232
|
userGesture: false,
|
|
187
|
-
level: "
|
|
233
|
+
level: "untrusted",
|
|
188
234
|
};
|
|
189
235
|
|
|
190
236
|
const EXPORTED_CAP_BRAND = Symbol("bunite.rpc.ExportedCap");
|
|
@@ -209,14 +255,26 @@ interface ServerStreamCtx {
|
|
|
209
255
|
cancelled: boolean;
|
|
210
256
|
credit: number;
|
|
211
257
|
creditWaker: (() => void) | null;
|
|
258
|
+
capId: number;
|
|
259
|
+
capName?: string;
|
|
260
|
+
method: string;
|
|
261
|
+
callId: number;
|
|
262
|
+
count: number;
|
|
212
263
|
}
|
|
213
264
|
|
|
214
265
|
interface ClientStreamCtx {
|
|
266
|
+
capId: number;
|
|
215
267
|
push(chunk: unknown): void;
|
|
216
268
|
end(): void;
|
|
217
269
|
fail(error: IpcError): void;
|
|
218
270
|
}
|
|
219
271
|
|
|
272
|
+
interface RegistryEntry {
|
|
273
|
+
cap: CapDef<any, any>;
|
|
274
|
+
impl: unknown;
|
|
275
|
+
version?: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
220
278
|
class ConnectionImpl implements Connection {
|
|
221
279
|
private readonly transport: Transport;
|
|
222
280
|
private readonly capTable: CapTable;
|
|
@@ -224,10 +282,15 @@ class ConnectionImpl implements Connection {
|
|
|
224
282
|
private readonly clientStreams = new Map<number, ClientStreamCtx>();
|
|
225
283
|
private readonly serverStreams = new Map<number, ServerStreamCtx>();
|
|
226
284
|
private readonly serverCallChildren = new Map<number, { parentId: number }>();
|
|
227
|
-
private readonly serverActiveCalls = new Map<number, AbortController>();
|
|
285
|
+
private readonly serverActiveCalls = new Map<number, { ctrl: AbortController; capId: number; capName: string; method: string; startedAt: number }>();
|
|
286
|
+
/** Server-side: name → registry entry. */
|
|
287
|
+
private readonly registry = new Map<string, RegistryEntry>();
|
|
288
|
+
/** Server-side: name → instance cap-id (cached bootstrap result). */
|
|
228
289
|
private readonly rootInstances = new Map<string, number>();
|
|
229
|
-
|
|
290
|
+
/** Client-side: cap-ids that the server revoked via cap_revoked. */
|
|
291
|
+
private readonly revokedCapIds = new Set<number>();
|
|
230
292
|
private readonly closeHandlers = new Set<() => void>();
|
|
293
|
+
private readonly observers: { [K in keyof ConnectionEvents]?: Set<(e: ConnectionEvents[K]) => void> } = {};
|
|
231
294
|
private nextCallId = 1;
|
|
232
295
|
private remoteHello: HelloFrame | null = null;
|
|
233
296
|
private readonly remoteReady: Promise<HelloFrame>;
|
|
@@ -235,11 +298,13 @@ class ConnectionImpl implements Connection {
|
|
|
235
298
|
private rejectRemoteReady!: (e: Error) => void;
|
|
236
299
|
private closed_ = false;
|
|
237
300
|
private readonly maxBytes: number;
|
|
301
|
+
private readonly maxInFlightCalls: number;
|
|
238
302
|
private readonly mode: "native" | "web";
|
|
239
303
|
private readonly origin: string;
|
|
240
304
|
private readonly features: string[];
|
|
241
305
|
private readonly attestation: Attestation;
|
|
242
306
|
private readonly peerId: string;
|
|
307
|
+
private readonly policy: Policy | undefined;
|
|
243
308
|
|
|
244
309
|
constructor(opts: ConnectionOptions) {
|
|
245
310
|
this.transport = opts.transport;
|
|
@@ -247,16 +312,20 @@ class ConnectionImpl implements Connection {
|
|
|
247
312
|
this.origin = opts.origin;
|
|
248
313
|
this.features = opts.features ?? [];
|
|
249
314
|
this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
315
|
+
this.maxInFlightCalls = opts.maxInFlightCalls ?? MAX_IN_FLIGHT_CALLS_PER_CONNECTION;
|
|
250
316
|
this.attestation = opts.attestation ?? DEFAULT_ATTESTATION;
|
|
251
317
|
this.peerId = opts.peerId ?? "peer";
|
|
318
|
+
this.policy = opts.policy;
|
|
252
319
|
this.capTable = new CapTable(opts.capLimit ?? MAX_CAPS_PER_CONNECTION);
|
|
253
320
|
|
|
321
|
+
// cap-id 0 = bootstrap dispatcher (no cap def — special-cased in handleCall)
|
|
254
322
|
this.capTable.install(USER_ROOTS_CAP_ID, {
|
|
255
323
|
typeId: USER_ROOTS_TYPE_ID,
|
|
256
324
|
cap: null,
|
|
257
325
|
impl: null,
|
|
258
326
|
refCount: 1,
|
|
259
327
|
});
|
|
328
|
+
// cap-id 1 = Runtime instance (framework-stable, pre-installed)
|
|
260
329
|
this.capTable.install(RUNTIME_CAP_ID, {
|
|
261
330
|
typeId: RUNTIME_TYPE_ID,
|
|
262
331
|
cap: RuntimeCap,
|
|
@@ -290,48 +359,146 @@ class ConnectionImpl implements Connection {
|
|
|
290
359
|
return () => this.closeHandlers.delete(handler);
|
|
291
360
|
}
|
|
292
361
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (!
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
362
|
+
on<K extends keyof ConnectionEvents>(event: K, handler: (e: ConnectionEvents[K]) => void): () => void {
|
|
363
|
+
let set = this.observers[event] as Set<(e: ConnectionEvents[K]) => void> | undefined;
|
|
364
|
+
if (!set) {
|
|
365
|
+
set = new Set();
|
|
366
|
+
(this.observers as Record<string, Set<unknown>>)[event] = set as unknown as Set<unknown>;
|
|
367
|
+
}
|
|
368
|
+
set.add(handler);
|
|
369
|
+
return () => { set!.delete(handler); };
|
|
301
370
|
}
|
|
302
371
|
|
|
303
|
-
private
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
325
|
-
|
|
372
|
+
private emitObs<K extends keyof ConnectionEvents>(event: K, data: ConnectionEvents[K]): void {
|
|
373
|
+
const set = this.observers[event] as Set<(e: ConnectionEvents[K]) => void> | undefined;
|
|
374
|
+
if (!set || set.size === 0) return;
|
|
375
|
+
for (const h of set) {
|
|
376
|
+
try { h(data); } catch { /* swallow */ }
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---- serve / unserve / replace ----
|
|
381
|
+
|
|
382
|
+
serve<C extends CapDef<any, any>>(cap: C, impl: ImplOf<C>, opts?: { ifExists?: IfExists }): ServeHandle {
|
|
383
|
+
this.assertNotFrameworkName(cap.name);
|
|
384
|
+
const ifExists = opts?.ifExists ?? "throw";
|
|
385
|
+
const existing = this.registry.get(cap.name);
|
|
386
|
+
if (existing) {
|
|
387
|
+
switch (ifExists) {
|
|
388
|
+
case "throw":
|
|
389
|
+
throw new IpcError({
|
|
390
|
+
code: "already_exists",
|
|
391
|
+
message: `cap "${cap.name}" already served`,
|
|
392
|
+
details: { reason: "name_collision" as AlreadyExistsReason },
|
|
393
|
+
});
|
|
394
|
+
case "skip":
|
|
395
|
+
// Empty handle: skipping means we are not the owner — unserve must not revoke someone else's registration.
|
|
396
|
+
return this.makeHandle([]);
|
|
397
|
+
case "replace":
|
|
398
|
+
this.replace(cap, impl);
|
|
399
|
+
return this.makeHandle([cap.name]);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
this.registry.set(cap.name, { cap, impl, version: cap.version });
|
|
403
|
+
return this.makeHandle([cap.name]);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private makeHandle(names: string[]): ServeHandle {
|
|
407
|
+
const handle: ServeHandle = {
|
|
408
|
+
names,
|
|
409
|
+
[Symbol.dispose]: () => this.unserve(handle),
|
|
410
|
+
};
|
|
411
|
+
return handle;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
serveAll<R extends SchemaRoots>(schema: Schema<R>, impls: ImplsOf<R>, opts?: { ifExists?: IfExists }): ServeHandle {
|
|
415
|
+
const ifExists = opts?.ifExists ?? "throw";
|
|
416
|
+
// Pre-validate everything that can fail across all modes before mutating any state (atomicity).
|
|
417
|
+
for (const k of Object.keys(schema.roots)) {
|
|
418
|
+
const c = schema.roots[k];
|
|
419
|
+
this.assertNotFrameworkName(c.name);
|
|
420
|
+
const existing = this.registry.get(c.name);
|
|
421
|
+
if (existing) {
|
|
422
|
+
if (ifExists === "throw") {
|
|
423
|
+
throw new IpcError({
|
|
424
|
+
code: "already_exists",
|
|
425
|
+
message: `cap "${c.name}" already served`,
|
|
426
|
+
details: { reason: "name_collision" as AlreadyExistsReason },
|
|
427
|
+
});
|
|
326
428
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
429
|
+
if (ifExists === "replace" && existing.version !== c.version) {
|
|
430
|
+
throw new IpcError({
|
|
431
|
+
code: "failed_precondition",
|
|
432
|
+
message: `version mismatch on replace for "${c.name}" (current "${existing.version}", new "${c.version}")`,
|
|
433
|
+
details: { reason: "version_mismatch" as FailedPreconditionReason },
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// Mutations below cannot fail (pre-validation already cleared collision/version/prefix paths).
|
|
439
|
+
const names: string[] = [];
|
|
440
|
+
for (const k of Object.keys(schema.roots)) {
|
|
441
|
+
const c = schema.roots[k];
|
|
442
|
+
const i = (impls as Record<string, unknown>)[k];
|
|
443
|
+
const h = this.serve(c, i as ImplOf<typeof c>, { ifExists });
|
|
444
|
+
// serve(...) returns empty names[] only for skipped entries — exclude from the aggregate handle.
|
|
445
|
+
if (h.names.length > 0) names.push(c.name);
|
|
331
446
|
}
|
|
332
|
-
return
|
|
447
|
+
return this.makeHandle(names);
|
|
333
448
|
}
|
|
334
449
|
|
|
450
|
+
unserve(target: CapDef<any, any> | ServeHandle): void {
|
|
451
|
+
const names = isCapDef(target) ? [target.name] : Array.from((target as ServeHandle).names);
|
|
452
|
+
const revoked: number[] = [];
|
|
453
|
+
for (const name of names) {
|
|
454
|
+
if (!this.registry.delete(name)) continue;
|
|
455
|
+
const instId = this.rootInstances.get(name);
|
|
456
|
+
if (instId !== undefined) {
|
|
457
|
+
const entry = this.capTable.get(instId);
|
|
458
|
+
if (entry) this.invokeServerDisposal(entry);
|
|
459
|
+
this.rootInstances.delete(name);
|
|
460
|
+
this.capTable.delete(instId);
|
|
461
|
+
revoked.push(instId);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (revoked.length > 0) {
|
|
465
|
+
this.transport.send({ op: "cap_revoked", capIds: revoked });
|
|
466
|
+
this.emitObs("revoke", { capIds: revoked, reason: "unserve" });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
replace<C extends CapDef<any, any>>(cap: C, impl: ImplOf<C>): void {
|
|
471
|
+
const entry = this.registry.get(cap.name);
|
|
472
|
+
if (!entry) throw new IpcError({ code: "not_found", message: `cap "${cap.name}" not served` });
|
|
473
|
+
if (entry.version !== cap.version) {
|
|
474
|
+
throw new IpcError({
|
|
475
|
+
code: "failed_precondition",
|
|
476
|
+
message: `version mismatch (current "${entry.version}", new "${cap.version}")`,
|
|
477
|
+
details: { reason: "version_mismatch" as FailedPreconditionReason },
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
entry.impl = impl;
|
|
481
|
+
entry.cap = cap;
|
|
482
|
+
const instId = this.rootInstances.get(cap.name);
|
|
483
|
+
if (instId !== undefined) {
|
|
484
|
+
const e = this.capTable.get(instId);
|
|
485
|
+
if (e) { e.impl = impl; e.cap = cap; }
|
|
486
|
+
}
|
|
487
|
+
this.emitObs("revoke", { capIds: instId !== undefined ? [instId] : [], reason: "replace" });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private assertNotFrameworkName(name: string): void {
|
|
491
|
+
if (name.startsWith(FRAMEWORK_NAME_PREFIX)) {
|
|
492
|
+
throw new IpcError({
|
|
493
|
+
code: "already_exists",
|
|
494
|
+
message: `cap name "${name}" uses reserved prefix "${FRAMEWORK_NAME_PREFIX}"`,
|
|
495
|
+
details: { reason: "reserved_namespace" as AlreadyExistsReason },
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ---- runtime / bootstrap ----
|
|
501
|
+
|
|
335
502
|
private runtimeProxy: ClientOf<typeof RuntimeCap> | null = null;
|
|
336
503
|
|
|
337
504
|
runtime(): ClientOf<typeof RuntimeCap> {
|
|
@@ -341,25 +508,49 @@ class ConnectionImpl implements Connection {
|
|
|
341
508
|
return this.runtimeProxy;
|
|
342
509
|
}
|
|
343
510
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
511
|
+
bootstrap<C extends CapDef<any, any>>(cap: C): Promise<ClientOf<C>>;
|
|
512
|
+
bootstrap<R extends SchemaRoots>(schema: Schema<R>): Promise<{ [K in keyof R]: ClientOf<R[K]> }>;
|
|
513
|
+
async bootstrap(target: CapDef<any, any> | Schema<any>): Promise<unknown> {
|
|
514
|
+
if (isCapDef(target)) return this._bootstrapCap(target);
|
|
515
|
+
if (isSchema(target)) return this._bootstrapSchema(target);
|
|
516
|
+
throw new IpcError({ code: "invalid_argument", message: "bootstrap target must be CapDef or Schema" });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private async _bootstrapCap<C extends CapDef<any, any>>(cap: C): Promise<ClientOf<C>> {
|
|
348
520
|
await this.remoteReady;
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
throw new IpcError({ code: "invalid_argument", message: `root "${name}" not in schema` });
|
|
353
|
-
}
|
|
354
|
-
const rootCap = schema.roots[name] as CapDef<any, any>;
|
|
355
|
-
const hash = await schema.topologyHash();
|
|
356
|
-
const raw = await this.sendCallTyped(USER_ROOTS_CAP_ID, idx, undefined, undefined, { topologyHash: hash });
|
|
521
|
+
const args: { name: string; version?: string } = { name: cap.name };
|
|
522
|
+
if (cap.version != null) args.version = cap.version;
|
|
523
|
+
const raw = await this.sendCallTyped(USER_ROOTS_CAP_ID, BOOTSTRAP_METHOD, args, undefined);
|
|
357
524
|
if (!(raw instanceof CapRef)) {
|
|
358
|
-
throw new IpcError({ code: "
|
|
525
|
+
throw new IpcError({ code: "invalid_argument", message: "bootstrap did not return a CapRef" });
|
|
526
|
+
}
|
|
527
|
+
return this.makeCapProxy(cap, raw.capId) as ClientOf<C>;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private async _bootstrapSchema<R extends SchemaRoots>(
|
|
531
|
+
schema: Schema<R>
|
|
532
|
+
): Promise<{ [K in keyof R]: ClientOf<R[K]> }> {
|
|
533
|
+
const keys = Object.keys(schema.roots) as (keyof R & string)[];
|
|
534
|
+
const settled = await Promise.allSettled(keys.map((k) => this._bootstrapCap(schema.roots[k])));
|
|
535
|
+
const rejected = settled.find((r): r is PromiseRejectedResult => r.status === "rejected");
|
|
536
|
+
if (rejected) {
|
|
537
|
+
// Release server refCount on the roots that succeeded — otherwise their cap-table entries linger until connection close.
|
|
538
|
+
for (const r of settled) {
|
|
539
|
+
if (r.status === "fulfilled") {
|
|
540
|
+
try { this.releaseRef(r.value); } catch { /* swallow */ }
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
throw rejected.reason;
|
|
544
|
+
}
|
|
545
|
+
const out = {} as { [K in keyof R]: ClientOf<R[K]> };
|
|
546
|
+
for (let i = 0; i < keys.length; i++) {
|
|
547
|
+
(out as Record<string, unknown>)[keys[i]] = (settled[i] as PromiseFulfilledResult<unknown>).value;
|
|
359
548
|
}
|
|
360
|
-
return
|
|
549
|
+
return out;
|
|
361
550
|
}
|
|
362
551
|
|
|
552
|
+
// ---- frame dispatch ----
|
|
553
|
+
|
|
363
554
|
private handleFrame(frame: Frame): void {
|
|
364
555
|
if (this.closed_) return;
|
|
365
556
|
switch (frame.op) {
|
|
@@ -381,6 +572,9 @@ class ConnectionImpl implements Connection {
|
|
|
381
572
|
case "drop":
|
|
382
573
|
this.handleDrop(frame);
|
|
383
574
|
return;
|
|
575
|
+
case "cap_revoked":
|
|
576
|
+
this.handleCapRevoked(frame);
|
|
577
|
+
return;
|
|
384
578
|
case "goaway":
|
|
385
579
|
this.handleGoaway(frame);
|
|
386
580
|
return;
|
|
@@ -393,11 +587,11 @@ class ConnectionImpl implements Connection {
|
|
|
393
587
|
private handleUnknownFrame(frame: unknown): void {
|
|
394
588
|
const id = (frame as { id?: unknown })?.id;
|
|
395
589
|
if (typeof id === "number") {
|
|
396
|
-
this.transport.send({ op: "result", id, ok: false, error: { code: "
|
|
590
|
+
this.transport.send({ op: "result", id, ok: false, error: { code: "invalid_argument", message: "unknown opcode" } });
|
|
397
591
|
return;
|
|
398
592
|
}
|
|
399
|
-
this.transport.send({ op: "goaway", reason: "
|
|
400
|
-
this.shutdown("
|
|
593
|
+
this.transport.send({ op: "goaway", reason: "invalid_argument", error: { code: "invalid_argument", message: "unknown opcode" } });
|
|
594
|
+
this.shutdown("invalid_argument");
|
|
401
595
|
}
|
|
402
596
|
|
|
403
597
|
private handleHello(frame: HelloFrame): void {
|
|
@@ -412,44 +606,158 @@ class ConnectionImpl implements Connection {
|
|
|
412
606
|
this.shutdown(frame.reason ?? "remote goaway");
|
|
413
607
|
}
|
|
414
608
|
|
|
609
|
+
private handleCapRevoked(frame: CapRevokedFrame): void {
|
|
610
|
+
for (const capId of frame.capIds) {
|
|
611
|
+
this.revokedCapIds.add(capId);
|
|
612
|
+
const err = new IpcError({
|
|
613
|
+
code: "failed_precondition",
|
|
614
|
+
message: "cap revoked",
|
|
615
|
+
details: { reason: "revoked" as FailedPreconditionReason },
|
|
616
|
+
});
|
|
617
|
+
// Fail pending calls targeting this cap.
|
|
618
|
+
for (const [id, p] of this.pending) {
|
|
619
|
+
if (p.capId === capId) {
|
|
620
|
+
this.pending.delete(id);
|
|
621
|
+
if (p.timer) clearTimeout(p.timer);
|
|
622
|
+
p.reject(err);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// Fail active client streams targeting this cap — "revoked wins" symmetric with calls.
|
|
626
|
+
for (const [id, s] of this.clientStreams) {
|
|
627
|
+
if (s.capId === capId) {
|
|
628
|
+
this.clientStreams.delete(id);
|
|
629
|
+
s.fail(err);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
415
635
|
private async handleCall(frame: CallFrame): Promise<void> {
|
|
636
|
+
if (frame.target.id === USER_ROOTS_CAP_ID && frame.method === BOOTSTRAP_METHOD) {
|
|
637
|
+
await this.handleBootstrap(frame);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
|
|
416
641
|
const entry = this.capTable.get(frame.target.id);
|
|
417
|
-
if (!entry)
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
642
|
+
if (!entry) {
|
|
643
|
+
this.emitObs("call", { capId: frame.target.id, method: frame.method, callId: frame.id, result: "not_found" });
|
|
644
|
+
return this.sendError(frame.id, "not_found", `cap-id ${frame.target.id} not found`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const cap = entry.cap;
|
|
648
|
+
if (!cap || !entry.impl) {
|
|
649
|
+
this.emitObs("call", { capId: frame.target.id, method: frame.method, callId: frame.id, result: "not_found" });
|
|
650
|
+
return this.sendError(frame.id, "not_found", "cap has no impl");
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const methodDef = cap.methods[frame.method] as MethodDef | undefined;
|
|
654
|
+
if (!methodDef) {
|
|
655
|
+
this.emitObs("call", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, result: "not_found" });
|
|
656
|
+
return this.sendError(frame.id, "not_found", `method "${frame.method}" on cap "${cap.name}"`);
|
|
657
|
+
}
|
|
658
|
+
const impl = (entry.impl as Record<string, unknown>)[frame.method];
|
|
659
|
+
if (typeof impl !== "function") {
|
|
660
|
+
this.emitObs("call", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, result: "not_found" });
|
|
661
|
+
return this.sendError(frame.id, "not_found", `method "${frame.method}" has no handler`);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Bound inbound work — symmetric with sendCallTyped's outbound check.
|
|
665
|
+
if (this.serverActiveCalls.size + this.serverStreams.size >= this.maxInFlightCalls) {
|
|
666
|
+
this.emitObs("call", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, result: "resource_exhausted" });
|
|
667
|
+
return this.sendError(
|
|
668
|
+
frame.id,
|
|
669
|
+
"resource_exhausted",
|
|
670
|
+
`in-flight calls limit ${this.maxInFlightCalls}`,
|
|
671
|
+
{ reason: "max_concurrent_calls" as ResourceExhaustedReason },
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
await this.invokeServerMethod(frame, cap, methodDef, impl as (params: unknown, ctx: CallCtx) => unknown);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
private async handleBootstrap(frame: CallFrame): Promise<void> {
|
|
679
|
+
const args = (frame.args ?? {}) as { name?: unknown; version?: unknown };
|
|
680
|
+
const name = args.name;
|
|
681
|
+
if (typeof name !== "string") {
|
|
682
|
+
this.emitObs("bootstrap", { name: String(name), attestation: this.attestation, result: "invalid_argument" });
|
|
683
|
+
return this.sendError(frame.id, "invalid_argument", "bootstrap requires {name: string}");
|
|
684
|
+
}
|
|
685
|
+
const clientVersion = args.version != null ? String(args.version) : undefined;
|
|
686
|
+
|
|
687
|
+
// Framework caps (cap-id 1 Runtime is pre-installed and accessed via .runtime(),
|
|
688
|
+
// not via bootstrap). For user-facing bootstrap, only the registry is consulted.
|
|
689
|
+
const entry = this.registry.get(name);
|
|
690
|
+
if (!entry) {
|
|
691
|
+
this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "not_found" });
|
|
692
|
+
return this.sendError(frame.id, "not_found", `cap "${name}" not served`);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const serverVersion = entry.version;
|
|
696
|
+
if (serverVersion != null && clientVersion != null && serverVersion !== clientVersion) {
|
|
697
|
+
this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "version_mismatch" });
|
|
698
|
+
return this.sendError(
|
|
699
|
+
frame.id,
|
|
700
|
+
"failed_precondition",
|
|
701
|
+
`version mismatch (server "${serverVersion}", client "${clientVersion}")`,
|
|
702
|
+
{ reason: "version_mismatch" as FailedPreconditionReason }
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (this.policy) {
|
|
707
|
+
try {
|
|
708
|
+
const allowed = await this.policy(name, this.attestation);
|
|
709
|
+
if (!allowed) {
|
|
710
|
+
this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "denied" });
|
|
425
711
|
return this.sendError(
|
|
426
712
|
frame.id,
|
|
427
713
|
"failed_precondition",
|
|
428
|
-
|
|
714
|
+
"policy denied",
|
|
715
|
+
{ reason: "unauthorized" as FailedPreconditionReason }
|
|
429
716
|
);
|
|
430
717
|
}
|
|
718
|
+
} catch (err) {
|
|
719
|
+
this.emitObs("error", { phase: "policy", error: err instanceof Error ? err : new Error(String(err)) });
|
|
720
|
+
this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "internal" });
|
|
721
|
+
return this.sendError(frame.id, "internal", "policy threw");
|
|
431
722
|
}
|
|
432
723
|
}
|
|
433
724
|
|
|
434
|
-
|
|
435
|
-
if (
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
725
|
+
let capId = this.rootInstances.get(name);
|
|
726
|
+
if (capId !== undefined) {
|
|
727
|
+
const cached = this.capTable.get(capId);
|
|
728
|
+
if (cached) {
|
|
729
|
+
cached.refCount += 1;
|
|
730
|
+
} else {
|
|
731
|
+
this.rootInstances.delete(name);
|
|
732
|
+
capId = undefined;
|
|
733
|
+
}
|
|
440
734
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
735
|
+
if (capId === undefined) {
|
|
736
|
+
try {
|
|
737
|
+
const allocated = this.capTable.allocate({
|
|
738
|
+
typeId: frameworkTypeIdOf(entry.cap) ?? FIRST_USER_TYPE_ID,
|
|
739
|
+
cap: entry.cap,
|
|
740
|
+
impl: entry.impl,
|
|
741
|
+
refCount: 1,
|
|
742
|
+
});
|
|
743
|
+
capId = allocated.capId;
|
|
744
|
+
this.rootInstances.set(name, capId);
|
|
745
|
+
} catch (err) {
|
|
746
|
+
if (err instanceof IpcError) {
|
|
747
|
+
const result = err.code === "resource_exhausted" ? "resource_exhausted" : "internal";
|
|
748
|
+
this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result });
|
|
749
|
+
return this.sendError(frame.id, err.code, err.message, err.details as Record<string, unknown> | undefined);
|
|
750
|
+
}
|
|
751
|
+
throw err;
|
|
752
|
+
}
|
|
446
753
|
}
|
|
447
|
-
|
|
448
|
-
|
|
754
|
+
this.emitObs("bootstrap", { name, version: clientVersion, attestation: this.attestation, result: "ok", capId });
|
|
755
|
+
this.transport.send({ op: "result", id: frame.id, ok: true, value: new CapRef(capId) });
|
|
449
756
|
}
|
|
450
757
|
|
|
451
758
|
private async invokeServerMethod(
|
|
452
759
|
frame: CallFrame,
|
|
760
|
+
cap: CapDef<any, any>,
|
|
453
761
|
methodDef: MethodDef,
|
|
454
762
|
impl: (params: unknown, ctx: CallCtx) => unknown
|
|
455
763
|
): Promise<void> {
|
|
@@ -464,16 +772,24 @@ class ConnectionImpl implements Connection {
|
|
|
464
772
|
? () => als.run({ callId: frame.id }, runStream)
|
|
465
773
|
: runStream;
|
|
466
774
|
const stream = scoped();
|
|
467
|
-
this.runServerStream(frame.id, stream, ctx, hintBudget);
|
|
775
|
+
this.runServerStream(frame.id, stream, ctx, hintBudget, cap, frame.method, frame.target.id);
|
|
468
776
|
} catch (err) {
|
|
777
|
+
this.emitObs("stream", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, event: "error" });
|
|
469
778
|
this.transport.send({ op: "stream", id: frame.id, ev: "error", error: toStatus(err) });
|
|
470
779
|
}
|
|
471
780
|
return;
|
|
472
781
|
}
|
|
473
782
|
|
|
474
783
|
if (isCallDef(methodDef)) {
|
|
784
|
+
const startedAt = performance.now();
|
|
475
785
|
const deadlineTimer = methodDef.idempotent ? undefined : this.armServerDeadline(frame, ctx);
|
|
476
|
-
this.serverActiveCalls.set(frame.id,
|
|
786
|
+
this.serverActiveCalls.set(frame.id, {
|
|
787
|
+
ctrl: (ctx as unknown as { _ctrl: AbortController })._ctrl,
|
|
788
|
+
capId: frame.target.id,
|
|
789
|
+
capName: cap.name,
|
|
790
|
+
method: frame.method,
|
|
791
|
+
startedAt,
|
|
792
|
+
});
|
|
477
793
|
const encoder = makeReturnEncoder(methodDef.returns);
|
|
478
794
|
const runImpl = () => Promise.resolve(impl(frame.args, ctx));
|
|
479
795
|
const als = callContextStorage;
|
|
@@ -486,15 +802,18 @@ class ConnectionImpl implements Connection {
|
|
|
486
802
|
if (!this.serverActiveCalls.delete(frame.id)) return;
|
|
487
803
|
const encoded = encoder(result);
|
|
488
804
|
this.transport.send({ op: "result", id: frame.id, ok: true, value: encoded });
|
|
805
|
+
this.emitObs("call", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, durationMs: performance.now() - startedAt, result: "ok" });
|
|
489
806
|
} catch (err) {
|
|
490
807
|
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
491
808
|
if (!this.serverActiveCalls.delete(frame.id)) return;
|
|
492
|
-
|
|
809
|
+
const status = toStatus(err);
|
|
810
|
+
this.sendErrorFromStatus(frame.id, status);
|
|
811
|
+
this.emitObs("call", { capId: frame.target.id, capName: cap.name, method: frame.method, callId: frame.id, durationMs: performance.now() - startedAt, result: status.code });
|
|
493
812
|
}
|
|
494
813
|
return;
|
|
495
814
|
}
|
|
496
815
|
|
|
497
|
-
this.sendError(frame.id, "
|
|
816
|
+
this.sendError(frame.id, "not_found", "unknown method kind");
|
|
498
817
|
}
|
|
499
818
|
|
|
500
819
|
private armServerDeadline(frame: CallFrame, ctx: CallCtx): ReturnType<typeof setTimeout> | undefined {
|
|
@@ -536,7 +855,15 @@ class ConnectionImpl implements Connection {
|
|
|
536
855
|
} as unknown as ExportedCap<C>;
|
|
537
856
|
}
|
|
538
857
|
|
|
539
|
-
private runServerStream(
|
|
858
|
+
private runServerStream(
|
|
859
|
+
callId: number,
|
|
860
|
+
stream: StreamType<unknown>,
|
|
861
|
+
ctx: CallCtx,
|
|
862
|
+
initialCredit: number,
|
|
863
|
+
cap: CapDef<any, any>,
|
|
864
|
+
method: string,
|
|
865
|
+
capId: number,
|
|
866
|
+
): void {
|
|
540
867
|
const iter = (stream as AsyncIterable<unknown>)[Symbol.asyncIterator]();
|
|
541
868
|
const abort = (ctx as unknown as { _ctrl: AbortController })._ctrl;
|
|
542
869
|
const slot: ServerStreamCtx = {
|
|
@@ -545,8 +872,14 @@ class ConnectionImpl implements Connection {
|
|
|
545
872
|
cancelled: false,
|
|
546
873
|
credit: initialCredit,
|
|
547
874
|
creditWaker: null,
|
|
875
|
+
capId,
|
|
876
|
+
capName: cap.name,
|
|
877
|
+
method,
|
|
878
|
+
callId,
|
|
879
|
+
count: 0,
|
|
548
880
|
};
|
|
549
881
|
this.serverStreams.set(callId, slot);
|
|
882
|
+
this.emitObs("stream", { capId, capName: cap.name, method, callId, event: "start" });
|
|
550
883
|
|
|
551
884
|
const waitForCredit = (): Promise<void> => {
|
|
552
885
|
if (slot.credit > 0 || slot.cancelled) return Promise.resolve();
|
|
@@ -563,13 +896,18 @@ class ConnectionImpl implements Connection {
|
|
|
563
896
|
if (done) break;
|
|
564
897
|
if (slot.cancelled) break;
|
|
565
898
|
slot.credit -= 1;
|
|
899
|
+
slot.count += 1;
|
|
566
900
|
this.transport.send({ op: "stream", id: callId, ev: "next", value });
|
|
567
901
|
}
|
|
568
902
|
} catch (err) {
|
|
569
903
|
errored = true;
|
|
570
904
|
this.transport.send({ op: "stream", id: callId, ev: "error", error: toStatus(err) });
|
|
905
|
+
this.emitObs("stream", { capId, capName: cap.name, method, callId, event: "error", count: slot.count });
|
|
571
906
|
} finally {
|
|
572
|
-
if (!errored)
|
|
907
|
+
if (!errored) {
|
|
908
|
+
this.transport.send({ op: "stream", id: callId, ev: "end" });
|
|
909
|
+
this.emitObs("stream", { capId, capName: cap.name, method, callId, event: slot.cancelled ? "cancel" : "end", count: slot.count });
|
|
910
|
+
}
|
|
573
911
|
this.serverStreams.delete(callId);
|
|
574
912
|
}
|
|
575
913
|
};
|
|
@@ -594,7 +932,7 @@ class ConnectionImpl implements Connection {
|
|
|
594
932
|
if (stream) {
|
|
595
933
|
this.clientStreams.delete(frame.id);
|
|
596
934
|
const err = frame.ok
|
|
597
|
-
? new IpcError({ code: "
|
|
935
|
+
? new IpcError({ code: "invalid_argument", message: "stream method returned result frame" })
|
|
598
936
|
: new IpcError(frame.error);
|
|
599
937
|
stream.fail(err);
|
|
600
938
|
}
|
|
@@ -611,13 +949,21 @@ class ConnectionImpl implements Connection {
|
|
|
611
949
|
const active = this.serverActiveCalls.get(frame.id);
|
|
612
950
|
if (active) {
|
|
613
951
|
this.serverActiveCalls.delete(frame.id);
|
|
614
|
-
active.abort();
|
|
952
|
+
active.ctrl.abort();
|
|
615
953
|
this.transport.send({
|
|
616
954
|
op: "result",
|
|
617
955
|
id: frame.id,
|
|
618
956
|
ok: false,
|
|
619
957
|
error: { code: "cancelled", message: frame.reason },
|
|
620
958
|
});
|
|
959
|
+
this.emitObs("call", {
|
|
960
|
+
capId: active.capId,
|
|
961
|
+
capName: active.capName,
|
|
962
|
+
method: active.method,
|
|
963
|
+
callId: frame.id,
|
|
964
|
+
durationMs: performance.now() - active.startedAt,
|
|
965
|
+
result: "cancelled",
|
|
966
|
+
});
|
|
621
967
|
}
|
|
622
968
|
for (const [childId, child] of this.serverCallChildren) {
|
|
623
969
|
if (child.parentId !== frame.id) continue;
|
|
@@ -680,34 +1026,52 @@ class ConnectionImpl implements Connection {
|
|
|
680
1026
|
}
|
|
681
1027
|
}
|
|
682
1028
|
|
|
683
|
-
private sendError(callId: number, code: IpcCode, message?: string): void {
|
|
684
|
-
|
|
1029
|
+
private sendError(callId: number, code: IpcCode, message?: string, details?: unknown): void {
|
|
1030
|
+
const error: IpcStatus = { code, message };
|
|
1031
|
+
if (details !== undefined) error.details = details;
|
|
1032
|
+
this.transport.send({ op: "result", id: callId, ok: false, error });
|
|
685
1033
|
}
|
|
686
1034
|
|
|
687
|
-
private
|
|
688
|
-
this.transport.send({ op: "result", id: callId, ok: false, error:
|
|
1035
|
+
private sendErrorFromStatus(callId: number, status: IpcStatus): void {
|
|
1036
|
+
this.transport.send({ op: "result", id: callId, ok: false, error: status });
|
|
689
1037
|
}
|
|
690
1038
|
|
|
691
1039
|
private nextId(): number {
|
|
692
1040
|
return this.nextCallId++;
|
|
693
1041
|
}
|
|
694
1042
|
|
|
695
|
-
sendCallRaw(capId: number, methodIdx: number, args: unknown, meta?: CallMeta): Promise<unknown> {
|
|
696
|
-
return this.sendCallTyped(capId, methodIdx, args, undefined, meta);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
1043
|
sendCallTyped(
|
|
700
1044
|
capId: number,
|
|
701
|
-
|
|
1045
|
+
method: string,
|
|
702
1046
|
args: unknown,
|
|
703
1047
|
decodeReturn: ReturnDecoder | undefined,
|
|
704
|
-
meta?: CallMeta
|
|
1048
|
+
meta?: CallMeta,
|
|
1049
|
+
capName?: string,
|
|
705
1050
|
): Promise<unknown> {
|
|
706
1051
|
if (this.closed_) return Promise.reject(new IpcError({ code: "unavailable", message: "connection closed" }));
|
|
1052
|
+
if (this.revokedCapIds.has(capId)) {
|
|
1053
|
+
return Promise.reject(new IpcError({
|
|
1054
|
+
code: "failed_precondition",
|
|
1055
|
+
message: "cap revoked",
|
|
1056
|
+
details: { reason: "revoked" as FailedPreconditionReason },
|
|
1057
|
+
}));
|
|
1058
|
+
}
|
|
1059
|
+
if (this.pending.size >= this.maxInFlightCalls) {
|
|
1060
|
+
return Promise.reject(new IpcError({
|
|
1061
|
+
code: "resource_exhausted",
|
|
1062
|
+
message: `in-flight calls limit ${this.maxInFlightCalls}`,
|
|
1063
|
+
details: { reason: "max_concurrent_calls" as ResourceExhaustedReason },
|
|
1064
|
+
}));
|
|
1065
|
+
}
|
|
707
1066
|
const id = this.nextId();
|
|
708
1067
|
return new Promise((resolve, reject) => {
|
|
709
1068
|
const abort = new AbortController();
|
|
710
|
-
const pending: PendingCall = {
|
|
1069
|
+
const pending: PendingCall = {
|
|
1070
|
+
resolve, reject, abort, decodeReturn,
|
|
1071
|
+
startedAt: performance.now(),
|
|
1072
|
+
capId, method, capName,
|
|
1073
|
+
kind: "call",
|
|
1074
|
+
};
|
|
711
1075
|
if (meta?.deadlineMs) {
|
|
712
1076
|
pending.timer = setTimeout(() => {
|
|
713
1077
|
if (this.pending.delete(id)) {
|
|
@@ -725,16 +1089,31 @@ class ConnectionImpl implements Connection {
|
|
|
725
1089
|
if (finalMeta?.parentCallId !== undefined) {
|
|
726
1090
|
this.serverCallChildren.set(id, { parentId: finalMeta.parentCallId });
|
|
727
1091
|
}
|
|
728
|
-
this.transport.send({ op: "call", id, target: { kind: "cap", id: capId }, method
|
|
1092
|
+
this.transport.send({ op: "call", id, target: { kind: "cap", id: capId }, method, args, meta: finalMeta });
|
|
729
1093
|
});
|
|
730
1094
|
}
|
|
731
1095
|
|
|
732
|
-
openClientStream<T>(
|
|
1096
|
+
openClientStream<T>(
|
|
1097
|
+
capId: number,
|
|
1098
|
+
method: string,
|
|
1099
|
+
args: unknown,
|
|
1100
|
+
meta?: CallMeta,
|
|
1101
|
+
capName?: string,
|
|
1102
|
+
): StreamType<T> {
|
|
733
1103
|
if (this.closed_) {
|
|
734
|
-
const empty = makeClientStream<T>(0, () => {}, () => {});
|
|
1104
|
+
const empty = makeClientStream<T>(capId, 0, () => {}, () => {});
|
|
735
1105
|
empty.fail(new IpcError({ code: "unavailable", message: "connection closed" }));
|
|
736
1106
|
return empty.stream;
|
|
737
1107
|
}
|
|
1108
|
+
if (this.revokedCapIds.has(capId)) {
|
|
1109
|
+
const empty = makeClientStream<T>(capId, 0, () => {}, () => {});
|
|
1110
|
+
empty.fail(new IpcError({
|
|
1111
|
+
code: "failed_precondition",
|
|
1112
|
+
message: "cap revoked",
|
|
1113
|
+
details: { reason: "revoked" as FailedPreconditionReason },
|
|
1114
|
+
}));
|
|
1115
|
+
return empty.stream;
|
|
1116
|
+
}
|
|
738
1117
|
const id = this.nextId();
|
|
739
1118
|
const cancel = () => {
|
|
740
1119
|
if (this.clientStreams.delete(id)) {
|
|
@@ -745,14 +1124,14 @@ class ConnectionImpl implements Connection {
|
|
|
745
1124
|
if (this.closed_ || !this.clientStreams.has(id)) return;
|
|
746
1125
|
this.transport.send({ op: "stream", id, ev: "credit", credit: { messages } });
|
|
747
1126
|
};
|
|
748
|
-
const ctx = makeClientStream<T>(id, cancel, sendCredit);
|
|
1127
|
+
const ctx = makeClientStream<T>(capId, id, cancel, sendCredit);
|
|
749
1128
|
this.clientStreams.set(id, ctx);
|
|
750
|
-
this.transport.send({ op: "call", id, target: { kind: "cap", id: capId }, method
|
|
1129
|
+
this.transport.send({ op: "call", id, target: { kind: "cap", id: capId }, method, args, meta });
|
|
1130
|
+
void capName;
|
|
751
1131
|
return ctx.stream;
|
|
752
1132
|
}
|
|
753
1133
|
|
|
754
1134
|
makeCapProxy<C extends CapDef<any, any>>(capDef: C, capId: number): ClientOf<C> {
|
|
755
|
-
const methodNames = Object.keys(capDef.methods);
|
|
756
1135
|
const proxy: Record<string | symbol, unknown> = {};
|
|
757
1136
|
const meta = { capId, dropped: false };
|
|
758
1137
|
proxy[CAP_PROXY_META] = meta;
|
|
@@ -762,32 +1141,31 @@ class ConnectionImpl implements Connection {
|
|
|
762
1141
|
this.sendDrop(capId);
|
|
763
1142
|
};
|
|
764
1143
|
|
|
765
|
-
for (
|
|
766
|
-
const name = methodNames[i];
|
|
1144
|
+
for (const name of Object.keys(capDef.methods)) {
|
|
767
1145
|
const def = capDef.methods[name] as MethodDef;
|
|
768
1146
|
if (isStreamDef(def)) {
|
|
769
|
-
proxy[name] = (params?: unknown) => this.openClientStream(capId,
|
|
1147
|
+
proxy[name] = (params?: unknown) => this.openClientStream(capId, name, params, undefined, capDef.name);
|
|
770
1148
|
} else if (isCallDef(def)) {
|
|
771
1149
|
const decoder = makeReturnDecoder(def.returns, (cd, cid) => this.makeCapProxy(cd, cid));
|
|
772
|
-
proxy[name] = (params?: unknown) => this.sendCallTyped(capId,
|
|
1150
|
+
proxy[name] = (params?: unknown) => this.sendCallTyped(capId, name, params, decoder, undefined, capDef.name);
|
|
773
1151
|
}
|
|
774
1152
|
}
|
|
775
1153
|
|
|
776
1154
|
const disposal = capDef.disposal as DisposalSpec | undefined;
|
|
777
1155
|
if (disposal) {
|
|
778
|
-
const
|
|
779
|
-
const fn = proxy[disposal.method] as (() => unknown) | undefined;
|
|
780
|
-
return fn?.();
|
|
781
|
-
};
|
|
782
|
-
proxy[Symbol.asyncDispose] = async () => {
|
|
783
|
-
const r = invokeMethod();
|
|
784
|
-
await Promise.resolve(r);
|
|
785
|
-
drop();
|
|
786
|
-
};
|
|
1156
|
+
const conn = this;
|
|
787
1157
|
proxy[Symbol.dispose] = () => {
|
|
788
|
-
|
|
1158
|
+
try {
|
|
1159
|
+
const r = (proxy[disposal.method] as (() => unknown) | undefined)?.();
|
|
1160
|
+
if (r && typeof (r as Promise<unknown>).then === "function") {
|
|
1161
|
+
(r as Promise<unknown>).catch((err) =>
|
|
1162
|
+
conn.emitObs("error", { phase: "dispose", error: err instanceof Error ? err : new Error(String(err)) })
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
} catch (err) {
|
|
1166
|
+
conn.emitObs("error", { phase: "dispose", error: err instanceof Error ? err : new Error(String(err)) });
|
|
1167
|
+
}
|
|
789
1168
|
drop();
|
|
790
|
-
void r;
|
|
791
1169
|
};
|
|
792
1170
|
}
|
|
793
1171
|
|
|
@@ -824,8 +1202,8 @@ class ConnectionImpl implements Connection {
|
|
|
824
1202
|
void slot.iter?.return?.();
|
|
825
1203
|
}
|
|
826
1204
|
this.serverStreams.clear();
|
|
827
|
-
for (const
|
|
828
|
-
ctrl.abort();
|
|
1205
|
+
for (const a of this.serverActiveCalls.values()) {
|
|
1206
|
+
a.ctrl.abort();
|
|
829
1207
|
}
|
|
830
1208
|
this.serverActiveCalls.clear();
|
|
831
1209
|
for (const entry of this.capTable.values()) {
|
|
@@ -858,10 +1236,18 @@ function makeReturnEncoder(returns: AnyCapToken | undefined): (v: unknown) => un
|
|
|
858
1236
|
if (!returns) return (v) => v;
|
|
859
1237
|
const expectExportedCap = (v: unknown, expectedCap: CapDef<any, any>): CapRef => {
|
|
860
1238
|
if (!isExportedCap(v)) {
|
|
861
|
-
throw new IpcError({
|
|
1239
|
+
throw new IpcError({
|
|
1240
|
+
code: "failed_precondition",
|
|
1241
|
+
message: "expected ctx.exportCap return for cap method",
|
|
1242
|
+
details: { reason: "unregistered_cap_return" as FailedPreconditionReason },
|
|
1243
|
+
});
|
|
862
1244
|
}
|
|
863
1245
|
if (v.cap !== expectedCap) {
|
|
864
|
-
throw new IpcError({
|
|
1246
|
+
throw new IpcError({
|
|
1247
|
+
code: "failed_precondition",
|
|
1248
|
+
message: "exported cap type mismatch with method returns",
|
|
1249
|
+
details: { reason: "unregistered_cap_return" as FailedPreconditionReason },
|
|
1250
|
+
});
|
|
865
1251
|
}
|
|
866
1252
|
return new CapRef(v.capId);
|
|
867
1253
|
};
|
|
@@ -869,7 +1255,7 @@ function makeReturnEncoder(returns: AnyCapToken | undefined): (v: unknown) => un
|
|
|
869
1255
|
if (isCapArray(returns)) {
|
|
870
1256
|
return (v) => {
|
|
871
1257
|
if (!Array.isArray(v)) {
|
|
872
|
-
throw new IpcError({ code: "
|
|
1258
|
+
throw new IpcError({ code: "invalid_argument", message: "expected ExportedCap[] for cap.array method" });
|
|
873
1259
|
}
|
|
874
1260
|
return v.map((item) => expectExportedCap(item, returns.cap));
|
|
875
1261
|
};
|
|
@@ -877,7 +1263,7 @@ function makeReturnEncoder(returns: AnyCapToken | undefined): (v: unknown) => un
|
|
|
877
1263
|
if (isCapRecord(returns)) {
|
|
878
1264
|
return (v) => {
|
|
879
1265
|
if (!v || typeof v !== "object") {
|
|
880
|
-
throw new IpcError({ code: "
|
|
1266
|
+
throw new IpcError({ code: "invalid_argument", message: "expected Record<string, ExportedCap> for cap.record method" });
|
|
881
1267
|
}
|
|
882
1268
|
const out: Record<string, CapRef> = {};
|
|
883
1269
|
for (const k of Object.keys(v as object)) {
|
|
@@ -897,7 +1283,7 @@ function makeReturnDecoder(
|
|
|
897
1283
|
if (isCapRef(returns)) {
|
|
898
1284
|
const inner = returns.cap;
|
|
899
1285
|
return (raw) => {
|
|
900
|
-
if (!(raw instanceof CapRef)) throw new IpcError({ code: "
|
|
1286
|
+
if (!(raw instanceof CapRef)) throw new IpcError({ code: "invalid_argument", message: "expected CapRef" });
|
|
901
1287
|
return spawn(inner, raw.capId);
|
|
902
1288
|
};
|
|
903
1289
|
}
|
|
@@ -905,9 +1291,9 @@ function makeReturnDecoder(
|
|
|
905
1291
|
const inner = returns.cap;
|
|
906
1292
|
const disposal = inner.disposal as DisposalSpec | undefined;
|
|
907
1293
|
return (raw) => {
|
|
908
|
-
if (!Array.isArray(raw)) throw new IpcError({ code: "
|
|
1294
|
+
if (!Array.isArray(raw)) throw new IpcError({ code: "invalid_argument", message: "expected array" });
|
|
909
1295
|
const proxies = raw.map((item) => {
|
|
910
|
-
if (!(item instanceof CapRef)) throw new IpcError({ code: "
|
|
1296
|
+
if (!(item instanceof CapRef)) throw new IpcError({ code: "invalid_argument", message: "expected CapRef in array" });
|
|
911
1297
|
return spawn(inner, item.capId);
|
|
912
1298
|
});
|
|
913
1299
|
attachArrayDisposal(proxies, disposal);
|
|
@@ -917,11 +1303,11 @@ function makeReturnDecoder(
|
|
|
917
1303
|
if (isCapRecord(returns)) {
|
|
918
1304
|
const inner = returns.cap;
|
|
919
1305
|
return (raw) => {
|
|
920
|
-
if (!raw || typeof raw !== "object") throw new IpcError({ code: "
|
|
1306
|
+
if (!raw || typeof raw !== "object") throw new IpcError({ code: "invalid_argument", message: "expected record" });
|
|
921
1307
|
const out: Record<string, unknown> = {};
|
|
922
1308
|
for (const k of Object.keys(raw as object)) {
|
|
923
1309
|
const item = (raw as Record<string, unknown>)[k];
|
|
924
|
-
if (!(item instanceof CapRef)) throw new IpcError({ code: "
|
|
1310
|
+
if (!(item instanceof CapRef)) throw new IpcError({ code: "invalid_argument", message: "expected CapRef in record" });
|
|
925
1311
|
out[k] = spawn(inner, item.capId);
|
|
926
1312
|
}
|
|
927
1313
|
return out;
|
|
@@ -932,27 +1318,22 @@ function makeReturnDecoder(
|
|
|
932
1318
|
|
|
933
1319
|
function attachArrayDisposal(arr: unknown[], disposal: DisposalSpec | undefined): void {
|
|
934
1320
|
if (!disposal) return;
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
: () => {
|
|
942
|
-
for (const proxy of arr) {
|
|
943
|
-
const fn = (proxy as Record<symbol, unknown>)[Symbol.dispose] as (() => void) | undefined;
|
|
944
|
-
fn?.call(proxy);
|
|
945
|
-
}
|
|
946
|
-
};
|
|
1321
|
+
(arr as any)[Symbol.dispose] = () => {
|
|
1322
|
+
for (const proxy of arr) {
|
|
1323
|
+
const fn = (proxy as Record<symbol, unknown>)[Symbol.dispose] as (() => void) | undefined;
|
|
1324
|
+
fn?.call(proxy);
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
947
1327
|
}
|
|
948
1328
|
|
|
949
1329
|
function toStatus(err: unknown): IpcStatus {
|
|
950
1330
|
if (err instanceof IpcError) return err.toStatus();
|
|
951
|
-
if (err instanceof Error) return { code: "
|
|
952
|
-
return { code: "
|
|
1331
|
+
if (err instanceof Error) return { code: "internal", message: err.message };
|
|
1332
|
+
return { code: "internal", message: String(err) };
|
|
953
1333
|
}
|
|
954
1334
|
|
|
955
1335
|
interface ClientStreamHandle<T> {
|
|
1336
|
+
capId: number;
|
|
956
1337
|
stream: StreamType<T>;
|
|
957
1338
|
push(chunk: unknown): void;
|
|
958
1339
|
end(): void;
|
|
@@ -960,6 +1341,7 @@ interface ClientStreamHandle<T> {
|
|
|
960
1341
|
}
|
|
961
1342
|
|
|
962
1343
|
function makeClientStream<T>(
|
|
1344
|
+
capId: number,
|
|
963
1345
|
streamId: number,
|
|
964
1346
|
cancel: () => void,
|
|
965
1347
|
sendCredit: (messages: number) => void
|
|
@@ -1033,6 +1415,7 @@ function makeClientStream<T>(
|
|
|
1033
1415
|
void streamId;
|
|
1034
1416
|
|
|
1035
1417
|
return {
|
|
1418
|
+
capId,
|
|
1036
1419
|
stream,
|
|
1037
1420
|
push(chunk) {
|
|
1038
1421
|
if (cancelled || ended || failure) return;
|