@qilin-agent/remote-mock 0.0.0-stage → 3.1.3
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/LICENSE +21 -0
- package/README.i18n.yaml +61 -0
- package/README.md +146 -2
- package/README.zh.md +147 -0
- package/lib/index.js +711 -0
- package/lib/types/index.d.ts +18 -0
- package/lib/types/log.d.ts +96 -0
- package/lib/types/remote-mock.d.ts +190 -0
- package/lib/types/remote-proxy.d.ts +15 -0
- package/lib/types/streams.d.ts +153 -0
- package/package.json +43 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
import { fn } from "@vitest/spy";
|
|
2
|
+
import { isRemoteUplinkItem } from "@qilin-agent/typert-protocol";
|
|
3
|
+
//#region lib/types/log.js
|
|
4
|
+
/** What a `RemoteMock` saw: answered calls, opened streams, and requests without a rule. */
|
|
5
|
+
/** The mock's log store. */
|
|
6
|
+
var MockLogStore = class {
|
|
7
|
+
callEntries = [];
|
|
8
|
+
streamEntries = [];
|
|
9
|
+
missEntries = [];
|
|
10
|
+
seq = 0;
|
|
11
|
+
calls(endpoint) {
|
|
12
|
+
return this.callEntries.filter((entry) => endpoint === void 0 || entry.endpoint === endpoint);
|
|
13
|
+
}
|
|
14
|
+
streams(endpoint) {
|
|
15
|
+
return this.streamEntries.filter((entry) => endpoint === void 0 || entry.endpoint === endpoint);
|
|
16
|
+
}
|
|
17
|
+
requests(endpoint) {
|
|
18
|
+
return [...this.calls(endpoint), ...this.streams(endpoint)].filter((entry) => endpoint !== void 0 || !entry.endpoint.startsWith("$")).sort((a, b) => a.seq - b.seq).map((entry) => entry.args[0]);
|
|
19
|
+
}
|
|
20
|
+
unmatched() {
|
|
21
|
+
return [...this.missEntries];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Record one call whose rule was selected.
|
|
25
|
+
* @param endpoint - endpoint.
|
|
26
|
+
* @param args - positional args.
|
|
27
|
+
* @returns the mutable entry the dispatcher settles.
|
|
28
|
+
*/
|
|
29
|
+
call(endpoint, args) {
|
|
30
|
+
const record = {
|
|
31
|
+
endpoint,
|
|
32
|
+
args,
|
|
33
|
+
state: "pending",
|
|
34
|
+
result: void 0,
|
|
35
|
+
seq: ++this.seq
|
|
36
|
+
};
|
|
37
|
+
this.callEntries.push(record);
|
|
38
|
+
return record;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Record one stream open.
|
|
42
|
+
* @param endpoint - endpoint.
|
|
43
|
+
* @param args - positional args.
|
|
44
|
+
* @returns the mutable entry the stream updates.
|
|
45
|
+
*/
|
|
46
|
+
stream(endpoint, args) {
|
|
47
|
+
const record = {
|
|
48
|
+
endpoint,
|
|
49
|
+
args,
|
|
50
|
+
state: "open",
|
|
51
|
+
pushed: 0,
|
|
52
|
+
seq: ++this.seq
|
|
53
|
+
};
|
|
54
|
+
this.streamEntries.push(record);
|
|
55
|
+
return record;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Record one request without a rule.
|
|
59
|
+
* @param endpoint - endpoint.
|
|
60
|
+
* @param mode - how it was requested.
|
|
61
|
+
*/
|
|
62
|
+
miss(endpoint, mode) {
|
|
63
|
+
this.missEntries.push({
|
|
64
|
+
endpoint,
|
|
65
|
+
mode
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region lib/types/streams.js
|
|
71
|
+
/** Stream scripts and the pushable, abort-aware stream a script drives. */
|
|
72
|
+
/**
|
|
73
|
+
* Type a fake stream method's result as the handle a generated Client method
|
|
74
|
+
* returns. The uplink members are inert: a fake built on it drives the downlink only.
|
|
75
|
+
* @param source - downlink items the fake yields.
|
|
76
|
+
* @returns a handle that iterates `source`.
|
|
77
|
+
*/
|
|
78
|
+
function streamHandle(source) {
|
|
79
|
+
return {
|
|
80
|
+
[Symbol.asyncIterator]: () => source[Symbol.asyncIterator](),
|
|
81
|
+
send: () => void 0,
|
|
82
|
+
end: () => void 0,
|
|
83
|
+
dispose: () => void 0
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Lift a fake stream method written as an async generator function into the
|
|
88
|
+
* signature of the generated method it stands in for.
|
|
89
|
+
* @param method - fake taking the method's arguments and yielding its downlink items.
|
|
90
|
+
* @returns the fake returning {@link streamHandle} of each call.
|
|
91
|
+
*/
|
|
92
|
+
function streamMethod(method) {
|
|
93
|
+
const lifted = (...args) => streamHandle(method(...args));
|
|
94
|
+
return lifted;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Script yielding `items`, then ending.
|
|
98
|
+
* @param items - items in order.
|
|
99
|
+
* @returns the script.
|
|
100
|
+
*/
|
|
101
|
+
function frames(items) {
|
|
102
|
+
return (_args, stream) => {
|
|
103
|
+
for (const item of items) stream.push(item);
|
|
104
|
+
stream.end();
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Script yielding `initial`, then staying open for `streams.push`.
|
|
109
|
+
* @param initial - items yielded on open.
|
|
110
|
+
* @returns the script.
|
|
111
|
+
*/
|
|
112
|
+
function openStream(initial = []) {
|
|
113
|
+
return (_args, stream) => {
|
|
114
|
+
for (const item of initial) stream.push(item);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** One open stream: a queue the script pushes into and a single consumer reads from; the log entry tracks its state. */
|
|
118
|
+
var MockStream = class {
|
|
119
|
+
record;
|
|
120
|
+
sourceSignal;
|
|
121
|
+
uplink;
|
|
122
|
+
release;
|
|
123
|
+
queue = [];
|
|
124
|
+
settled;
|
|
125
|
+
waiting;
|
|
126
|
+
drainWaiters = [];
|
|
127
|
+
cancellation = new AbortController();
|
|
128
|
+
signal = this.cancellation.signal;
|
|
129
|
+
/** Consumer cancellation listener; attached while the stream is open, removed when it settles or cancels. */
|
|
130
|
+
onAbort = () => {
|
|
131
|
+
this.cancel(this.sourceSignal.reason);
|
|
132
|
+
};
|
|
133
|
+
/** Items pushed but not yet pulled by the consumer. */
|
|
134
|
+
get queued() {
|
|
135
|
+
return this.queue.length;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* @param record - log entry this stream updates.
|
|
139
|
+
* @param sourceSignal - cancellation from the caller that opened the stream.
|
|
140
|
+
* @param uplink - the uplink the script reads.
|
|
141
|
+
* @param release - closes the uplink this stream owns; called once, when the stream settles, is cancelled, or the consumer leaves.
|
|
142
|
+
*/
|
|
143
|
+
constructor(record, sourceSignal, uplink, release) {
|
|
144
|
+
this.record = record;
|
|
145
|
+
this.sourceSignal = sourceSignal;
|
|
146
|
+
this.uplink = uplink;
|
|
147
|
+
this.release = release;
|
|
148
|
+
if (sourceSignal.aborted) this.cancel(sourceSignal.reason);
|
|
149
|
+
else sourceSignal.addEventListener("abort", this.onAbort, { once: true });
|
|
150
|
+
}
|
|
151
|
+
push(item) {
|
|
152
|
+
if (this.record.state !== "open") return;
|
|
153
|
+
this.record.pushed += 1;
|
|
154
|
+
if (this.waiting !== void 0) {
|
|
155
|
+
const { resolve } = this.waiting;
|
|
156
|
+
this.waiting = void 0;
|
|
157
|
+
resolve({
|
|
158
|
+
value: item,
|
|
159
|
+
done: false
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.queue.push(item);
|
|
164
|
+
}
|
|
165
|
+
end() {
|
|
166
|
+
this.settle({ kind: "end" }, "ended");
|
|
167
|
+
}
|
|
168
|
+
fail(error) {
|
|
169
|
+
this.settle({
|
|
170
|
+
kind: "fail",
|
|
171
|
+
error
|
|
172
|
+
}, "failed");
|
|
173
|
+
}
|
|
174
|
+
/** The consumer left: cancel the stream and drop what it left. */
|
|
175
|
+
dispose() {
|
|
176
|
+
this.cancel(/* @__PURE__ */ new Error(`remote-mock: ${this.record.endpoint} stream disposed`));
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Start `script` on this stream.
|
|
180
|
+
* @param script - the registered script.
|
|
181
|
+
* @param args - open args.
|
|
182
|
+
*/
|
|
183
|
+
run(script, args) {
|
|
184
|
+
let outcome;
|
|
185
|
+
try {
|
|
186
|
+
outcome = script(args, this);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
this.fail(toError(error));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (outcome instanceof Promise) outcome.catch((error) => {
|
|
192
|
+
this.fail(toError(error));
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Resolve once the consumer has pulled every queued item and waits for the next one, or the stream is no longer
|
|
197
|
+
* open and holds nothing the consumer could still pull (a consumer that returns discards what it left).
|
|
198
|
+
* @returns settles when drained.
|
|
199
|
+
*/
|
|
200
|
+
drained() {
|
|
201
|
+
if (this.isDrained()) return Promise.resolve();
|
|
202
|
+
return new Promise((resolve) => {
|
|
203
|
+
this.drainWaiters.push(resolve);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
[Symbol.asyncIterator]() {
|
|
207
|
+
return {
|
|
208
|
+
next: () => this.next(),
|
|
209
|
+
return: () => {
|
|
210
|
+
this.cancel();
|
|
211
|
+
return Promise.resolve({
|
|
212
|
+
value: void 0,
|
|
213
|
+
done: true
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
next() {
|
|
219
|
+
const result = this.pull();
|
|
220
|
+
this.wakeDrained();
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
pull() {
|
|
224
|
+
if (this.queue.length > 0) return Promise.resolve({
|
|
225
|
+
value: this.queue.shift(),
|
|
226
|
+
done: false
|
|
227
|
+
});
|
|
228
|
+
if (this.settled !== void 0) return this.finish(this.settled);
|
|
229
|
+
if (this.record.state === "cancelled") return Promise.resolve({
|
|
230
|
+
value: void 0,
|
|
231
|
+
done: true
|
|
232
|
+
});
|
|
233
|
+
if (this.waiting !== void 0) return Promise.reject(/* @__PURE__ */ new Error(`remote-mock: ${this.record.endpoint} stream has one consumer`));
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
this.waiting = {
|
|
236
|
+
resolve,
|
|
237
|
+
reject
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
finish(settled) {
|
|
242
|
+
return settled.kind === "end" ? Promise.resolve({
|
|
243
|
+
value: void 0,
|
|
244
|
+
done: true
|
|
245
|
+
}) : Promise.reject(settled.error);
|
|
246
|
+
}
|
|
247
|
+
settle(settled, state) {
|
|
248
|
+
if (this.record.state !== "open") return;
|
|
249
|
+
this.record.state = state;
|
|
250
|
+
this.settled = settled;
|
|
251
|
+
this.sourceSignal.removeEventListener("abort", this.onAbort);
|
|
252
|
+
this.release();
|
|
253
|
+
this.wakeDrained();
|
|
254
|
+
const waiting = this.waiting;
|
|
255
|
+
if (waiting === void 0) return;
|
|
256
|
+
this.waiting = void 0;
|
|
257
|
+
this.finish(settled).then(waiting.resolve, waiting.reject);
|
|
258
|
+
}
|
|
259
|
+
cancel(reason) {
|
|
260
|
+
if (this.record.state === "open") {
|
|
261
|
+
this.record.state = "cancelled";
|
|
262
|
+
this.sourceSignal.removeEventListener("abort", this.onAbort);
|
|
263
|
+
const waiting = this.waiting;
|
|
264
|
+
this.waiting = void 0;
|
|
265
|
+
this.queue.length = 0;
|
|
266
|
+
this.cancellation.abort(reason);
|
|
267
|
+
this.release();
|
|
268
|
+
waiting?.resolve({
|
|
269
|
+
value: void 0,
|
|
270
|
+
done: true
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
this.queue.length = 0;
|
|
274
|
+
this.wakeDrained();
|
|
275
|
+
}
|
|
276
|
+
isDrained() {
|
|
277
|
+
return this.queue.length === 0 && (this.waiting !== void 0 || this.record.state !== "open");
|
|
278
|
+
}
|
|
279
|
+
wakeDrained() {
|
|
280
|
+
if (!this.isDrained()) return;
|
|
281
|
+
const waiters = this.drainWaiters;
|
|
282
|
+
this.drainWaiters = [];
|
|
283
|
+
for (const resolve of waiters) resolve();
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
/**
|
|
287
|
+
* The uplink a direct handle owns: `send()` pushes, `end()` half-closes, and
|
|
288
|
+
* `close()` ends it and drops unread items once the stream settles, is
|
|
289
|
+
* cancelled, or the consumer leaves. The script reads it as `StreamHandle.uplink`.
|
|
290
|
+
*/
|
|
291
|
+
var HandleUplink = class {
|
|
292
|
+
items = [];
|
|
293
|
+
done = false;
|
|
294
|
+
waiter;
|
|
295
|
+
/**
|
|
296
|
+
* Queues one uplink item for the script, or completes the read waiting for it.
|
|
297
|
+
* @param item - the item the handle's `send()` carried.
|
|
298
|
+
* @throws {Error} after `end()`.
|
|
299
|
+
*/
|
|
300
|
+
push(item) {
|
|
301
|
+
if (this.done) throw new Error("remote-mock: uplink was ended");
|
|
302
|
+
const waiter = this.waiter;
|
|
303
|
+
this.waiter = void 0;
|
|
304
|
+
if (waiter === void 0) this.items.push(item);
|
|
305
|
+
else waiter({
|
|
306
|
+
value: item,
|
|
307
|
+
done: false
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
/** Half-closes the uplink: pending and later reads complete as done; repeated calls are no-ops. */
|
|
311
|
+
end() {
|
|
312
|
+
if (this.done) return;
|
|
313
|
+
this.done = true;
|
|
314
|
+
this.waiter?.({
|
|
315
|
+
value: void 0,
|
|
316
|
+
done: true
|
|
317
|
+
});
|
|
318
|
+
this.waiter = void 0;
|
|
319
|
+
}
|
|
320
|
+
/** Ends the uplink and drops unread items; the owning stream calls it on settle or cancel, the script's iterator on `return()`. */
|
|
321
|
+
close() {
|
|
322
|
+
this.items.length = 0;
|
|
323
|
+
this.end();
|
|
324
|
+
}
|
|
325
|
+
[Symbol.asyncIterator]() {
|
|
326
|
+
return {
|
|
327
|
+
next: () => {
|
|
328
|
+
if (this.items.length > 0) return Promise.resolve({
|
|
329
|
+
value: this.items.shift(),
|
|
330
|
+
done: false
|
|
331
|
+
});
|
|
332
|
+
if (this.done) return Promise.resolve({
|
|
333
|
+
value: void 0,
|
|
334
|
+
done: true
|
|
335
|
+
});
|
|
336
|
+
return new Promise((resolve) => {
|
|
337
|
+
this.waiter = resolve;
|
|
338
|
+
});
|
|
339
|
+
},
|
|
340
|
+
return: () => {
|
|
341
|
+
this.close();
|
|
342
|
+
return Promise.resolve({
|
|
343
|
+
value: void 0,
|
|
344
|
+
done: true
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
/**
|
|
351
|
+
* The handle a direct `mock.remote` stream call returns, as a generated method
|
|
352
|
+
* would: iterate it for the script's downlink, `send()` and `end()` feed
|
|
353
|
+
* `StreamHandle.uplink`, `dispose()` cancels the stream. `send()` applies the
|
|
354
|
+
* real handle's check, so a non-lossless JSON item throws and a top-level
|
|
355
|
+
* `undefined` passes. A stream opened through `rpc.open` reads the carrier's
|
|
356
|
+
* uplink instead, and this handle owns none.
|
|
357
|
+
*/
|
|
358
|
+
var MockClientStream = class {
|
|
359
|
+
stream;
|
|
360
|
+
uplink;
|
|
361
|
+
/**
|
|
362
|
+
* @param stream - the script-side stream.
|
|
363
|
+
* @param uplink - the uplink this handle feeds; absent when the carrier supplied one.
|
|
364
|
+
*/
|
|
365
|
+
constructor(stream, uplink) {
|
|
366
|
+
this.stream = stream;
|
|
367
|
+
this.uplink = uplink;
|
|
368
|
+
}
|
|
369
|
+
send(item) {
|
|
370
|
+
if (this.uplink === void 0) throw new Error(`remote-mock: ${this.stream.record.endpoint} uplink belongs to the carrier that opened the stream`);
|
|
371
|
+
if (!isRemoteUplinkItem(item)) throw new Error(`remote-mock: ${this.stream.record.endpoint} uplink item is not a lossless JSON value`);
|
|
372
|
+
this.uplink.push(item);
|
|
373
|
+
}
|
|
374
|
+
end() {
|
|
375
|
+
this.uplink?.end();
|
|
376
|
+
}
|
|
377
|
+
dispose() {
|
|
378
|
+
this.stream.dispose();
|
|
379
|
+
}
|
|
380
|
+
[Symbol.asyncIterator]() {
|
|
381
|
+
return this.stream[Symbol.asyncIterator]();
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
/**
|
|
385
|
+
* The `Error` a thrown value stands for: itself, or a new Error carrying its string form.
|
|
386
|
+
* @param reason - thrown value.
|
|
387
|
+
* @returns the error.
|
|
388
|
+
*/
|
|
389
|
+
function toError(reason) {
|
|
390
|
+
return reason instanceof Error ? reason : new Error(String(reason));
|
|
391
|
+
}
|
|
392
|
+
//#endregion
|
|
393
|
+
//#region lib/types/remote-proxy.js
|
|
394
|
+
/**
|
|
395
|
+
* Cache accessed namespaces and expose the current native mock for each method.
|
|
396
|
+
* @param method - selects a stable endpoint mock for its configured invocation mode.
|
|
397
|
+
* @returns enumerable accessed namespaces and methods; symbols and thenable probes remain inert.
|
|
398
|
+
*/
|
|
399
|
+
function createRemoteProxy(method) {
|
|
400
|
+
return new Proxy(Object.create(null), { get(namespaces, namespace) {
|
|
401
|
+
if (typeof namespace !== "string" || namespace === "then") return void 0;
|
|
402
|
+
return namespaces[namespace] ??= new Proxy(Object.create(null), { get(methods, name) {
|
|
403
|
+
if (typeof name !== "string" || name === "then") return void 0;
|
|
404
|
+
return methods[name] = method(`${namespace}/${name}`);
|
|
405
|
+
} });
|
|
406
|
+
} });
|
|
407
|
+
}
|
|
408
|
+
//#endregion
|
|
409
|
+
//#region lib/types/remote-mock.js
|
|
410
|
+
/** `RemoteMock`: an endpoint table (unary answers or stream scripts), live stream control, a log, and the Connection carrier face. */
|
|
411
|
+
const EVENTS_ENDPOINT = "$events";
|
|
412
|
+
/**
|
|
413
|
+
* The success envelope the Client's Remote callers read: `{ ok: true, value }`.
|
|
414
|
+
* @param value - success value.
|
|
415
|
+
* @returns the envelope.
|
|
416
|
+
*/
|
|
417
|
+
function ok(value) {
|
|
418
|
+
return {
|
|
419
|
+
ok: true,
|
|
420
|
+
value
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/** A missing default answer is an unmatched request, not a selected rule that failed. */
|
|
424
|
+
var MissingUnaryRule = class extends Error {};
|
|
425
|
+
/**
|
|
426
|
+
* Endpoint-named Remote mock. `dispatch` / `open` are the core; `rpc` is the
|
|
427
|
+
* same core as the decoded carrier accepted by the Connection installer.
|
|
428
|
+
*/
|
|
429
|
+
var RemoteMock = class RemoteMock {
|
|
430
|
+
/**
|
|
431
|
+
* Create a mock whose `$events` stream answers the Gateway client's opening
|
|
432
|
+
* with one ready frame and then stays open, so the assembled client connects.
|
|
433
|
+
* @param options - host facts for the ready frame.
|
|
434
|
+
* @returns the mock.
|
|
435
|
+
*/
|
|
436
|
+
static create(options = {}) {
|
|
437
|
+
const mock = new RemoteMock();
|
|
438
|
+
const host = options.host ?? { home: "/home/mock" };
|
|
439
|
+
let generation = 0;
|
|
440
|
+
return mock.stream(EVENTS_ENDPOINT, (_args, stream) => {
|
|
441
|
+
generation += 1;
|
|
442
|
+
stream.push({
|
|
443
|
+
type: "ready",
|
|
444
|
+
clientId: `mock-client-${String(generation)}`,
|
|
445
|
+
host
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
unaryDefaults = /* @__PURE__ */ new Map();
|
|
450
|
+
scripts = /* @__PURE__ */ new Map();
|
|
451
|
+
live = [];
|
|
452
|
+
openWaiters = [];
|
|
453
|
+
logStore = new MockLogStore();
|
|
454
|
+
unaryMocks = /* @__PURE__ */ new Map();
|
|
455
|
+
streamMocks = /* @__PURE__ */ new Map();
|
|
456
|
+
/** The carrier's uplink for the open in progress through {@link open}; a direct call has none. */
|
|
457
|
+
carrierUplink;
|
|
458
|
+
proxy = createRemoteProxy((endpoint) => this.modeOf(endpoint) === "stream" ? this.streamMock(endpoint) : this.unaryMock(endpoint));
|
|
459
|
+
/**
|
|
460
|
+
* Native mock functions for every namespace: configure answers and inspect calls without declaring method names.
|
|
461
|
+
* Explicit stream registrations select stream mocks; all other methods use unary mocks.
|
|
462
|
+
* Native overrides belong to this instance; `mockReset()` restores the implementation reading current defaults.
|
|
463
|
+
* Generated declarations provide exact signatures; absent declarations weaken only this test-owned proxy.
|
|
464
|
+
*/
|
|
465
|
+
remote = this.proxy;
|
|
466
|
+
/** Everything observed so far. */
|
|
467
|
+
log = this.logStore;
|
|
468
|
+
/** Control over streams opened by registered scripts. */
|
|
469
|
+
streams = {
|
|
470
|
+
push: (endpoint, item, where) => this.forEachOpen(endpoint, where, (stream) => {
|
|
471
|
+
stream.push(item);
|
|
472
|
+
}),
|
|
473
|
+
end: (endpoint, where) => this.forEachOpen(endpoint, where, (stream) => {
|
|
474
|
+
stream.end();
|
|
475
|
+
}),
|
|
476
|
+
fail: (endpoint, error, where) => this.forEachOpen(endpoint, where, (stream) => {
|
|
477
|
+
stream.fail(error);
|
|
478
|
+
}),
|
|
479
|
+
opened: (endpoint, count) => {
|
|
480
|
+
if (this.logStore.streams(endpoint).length >= count) return Promise.resolve();
|
|
481
|
+
return new Promise((resolve) => {
|
|
482
|
+
this.openWaiters.push({
|
|
483
|
+
endpoint,
|
|
484
|
+
count,
|
|
485
|
+
resolve
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
},
|
|
489
|
+
drained: (endpoint, where) => Promise.all(this.matching(endpoint, where).map((stream) => stream.drained())).then(() => void 0)
|
|
490
|
+
};
|
|
491
|
+
/**
|
|
492
|
+
* The Connection carrier face: `call` dispatches, `open` opens; payloads carry
|
|
493
|
+
* `{ args }` as the whole-client proxies (an array) or the Gateway's own
|
|
494
|
+
* endpoints (one object) send them, and a call aborted by its signal rejects.
|
|
495
|
+
*/
|
|
496
|
+
rpc = {
|
|
497
|
+
call: async (_channel, endpoint, payload, signal) => {
|
|
498
|
+
const pending = this.dispatch(endpoint, argsOf(endpoint, payload));
|
|
499
|
+
return await (signal === void 0 ? pending : settleOrAbort(pending, signal));
|
|
500
|
+
},
|
|
501
|
+
open: (_channel, endpoint, payload, signal, uplink) => this.open(endpoint, argsOf(endpoint, payload), signal, uplink)
|
|
502
|
+
};
|
|
503
|
+
constructor() {}
|
|
504
|
+
unary(endpoint, rule) {
|
|
505
|
+
this.unaryDefaults.set(endpoint, rule);
|
|
506
|
+
return this;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Declare `endpoint` as a stream and replace its default script when supplied. A declaration without a script
|
|
510
|
+
* preserves any existing script; an endpoint declared without one fails an open as a stream miss.
|
|
511
|
+
* @param endpoint - `<namespace>/<method>`.
|
|
512
|
+
* @param script - script (`frames` / `openStream` build the common ones).
|
|
513
|
+
* @returns this.
|
|
514
|
+
*/
|
|
515
|
+
stream(endpoint, script) {
|
|
516
|
+
if (script !== void 0 || !this.scripts.has(endpoint)) this.scripts.set(endpoint, script);
|
|
517
|
+
return this;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Register endpoint defaults without changing native mock overrides.
|
|
521
|
+
* @param table - unary answers and stream scripts.
|
|
522
|
+
* @returns this.
|
|
523
|
+
*/
|
|
524
|
+
load(table) {
|
|
525
|
+
for (const [endpoint, rule] of Object.entries(table.unary ?? {})) this.unary(endpoint, rule);
|
|
526
|
+
for (const endpoint of table.streams ?? []) this.stream(endpoint);
|
|
527
|
+
for (const [endpoint, script] of Object.entries(table.stream ?? {})) this.stream(endpoint, script);
|
|
528
|
+
return this;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Whether `endpoint` is declared a stream (with or without a script) or has a unary rule — the one declaration
|
|
532
|
+
* the whole-client proxies need; an endpoint neither declared nor ruled is dispatched as a unary call.
|
|
533
|
+
* @param endpoint - endpoint.
|
|
534
|
+
* @returns the mode, or undefined when nothing is registered.
|
|
535
|
+
*/
|
|
536
|
+
modeOf(endpoint) {
|
|
537
|
+
if (this.scripts.has(endpoint)) return "stream";
|
|
538
|
+
if (this.unaryDefaults.has(endpoint)) return "unary";
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Registered endpoints and accessed proxy methods, sorted.
|
|
542
|
+
* @returns endpoint names.
|
|
543
|
+
*/
|
|
544
|
+
endpoints() {
|
|
545
|
+
const names = new Set(this.scripts.keys());
|
|
546
|
+
for (const endpoint of this.unaryDefaults.keys()) names.add(endpoint);
|
|
547
|
+
for (const [namespace, methods] of Object.entries(this.proxy)) for (const method of Object.keys(methods)) names.add(`${namespace}/${method}`);
|
|
548
|
+
return [...names].sort();
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Answer one unary call with the registered rule's value, verbatim. The call
|
|
552
|
+
* is logged as soon as its rule is selected and settles with the rule: a
|
|
553
|
+
* rule that throws or rejects fails the call with that error.
|
|
554
|
+
* @param endpoint - endpoint.
|
|
555
|
+
* @param args - positional args.
|
|
556
|
+
* @returns the answer.
|
|
557
|
+
* @throws {Error} when no rule is registered (logged as unmatched).
|
|
558
|
+
*/
|
|
559
|
+
async dispatch(endpoint, args) {
|
|
560
|
+
let answer;
|
|
561
|
+
try {
|
|
562
|
+
answer = this.unaryMock(endpoint)(...args);
|
|
563
|
+
} catch (error) {
|
|
564
|
+
if (error instanceof MissingUnaryRule) throw error;
|
|
565
|
+
answer = Promise.reject(toError(error));
|
|
566
|
+
}
|
|
567
|
+
const record = this.logStore.call(endpoint, args);
|
|
568
|
+
try {
|
|
569
|
+
record.result = await answer;
|
|
570
|
+
} catch (error) {
|
|
571
|
+
record.state = "failed";
|
|
572
|
+
record.result = error;
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
575
|
+
record.state = "answered";
|
|
576
|
+
return record.result;
|
|
577
|
+
}
|
|
578
|
+
unaryMock(endpoint) {
|
|
579
|
+
let mock = this.unaryMocks.get(endpoint);
|
|
580
|
+
if (mock === void 0) {
|
|
581
|
+
mock = fn((...args) => Promise.resolve(this.unaryAnswer(endpoint, args)));
|
|
582
|
+
this.unaryMocks.set(endpoint, mock);
|
|
583
|
+
}
|
|
584
|
+
return mock;
|
|
585
|
+
}
|
|
586
|
+
unaryAnswer(endpoint, args) {
|
|
587
|
+
if (!this.unaryDefaults.has(endpoint)) {
|
|
588
|
+
this.logStore.miss(endpoint, "unary");
|
|
589
|
+
throw new MissingUnaryRule(this.noRuleMessage(endpoint));
|
|
590
|
+
}
|
|
591
|
+
const rule = this.unaryDefaults.get(endpoint);
|
|
592
|
+
return isRuleFn(rule) ? answerOf(rule, args) : rule;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Open through the endpoint's native mock, which is called with the generated
|
|
596
|
+
* signature's arguments only; its default runs the registered script as a
|
|
597
|
+
* controlled stream. A native override returns its own iterable: the caller
|
|
598
|
+
* owns consumption and cancellation, outside `OpenStreams`.
|
|
599
|
+
* @param endpoint - endpoint.
|
|
600
|
+
* @param args - positional args.
|
|
601
|
+
* @param signal - consumer cancellation.
|
|
602
|
+
* @param uplink - the carrier's uplink for this open, which the script reads as `StreamHandle.uplink`;
|
|
603
|
+
* absent for a direct call, whose returned handle owns the uplink.
|
|
604
|
+
* @returns the controlled script stream or the native override's caller-owned iterable.
|
|
605
|
+
* @throws {Error} when the default runs without a registered script (logged as unmatched).
|
|
606
|
+
*/
|
|
607
|
+
open(endpoint, args, signal, uplink) {
|
|
608
|
+
this.carrierUplink = uplink;
|
|
609
|
+
try {
|
|
610
|
+
return this.streamMock(endpoint)(...args, signal);
|
|
611
|
+
} finally {
|
|
612
|
+
this.carrierUplink = void 0;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
streamMock(endpoint) {
|
|
616
|
+
let mock = this.streamMocks.get(endpoint);
|
|
617
|
+
if (mock === void 0) {
|
|
618
|
+
mock = fn((...values) => {
|
|
619
|
+
const args = [...values];
|
|
620
|
+
const signal = args.at(-1) instanceof AbortSignal ? args.pop() : new AbortController().signal;
|
|
621
|
+
return this.openScript(endpoint, args, signal, this.carrierUplink);
|
|
622
|
+
});
|
|
623
|
+
this.streamMocks.set(endpoint, mock);
|
|
624
|
+
}
|
|
625
|
+
return mock;
|
|
626
|
+
}
|
|
627
|
+
openScript(endpoint, args, signal, carrierUplink) {
|
|
628
|
+
const script = this.scripts.get(endpoint);
|
|
629
|
+
if (script === void 0) {
|
|
630
|
+
this.logStore.miss(endpoint, "stream");
|
|
631
|
+
throw new Error(this.noRuleMessage(endpoint));
|
|
632
|
+
}
|
|
633
|
+
let uplink;
|
|
634
|
+
let owned;
|
|
635
|
+
if (carrierUplink === void 0) {
|
|
636
|
+
owned = new HandleUplink();
|
|
637
|
+
uplink = owned;
|
|
638
|
+
} else uplink = carrierUplink;
|
|
639
|
+
const stream = new MockStream(this.logStore.stream(endpoint, args), signal, uplink, () => {
|
|
640
|
+
owned?.close();
|
|
641
|
+
});
|
|
642
|
+
this.live.push(stream);
|
|
643
|
+
this.wakeOpened(endpoint);
|
|
644
|
+
stream.run(script, args);
|
|
645
|
+
return new MockClientStream(stream, owned);
|
|
646
|
+
}
|
|
647
|
+
/** Throw when any request found no rule, naming the endpoints and the registered ones. */
|
|
648
|
+
assertNoUnmatched() {
|
|
649
|
+
const unmatched = this.logStore.unmatched();
|
|
650
|
+
if (unmatched.length === 0) return;
|
|
651
|
+
const lines = unmatched.map((entry) => ` ${entry.endpoint} (${entry.mode})`);
|
|
652
|
+
throw new Error(`remote-mock: ${String(unmatched.length)} unmatched request(s):\n${lines.join("\n")}\nregistered: ${this.endpoints().join(", ")}`);
|
|
653
|
+
}
|
|
654
|
+
noRuleMessage(endpoint) {
|
|
655
|
+
return `remote-mock: no rule for ${endpoint}; registered: ${this.endpoints().join(", ")}`;
|
|
656
|
+
}
|
|
657
|
+
/** Streams on `endpoint` still open or still holding items their consumer has not pulled; the rest are forgotten. */
|
|
658
|
+
matching(endpoint, where) {
|
|
659
|
+
this.live = this.live.filter((stream) => stream.record.state === "open" || stream.queued > 0);
|
|
660
|
+
return this.live.filter((stream) => stream.record.endpoint === endpoint && (where === void 0 || where(stream.record.args)));
|
|
661
|
+
}
|
|
662
|
+
forEachOpen(endpoint, where, action) {
|
|
663
|
+
const targets = this.matching(endpoint, where).filter((stream) => stream.record.state === "open");
|
|
664
|
+
for (const stream of targets) action(stream);
|
|
665
|
+
return targets.length;
|
|
666
|
+
}
|
|
667
|
+
wakeOpened(endpoint) {
|
|
668
|
+
const opened = this.logStore.streams(endpoint).length;
|
|
669
|
+
const ready = this.openWaiters.filter((waiter) => waiter.endpoint === endpoint && opened >= waiter.count);
|
|
670
|
+
this.openWaiters = this.openWaiters.filter((waiter) => !ready.includes(waiter));
|
|
671
|
+
for (const waiter of ready) waiter.resolve();
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
function isRuleFn(rule) {
|
|
675
|
+
return typeof rule === "function";
|
|
676
|
+
}
|
|
677
|
+
/** A rule's synchronous throw becomes a rejection so the call settles through one path. */
|
|
678
|
+
function answerOf(rule, args) {
|
|
679
|
+
try {
|
|
680
|
+
return rule(...args);
|
|
681
|
+
} catch (error) {
|
|
682
|
+
return Promise.reject(toError(error));
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/** Positional args from a carrier payload: the array the whole-client proxies send, or the one object the Gateway's own endpoints send. */
|
|
686
|
+
function argsOf(endpoint, payload) {
|
|
687
|
+
if (typeof payload === "object" && payload !== null && "args" in payload) {
|
|
688
|
+
const { args } = payload;
|
|
689
|
+
if (Array.isArray(args)) return args;
|
|
690
|
+
if (typeof args === "object" && args !== null) return [args];
|
|
691
|
+
}
|
|
692
|
+
throw new TypeError(`remote-mock: payload of ${endpoint} must be { args: unknown[] | object }`);
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Settle with the call, or reject with the abort reason first; the call itself
|
|
696
|
+
* always keeps a handler so its own outcome is never an unhandled rejection.
|
|
697
|
+
*/
|
|
698
|
+
function settleOrAbort(pending, signal) {
|
|
699
|
+
return new Promise((resolve, reject) => {
|
|
700
|
+
const abort = () => {
|
|
701
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error("remote-mock: call aborted", { cause: signal.reason }));
|
|
702
|
+
};
|
|
703
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
704
|
+
pending.then(resolve, reject).finally(() => {
|
|
705
|
+
signal.removeEventListener("abort", abort);
|
|
706
|
+
});
|
|
707
|
+
if (signal.aborted) abort();
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
//#endregion
|
|
711
|
+
export { MockClientStream, RemoteMock, frames, ok, openStream, streamHandle, streamMethod };
|