@procedures/client 0.47.1 → 0.48.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/CHANGELOG.md +92 -68
- package/README.md +3 -3
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/client/batch.d.ts.map +1 -1
- package/dist/adapters/http/client/batch.js.map +1 -1
- package/dist/adapters/http/server/body.d.ts.map +1 -1
- package/dist/adapters/http/server/body.js.map +1 -1
- package/dist/adapters/http/shared/constants.d.ts +82 -78
- package/dist/adapters/http/shared/constants.d.ts.map +1 -1
- package/dist/adapters/http/shared/constants.js +78 -80
- package/dist/adapters/http/shared/constants.js.map +1 -1
- package/dist/adapters/http/shared/errors.d.ts +15 -13
- package/dist/adapters/http/shared/errors.d.ts.map +1 -1
- package/dist/adapters/http/shared/errors.js +13 -14
- package/dist/adapters/http/shared/errors.js.map +1 -1
- package/dist/adapters/port/transport.d.ts.map +1 -1
- package/dist/adapters/port/transport.js.map +1 -1
- package/dist/adapters/sequence.d.ts.map +1 -1
- package/dist/adapters/sequence.js.map +1 -1
- package/dist/adapters/websocket/client/types.d.ts +16 -6
- package/dist/adapters/websocket/client/types.d.ts.map +1 -1
- package/dist/adapters/websocket/client/types.js +7 -8
- package/dist/adapters/websocket/client/types.js.map +1 -1
- package/dist/adapters/websocket/shared/rpc.d.ts.map +1 -1
- package/dist/adapters/websocket/shared/rpc.js.map +1 -1
- package/dist/browser.d.ts +1 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/client/batch-executor.d.ts.map +1 -1
- package/dist/client/batch-executor.js.map +1 -1
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +3 -4
- package/dist/client/client.js.map +1 -1
- package/dist/client/errors/codes.d.ts +66 -64
- package/dist/client/errors/codes.d.ts.map +1 -1
- package/dist/client/errors/codes.js +64 -65
- package/dist/client/errors/codes.js.map +1 -1
- package/dist/client/errors/http-status.d.ts +69 -67
- package/dist/client/errors/http-status.d.ts.map +1 -1
- package/dist/client/errors/http-status.js +67 -68
- package/dist/client/errors/http-status.js.map +1 -1
- package/dist/client/errors/types.d.ts +38 -15
- package/dist/client/errors/types.d.ts.map +1 -1
- package/dist/client/errors/types.js +17 -19
- package/dist/client/errors/types.js.map +1 -1
- package/dist/client/middleware/circuit-breaker.d.ts.map +1 -1
- package/dist/client/middleware/circuit-breaker.js.map +1 -1
- package/dist/client/middleware/lru.d.ts +1 -1
- package/dist/client/middleware/lru.d.ts.map +1 -1
- package/dist/client/middleware/lru.js.map +1 -1
- package/dist/client/middleware/rate-limit.d.ts.map +1 -1
- package/dist/client/middleware/rate-limit.js.map +1 -1
- package/dist/client/middleware/resilience.d.ts.map +1 -1
- package/dist/client/middleware/resilience.js +2 -1
- package/dist/client/middleware/resilience.js.map +1 -1
- package/dist/client/route-resolver.d.ts.map +1 -1
- package/dist/client/route-resolver.js.map +1 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.js.map +1 -1
- package/dist/client/validation/types.d.ts +1 -7
- package/dist/client/validation/types.d.ts.map +1 -1
- package/dist/client/validation/types.js +4 -7
- package/dist/client/validation/types.js.map +1 -1
- package/dist/events/bus.d.ts +35 -0
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/events/bus.js +135 -22
- package/dist/events/bus.js.map +1 -1
- package/dist/events/index.d.ts +1 -1
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js.map +1 -1
- package/dist/events/publisher.d.ts.map +1 -1
- package/dist/events/publisher.js +4 -7
- package/dist/events/publisher.js.map +1 -1
- package/dist/events/recheck.d.ts.map +1 -1
- package/dist/events/recheck.js.map +1 -1
- package/dist/events/remote.d.ts +1 -7
- package/dist/events/remote.d.ts.map +1 -1
- package/dist/events/remote.js +4 -7
- package/dist/events/remote.js.map +1 -1
- package/dist/events/types.d.ts +30 -0
- package/dist/events/types.d.ts.map +1 -1
- package/dist/middleware/compose.d.ts.map +1 -1
- package/dist/middleware/compose.js +6 -10
- package/dist/middleware/compose.js.map +1 -1
- package/dist/procedures/caller.d.ts +1 -1
- package/dist/procedures/caller.js +1 -1
- package/dist/procedures/collection/admission.d.ts +1 -1
- package/dist/procedures/collection/admission.js +1 -1
- package/dist/procedures/collection/hint-filter.d.ts +1 -7
- package/dist/procedures/collection/hint-filter.d.ts.map +1 -1
- package/dist/procedures/collection/hint-filter.js +4 -7
- package/dist/procedures/collection/hint-filter.js.map +1 -1
- package/dist/server/face-rules.d.ts +1 -1
- package/dist/server/face-rules.js +1 -1
- package/dist/server/procedure-server.d.ts +7 -5
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +10 -8
- package/dist/server/procedure-server.js.map +1 -1
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/types.js.map +1 -1
- package/dist/testing/transport-contract.d.ts +1 -1
- package/dist/testing/transport-contract.js +1 -1
- package/package.json +8 -4
- package/src/adapters/http/client/batch.ts +4 -1
- package/src/adapters/http/server/body.ts +4 -1
- package/src/adapters/http/shared/constants.ts +84 -78
- package/src/adapters/http/shared/errors.ts +16 -13
- package/src/adapters/port/transport.ts +4 -1
- package/src/adapters/sequence.ts +5 -1
- package/src/adapters/websocket/client/types.ts +19 -6
- package/src/adapters/websocket/shared/rpc.ts +15 -3
- package/src/browser.ts +1 -0
- package/src/client/batch-executor.ts +5 -1
- package/src/client/client.ts +3 -4
- package/src/client/errors/README.md +12 -12
- package/src/client/errors/codes.ts +67 -64
- package/src/client/errors/http-status.ts +70 -67
- package/src/client/errors/types.ts +44 -15
- package/src/client/middleware/circuit-breaker.ts +7 -2
- package/src/client/middleware/lru.ts +7 -2
- package/src/client/middleware/rate-limit.ts +4 -1
- package/src/client/middleware/resilience.ts +2 -1
- package/src/client/route-resolver.ts +5 -1
- package/src/client/types.ts +7 -2
- package/src/client/validation/types.ts +13 -6
- package/src/events/bus.ts +152 -23
- package/src/events/index.ts +1 -0
- package/src/events/publisher.ts +22 -9
- package/src/events/recheck.ts +4 -1
- package/src/events/remote.ts +33 -12
- package/src/events/types.ts +34 -0
- package/src/middleware/compose.ts +6 -22
- package/src/procedures/caller.ts +1 -1
- package/src/procedures/collection/admission.ts +1 -1
- package/src/procedures/collection/hint-filter.ts +13 -6
- package/src/server/face-rules.ts +1 -1
- package/src/server/procedure-server.ts +11 -8
- package/src/server/types.ts +7 -2
- package/src/testing/transport-contract.ts +1 -1
package/src/events/bus.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
EventHandler,
|
|
12
12
|
Unsubscribe,
|
|
13
13
|
EventBusOptions,
|
|
14
|
+
EventFanOutOptions,
|
|
14
15
|
EventListenOptions,
|
|
15
16
|
EventWaitOptions,
|
|
16
17
|
} from "./types.js";
|
|
@@ -34,6 +35,31 @@ function clearedError(channel: string): Error {
|
|
|
34
35
|
return error;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** The default of `fanOut.maxPending`. No bench chose it: a run of a slow channel can change it. */
|
|
39
|
+
const DEFAULT_MAX_PENDING = 1024;
|
|
40
|
+
|
|
41
|
+
/** The default of `fanOut.schedule`: a later task of the event loop. */
|
|
42
|
+
function nextTask(slice: () => void): void {
|
|
43
|
+
if (typeof setImmediate === "function") setImmediate(slice);
|
|
44
|
+
else setTimeout(slice, 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The handlers of a channel, each with the number of its subscription, in the order of these numbers. */
|
|
48
|
+
type ChannelHandlers = Map<EventHandler<unknown>, number>;
|
|
49
|
+
|
|
50
|
+
/** One event that waits for its slices. */
|
|
51
|
+
interface PendingEvent {
|
|
52
|
+
readonly channel: string;
|
|
53
|
+
readonly data: unknown;
|
|
54
|
+
/** The handlers of the channel at `emit()`. A new map of the channel gets no old event. */
|
|
55
|
+
readonly handlers: ChannelHandlers;
|
|
56
|
+
/** The live cursor: a removed handler gets no call, with no copy of the handlers per event. */
|
|
57
|
+
readonly cursor: Iterator<[EventHandler<unknown>, number]>;
|
|
58
|
+
/** The last subscription at `emit()`. A later handler does not get this event. */
|
|
59
|
+
readonly lastSubscription: number;
|
|
60
|
+
done: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
37
63
|
// =============================================================================
|
|
38
64
|
// Event Bus Implementation
|
|
39
65
|
// =============================================================================
|
|
@@ -43,16 +69,34 @@ function clearedError(channel: string): Error {
|
|
|
43
69
|
* Supports pub/sub messaging with async iteration.
|
|
44
70
|
*/
|
|
45
71
|
export class DefaultEventBus implements EventBus {
|
|
46
|
-
private readonly subscribers = new Map<string,
|
|
72
|
+
private readonly subscribers = new Map<string, ChannelHandlers>();
|
|
73
|
+
/** The number of the last subscription of this bus. */
|
|
74
|
+
private lastSubscription = 0;
|
|
75
|
+
/** The events that wait for their slices, oldest first, from the index `pendingHead`. */
|
|
76
|
+
private pending: PendingEvent[] = [];
|
|
77
|
+
private pendingHead = 0;
|
|
78
|
+
/** For each channel, the number of its events in `pending`. */
|
|
79
|
+
private readonly pendingOn = new Map<string, number>();
|
|
80
|
+
private sliceScheduled = false;
|
|
81
|
+
private drainWaiters: Array<() => void> = [];
|
|
47
82
|
private readonly buffer = new Map<string, unknown[]>();
|
|
48
83
|
/**
|
|
49
84
|
* For each channel, the functions that `clear()` runs: the `onClear` of each handler of `on()`
|
|
50
85
|
* that has one. Each open stream has one too, so `clear()` ends it.
|
|
51
86
|
*/
|
|
52
87
|
private readonly clearHandlers = new Map<string, Set<() => void>>();
|
|
53
|
-
private readonly options: Required<EventBusOptions
|
|
88
|
+
private readonly options: Required<Omit<EventBusOptions, "fanOut">>;
|
|
89
|
+
private readonly fanOut: Required<EventFanOutOptions> | undefined;
|
|
54
90
|
|
|
55
91
|
constructor(options: EventBusOptions = {}) {
|
|
92
|
+
const { fanOut } = options;
|
|
93
|
+
if (fanOut && (!Number.isInteger(fanOut.sliceSize) || fanOut.sliceSize < 1)) {
|
|
94
|
+
throw new RangeError(`The option fanOut.sliceSize must be a whole number from 1 up, not ${fanOut.sliceSize}`);
|
|
95
|
+
}
|
|
96
|
+
if (fanOut?.maxPending !== undefined && (!Number.isInteger(fanOut.maxPending) || fanOut.maxPending < 1)) {
|
|
97
|
+
throw new RangeError(`The option fanOut.maxPending must be a whole number from 1 up, not ${fanOut.maxPending}`);
|
|
98
|
+
}
|
|
99
|
+
this.fanOut = fanOut && { maxPending: DEFAULT_MAX_PENDING, schedule: nextTask, ...fanOut };
|
|
56
100
|
this.options = {
|
|
57
101
|
bufferSize: options.bufferSize ?? 0,
|
|
58
102
|
onError: options.onError ?? ((error, channel) => {
|
|
@@ -64,6 +108,12 @@ export class DefaultEventBus implements EventBus {
|
|
|
64
108
|
|
|
65
109
|
/**
|
|
66
110
|
* Emit an event on a channel.
|
|
111
|
+
*
|
|
112
|
+
* Without the option `fanOut`, each handler runs before this method returns. With it, a channel
|
|
113
|
+
* with more than `fanOut.sliceSize` handlers delivers later, in slices (roadmap CLI-90). One
|
|
114
|
+
* handler gets the events of a channel in the order of `emit()`. An unsubscribe stops the calls
|
|
115
|
+
* that wait for that handler. A handler that comes after `emit()` does not get the event. The
|
|
116
|
+
* events of two different channels can reach a handler in another order.
|
|
67
117
|
*/
|
|
68
118
|
emit<T>(channel: string, data: T): void {
|
|
69
119
|
if (this.options.debug) {
|
|
@@ -87,19 +137,104 @@ export class DefaultEventBus implements EventBus {
|
|
|
87
137
|
const channelSubs = this.subscribers.get(channel);
|
|
88
138
|
if (!channelSubs) return;
|
|
89
139
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
140
|
+
if (this.fanOut && (channelSubs.size > this.fanOut.sliceSize || this.pendingOn.has(channel))) {
|
|
141
|
+
this.enqueue(channel, data, channelSubs, this.fanOut);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const handler of channelSubs.keys()) this.call(handler, data, channel);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** This function calls one handler. An error goes to `onError`, and it stops no other handler. */
|
|
149
|
+
private call(handler: EventHandler<unknown>, data: unknown, channel: string): void {
|
|
150
|
+
try {
|
|
151
|
+
const result = handler(data);
|
|
152
|
+
// Handle async handlers
|
|
153
|
+
if (result instanceof Promise) {
|
|
154
|
+
result.catch((error) => this.options.onError(error, channel));
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
this.options.onError(error instanceof Error ? error : new Error(String(error)), channel);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** This function puts an event in the queue of the slices. A full queue finishes its oldest events. */
|
|
162
|
+
private enqueue(channel: string, data: unknown, handlers: ChannelHandlers, fanOut: Required<EventFanOutOptions>): void {
|
|
163
|
+
this.pending.push({ channel, data, handlers, cursor: handlers.entries(), lastSubscription: this.lastSubscription, done: false });
|
|
164
|
+
this.pendingOn.set(channel, (this.pendingOn.get(channel) ?? 0) + 1);
|
|
165
|
+
while (this.pending.length - this.pendingHead > fanOut.maxPending) {
|
|
166
|
+
const oldest = this.pending[this.pendingHead]!;
|
|
167
|
+
this.deliverPart(oldest, Number.POSITIVE_INFINITY);
|
|
168
|
+
this.retire(oldest);
|
|
169
|
+
}
|
|
170
|
+
this.scheduleSlice(fanOut);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private scheduleSlice(fanOut: Required<EventFanOutOptions>): void {
|
|
174
|
+
if (this.sliceScheduled || this.pendingHead === this.pending.length) return;
|
|
175
|
+
this.sliceScheduled = true;
|
|
176
|
+
fanOut.schedule(() => {
|
|
177
|
+
this.sliceScheduled = false;
|
|
178
|
+
this.deliver(fanOut.sliceSize);
|
|
179
|
+
this.scheduleSlice(fanOut);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** This function calls at most `limit` handlers of the oldest events. */
|
|
184
|
+
private deliver(limit: number): void {
|
|
185
|
+
let left = limit;
|
|
186
|
+
while (left > 0 && this.pendingHead < this.pending.length) {
|
|
187
|
+
const event = this.pending[this.pendingHead]!;
|
|
188
|
+
left -= this.deliverPart(event, left);
|
|
189
|
+
if (event.done) this.retire(event);
|
|
190
|
+
}
|
|
191
|
+
if (this.pendingHead === this.pending.length) this.drainWaiters.splice(0).forEach((resolve) => resolve());
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** This function calls at most `limit` handlers of one event, and returns the count of calls. */
|
|
195
|
+
private deliverPart(event: PendingEvent, limit: number): number {
|
|
196
|
+
let calls = 0;
|
|
197
|
+
while (calls < limit && !event.done) {
|
|
198
|
+
// clear(), or the last unsubscribe: the map of this event is not the map of the channel now
|
|
199
|
+
const step = this.subscribers.get(event.channel) === event.handlers ? event.cursor.next() : undefined;
|
|
200
|
+
if (!step || step.done || step.value[1] > event.lastSubscription) {
|
|
201
|
+
event.done = true;
|
|
202
|
+
break;
|
|
99
203
|
}
|
|
204
|
+
this.call(step.value[0], event.data, event.channel);
|
|
205
|
+
calls++;
|
|
206
|
+
}
|
|
207
|
+
return calls;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* This function takes a finished event from the queue. A handler in a slice can emit, and that
|
|
212
|
+
* emit can finish the event first, so this function examines the head.
|
|
213
|
+
*/
|
|
214
|
+
private retire(event: PendingEvent): void {
|
|
215
|
+
if (this.pending[this.pendingHead] !== event) return;
|
|
216
|
+
this.pendingHead++;
|
|
217
|
+
const left = (this.pendingOn.get(event.channel) ?? 1) - 1;
|
|
218
|
+
if (left === 0) this.pendingOn.delete(event.channel);
|
|
219
|
+
else this.pendingOn.set(event.channel, left);
|
|
220
|
+
if (this.pendingHead === this.pending.length) {
|
|
221
|
+
this.pending = [];
|
|
222
|
+
this.pendingHead = 0;
|
|
223
|
+
} else if (this.pendingHead > 1024 && this.pendingHead * 2 > this.pending.length) {
|
|
224
|
+
this.pending = this.pending.slice(this.pendingHead);
|
|
225
|
+
this.pendingHead = 0;
|
|
100
226
|
}
|
|
101
227
|
}
|
|
102
228
|
|
|
229
|
+
/**
|
|
230
|
+
* This method resolves when no event waits for a slice. A host calls it before it stops. Without
|
|
231
|
+
* the option `fanOut`, it resolves at once.
|
|
232
|
+
*/
|
|
233
|
+
drained(): Promise<void> {
|
|
234
|
+
if (this.pendingHead === this.pending.length) return Promise.resolve();
|
|
235
|
+
return new Promise((resolve) => this.drainWaiters.push(resolve));
|
|
236
|
+
}
|
|
237
|
+
|
|
103
238
|
/**
|
|
104
239
|
* Subscribe to events on a channel. The `onClear` of the options runs one time when `clear()`
|
|
105
240
|
* or `clearAll()` removes the handler, not at an unsubscribe.
|
|
@@ -111,11 +246,14 @@ export class DefaultEventBus implements EventBus {
|
|
|
111
246
|
|
|
112
247
|
let channelSubs = this.subscribers.get(channel);
|
|
113
248
|
if (!channelSubs) {
|
|
114
|
-
channelSubs = new
|
|
249
|
+
channelSubs = new Map();
|
|
115
250
|
this.subscribers.set(channel, channelSubs);
|
|
116
251
|
}
|
|
117
252
|
|
|
118
|
-
|
|
253
|
+
// A handler that is already on the channel keeps its place and its number
|
|
254
|
+
if (!channelSubs.has(handler as EventHandler<unknown>)) {
|
|
255
|
+
channelSubs.set(handler as EventHandler<unknown>, ++this.lastSubscription);
|
|
256
|
+
}
|
|
119
257
|
|
|
120
258
|
// The unsubscribe function works once, and removes only the set it added to. (Before, a
|
|
121
259
|
// repeated call of an old unsubscribe removed the set of a newer subscriber: deep dive CORE-15.)
|
|
@@ -144,16 +282,7 @@ export class DefaultEventBus implements EventBus {
|
|
|
144
282
|
// Replay buffered events if any
|
|
145
283
|
const buffered = this.buffer.get(channel);
|
|
146
284
|
if (buffered && buffered.length > 0) {
|
|
147
|
-
for (const data of buffered)
|
|
148
|
-
try {
|
|
149
|
-
const result = handler(data as T);
|
|
150
|
-
if (result instanceof Promise) {
|
|
151
|
-
result.catch((error) => this.options.onError(error, channel));
|
|
152
|
-
}
|
|
153
|
-
} catch (error) {
|
|
154
|
-
this.options.onError(error instanceof Error ? error : new Error(String(error)), channel);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
285
|
+
for (const data of buffered) this.call(handler as EventHandler<unknown>, data, channel);
|
|
157
286
|
}
|
|
158
287
|
|
|
159
288
|
return () => {
|
package/src/events/index.ts
CHANGED
package/src/events/publisher.ts
CHANGED
|
@@ -226,6 +226,16 @@ function resetHint(): unknown {
|
|
|
226
226
|
* gives the events of its queue, and then it ends with `TOPIC_CLEARED`.
|
|
227
227
|
*/
|
|
228
228
|
class TopicSubscription implements AsyncIterableIterator<EventItem> {
|
|
229
|
+
private readonly buffer: number;
|
|
230
|
+
private readonly signal: AbortSignal | undefined;
|
|
231
|
+
private readonly onClose: (subscription: TopicSubscription) => void;
|
|
232
|
+
/** The output filter of the host for this subscriber: each row of a value passes it */
|
|
233
|
+
private readonly admit: ((item: unknown) => Promise<boolean>) | undefined;
|
|
234
|
+
/** The invocation of the subscription: the rule of the rows of a value gets it (roadmap CLI-41) */
|
|
235
|
+
private readonly invocation?: Invocation | undefined;
|
|
236
|
+
/** The row filter of each topic of a collection, by the topic of the subscriber (roadmap CLI-78) */
|
|
237
|
+
private readonly filters?: ReadonlyMap<string, RowHintFilter> | undefined;
|
|
238
|
+
|
|
229
239
|
private confirmation: EventItem | undefined;
|
|
230
240
|
private readonly queue: QueuedEvent[] = [];
|
|
231
241
|
private seq = 0;
|
|
@@ -245,16 +255,19 @@ class TopicSubscription implements AsyncIterableIterator<EventItem> {
|
|
|
245
255
|
bus: EventBus,
|
|
246
256
|
/** The topics of the subscriber, each with the topic of the bus that it listens to */
|
|
247
257
|
routes: ReadonlyArray<Route>,
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
private readonly invocation?: Invocation | undefined,
|
|
255
|
-
/** The row filter of each topic of a collection, by the topic of the subscriber (roadmap CLI-78) */
|
|
256
|
-
private readonly filters?: ReadonlyMap<string, RowHintFilter> | undefined,
|
|
258
|
+
buffer: number,
|
|
259
|
+
signal: AbortSignal | undefined,
|
|
260
|
+
onClose: (subscription: TopicSubscription) => void,
|
|
261
|
+
admit: ((item: unknown) => Promise<boolean>) | undefined,
|
|
262
|
+
invocation?: Invocation | undefined,
|
|
263
|
+
filters?: ReadonlyMap<string, RowHintFilter> | undefined,
|
|
257
264
|
) {
|
|
265
|
+
this.buffer = buffer;
|
|
266
|
+
this.signal = signal;
|
|
267
|
+
this.onClose = onClose;
|
|
268
|
+
this.admit = admit;
|
|
269
|
+
this.invocation = invocation;
|
|
270
|
+
this.filters = filters;
|
|
258
271
|
this.confirmation = { type: "subscribed", topics: routes.map(({ topic }) => topic) };
|
|
259
272
|
// Before, clear() removed the listeners, and the subscriber saw no end (EVENTS.md)
|
|
260
273
|
this.unsubscribes = routes.map(({ topic, busTopic }) =>
|
package/src/events/recheck.ts
CHANGED
|
@@ -109,6 +109,8 @@ type RecheckCheck = (cause: RecheckCause, scope?: RightsScopes) => Promise<void>
|
|
|
109
109
|
* each new subscription to `watch()`, with the check of its admission.
|
|
110
110
|
*/
|
|
111
111
|
export class SubscriptionRechecks {
|
|
112
|
+
private readonly clock: Clock;
|
|
113
|
+
|
|
112
114
|
private readonly watches = new Set<Watch>();
|
|
113
115
|
private readonly maxAgeMs: number | undefined;
|
|
114
116
|
private readonly source: SubscriptionRecheck["rightsChanges"];
|
|
@@ -117,8 +119,9 @@ export class SubscriptionRechecks {
|
|
|
117
119
|
|
|
118
120
|
constructor(
|
|
119
121
|
options: SubscriptionRecheck,
|
|
120
|
-
|
|
122
|
+
clock: Clock,
|
|
121
123
|
) {
|
|
124
|
+
this.clock = clock;
|
|
122
125
|
const maxAgeMs = options.maxAge === undefined ? undefined : toMs(options.maxAge, "recheck.maxAge");
|
|
123
126
|
if (maxAgeMs !== undefined && !(maxAgeMs > 0)) {
|
|
124
127
|
throw new TypeError(`recheck.maxAge must be more than 0 ms, got ${maxAgeMs} ms`);
|
package/src/events/remote.ts
CHANGED
|
@@ -168,18 +168,25 @@ export interface Subscription {
|
|
|
168
168
|
|
|
169
169
|
/** The error that ends a subscription. */
|
|
170
170
|
export class SubscriptionError extends Error {
|
|
171
|
+
/** The code: a code of the protocol, for example `TOPIC_NOT_FOUND`. */
|
|
172
|
+
readonly code: string;
|
|
173
|
+
/** True when a resubscribe can repair the failure. */
|
|
174
|
+
readonly retryable: boolean;
|
|
175
|
+
/** The reason of a `FORBIDDEN` error, for example `topic-not-allowed`. */
|
|
176
|
+
readonly reason?: string | undefined;
|
|
177
|
+
|
|
171
178
|
override readonly name = "SubscriptionError";
|
|
172
179
|
|
|
173
180
|
constructor(
|
|
174
|
-
|
|
175
|
-
readonly code: string,
|
|
181
|
+
code: string,
|
|
176
182
|
message: string,
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
/** The reason of a `FORBIDDEN` error, for example `topic-not-allowed`. */
|
|
180
|
-
readonly reason?: string | undefined,
|
|
183
|
+
retryable: boolean,
|
|
184
|
+
reason?: string | undefined,
|
|
181
185
|
) {
|
|
182
186
|
super(message);
|
|
187
|
+
this.code = code;
|
|
188
|
+
this.retryable = retryable;
|
|
189
|
+
this.reason = reason;
|
|
183
190
|
}
|
|
184
191
|
}
|
|
185
192
|
|
|
@@ -462,6 +469,8 @@ type Entry =
|
|
|
462
469
|
* last event that the shared subscription got before the subscriber joined.
|
|
463
470
|
*/
|
|
464
471
|
class Subscriber {
|
|
472
|
+
readonly id: string;
|
|
473
|
+
|
|
465
474
|
/** The `seq` of the wire subscription before the first event of this subscriber. */
|
|
466
475
|
base = 0;
|
|
467
476
|
/** The error that ends this subscriber, after the entries of its queue. */
|
|
@@ -473,7 +482,9 @@ class Subscriber {
|
|
|
473
482
|
private waiter: ((entry: Entry | undefined) => void) | undefined;
|
|
474
483
|
private ended = false;
|
|
475
484
|
|
|
476
|
-
constructor(
|
|
485
|
+
constructor(id: string) {
|
|
486
|
+
this.id = id;
|
|
487
|
+
}
|
|
477
488
|
|
|
478
489
|
/** True when the subscriber waits for an entry. */
|
|
479
490
|
get waiting(): boolean {
|
|
@@ -546,6 +557,11 @@ class Subscriber {
|
|
|
546
557
|
* when its last subscriber leaves, or with the end of the wire subscription for each subscriber.
|
|
547
558
|
*/
|
|
548
559
|
class SharedSubscription {
|
|
560
|
+
private readonly shares: Map<string, SharedSubscription> | undefined;
|
|
561
|
+
private readonly key: string;
|
|
562
|
+
private readonly transport: EventTransport;
|
|
563
|
+
private readonly call: Omit<WireCall, "onSubscribed" | "onRetry">;
|
|
564
|
+
|
|
549
565
|
private readonly members = new Set<Subscriber>();
|
|
550
566
|
private readonly stop = new AbortController();
|
|
551
567
|
/** The topics of the current confirmation, or undefined before it and during a resubscribe. */
|
|
@@ -557,11 +573,16 @@ class SharedSubscription {
|
|
|
557
573
|
private finished = false;
|
|
558
574
|
|
|
559
575
|
constructor(
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
) {
|
|
576
|
+
shares: Map<string, SharedSubscription> | undefined,
|
|
577
|
+
key: string,
|
|
578
|
+
transport: EventTransport,
|
|
579
|
+
call: Omit<WireCall, "onSubscribed" | "onRetry">,
|
|
580
|
+
) {
|
|
581
|
+
this.shares = shares;
|
|
582
|
+
this.key = key;
|
|
583
|
+
this.transport = transport;
|
|
584
|
+
this.call = call;
|
|
585
|
+
}
|
|
565
586
|
|
|
566
587
|
/** True while a new subscriber can join. */
|
|
567
588
|
get open(): boolean {
|
package/src/events/types.ts
CHANGED
|
@@ -273,4 +273,38 @@ export interface EventBusOptions {
|
|
|
273
273
|
* Default: false
|
|
274
274
|
*/
|
|
275
275
|
debug?: boolean;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The bounded fan-out (roadmap CLI-90). Without it, `emit()` calls each handler before it
|
|
279
|
+
* returns, at each channel size.
|
|
280
|
+
*/
|
|
281
|
+
fanOut?: EventFanOutOptions;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The options of the bounded fan-out of an event bus (roadmap CLI-90).
|
|
286
|
+
*
|
|
287
|
+
* A channel with more handlers than `sliceSize` delivers after `emit()` returns, in slices. The
|
|
288
|
+
* event loop runs between two slices, so one event does not hold it. A smaller channel stays
|
|
289
|
+
* synchronous, while no event of the channel waits.
|
|
290
|
+
*
|
|
291
|
+
* The limit is a count of handlers, not a time. The work of a stream reader runs in microtasks
|
|
292
|
+
* after its handler returns, so a clock in the slice does not see this work.
|
|
293
|
+
*/
|
|
294
|
+
export interface EventFanOutOptions {
|
|
295
|
+
/** The most handlers that one slice calls. A number from 1 up. */
|
|
296
|
+
sliceSize: number;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The most events that wait for their slices. The next event then finishes the oldest events
|
|
300
|
+
* in its `emit()`: the bus loses no event, and its memory stays bounded.
|
|
301
|
+
* Default: 1024
|
|
302
|
+
*/
|
|
303
|
+
maxPending?: number;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The function that runs a slice in a later task of the event loop.
|
|
307
|
+
* Default: `setImmediate`, or `setTimeout` where `setImmediate` does not exist
|
|
308
|
+
*/
|
|
309
|
+
schedule?: (slice: () => void) => void;
|
|
276
310
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* for any chain length - no manual overloads needed!
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { compose as foldLayers, type Layer } from "@mark1russell7/collections/layers";
|
|
9
10
|
import type {
|
|
10
11
|
Middleware,
|
|
11
12
|
MiddlewareRunner,
|
|
@@ -47,12 +48,7 @@ export function wrapWithAsyncMiddlewares<
|
|
|
47
48
|
true
|
|
48
49
|
>
|
|
49
50
|
): MiddlewareRunner<TBaseContext, TReturn, true> {
|
|
50
|
-
const combinedRunner = middlewares
|
|
51
|
-
(currentRunner, currentMiddleware) => {
|
|
52
|
-
return currentMiddleware(currentRunner);
|
|
53
|
-
},
|
|
54
|
-
fn
|
|
55
|
-
) as MiddlewareRunner<TBaseContext, TReturn, true>;
|
|
51
|
+
const combinedRunner = foldLayers(...(middlewares as readonly Layer[]))(fn) as MiddlewareRunner<TBaseContext, TReturn, true>;
|
|
56
52
|
|
|
57
53
|
// Wrap in async to ensure synchronous throws become rejected promises
|
|
58
54
|
return async (context) => {
|
|
@@ -85,9 +81,7 @@ export function wrapWithSyncMiddlewares<
|
|
|
85
81
|
false
|
|
86
82
|
>
|
|
87
83
|
): MiddlewareRunner<TBaseContext, TReturn, false> {
|
|
88
|
-
return
|
|
89
|
-
return currentMiddleware(currentRunner);
|
|
90
|
-
}, fn) as MiddlewareRunner<TBaseContext, TReturn, false>;
|
|
84
|
+
return foldLayers(...(middlewares as readonly Layer[]))(fn) as MiddlewareRunner<TBaseContext, TReturn, false>;
|
|
91
85
|
}
|
|
92
86
|
|
|
93
87
|
/**
|
|
@@ -115,13 +109,8 @@ export function compose<TAsync extends boolean = false>(
|
|
|
115
109
|
const fn = args[args.length - 1];
|
|
116
110
|
const middleware = args.slice(0, -1) as Middleware<any, any, any, TAsync>[];
|
|
117
111
|
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
(next, mw) => mw(next),
|
|
121
|
-
fn
|
|
122
|
-
) as MiddlewareRunner<any, any, TAsync>;
|
|
123
|
-
|
|
124
|
-
return combined;
|
|
112
|
+
// The first middleware is the outermost one (onion model)
|
|
113
|
+
return foldLayers(...(middleware as readonly Layer[]))(fn) as MiddlewareRunner<any, any, TAsync>;
|
|
125
114
|
}
|
|
126
115
|
|
|
127
116
|
/**
|
|
@@ -150,10 +139,5 @@ export function compose<TAsync extends boolean = false>(
|
|
|
150
139
|
export function bundle<TAsync extends boolean = false>(
|
|
151
140
|
...middleware: Middleware<any, any, any, TAsync>[]
|
|
152
141
|
): Middleware<any, any, any, TAsync> {
|
|
153
|
-
return (
|
|
154
|
-
middleware.reduceRight((acc, mw) => mw(acc), next) as MiddlewareRunner<
|
|
155
|
-
any,
|
|
156
|
-
any,
|
|
157
|
-
TAsync
|
|
158
|
-
>;
|
|
142
|
+
return foldLayers(...(middleware as readonly Layer[])) as Middleware<any, any, any, TAsync>;
|
|
159
143
|
}
|
package/src/procedures/caller.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A caller of the procedures of some modules (`callerOf`). An app that calls the procedures of a
|
|
3
3
|
* package in its own process repeats three steps: a registry of the module, `invokePath` and
|
|
4
|
-
* `outputValue`. A caller does the three steps in one call.
|
|
4
|
+
* `outputValue`. A caller does the three steps in one call.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { ProcedurePath } from "./types.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The rules of the rows of a served collection (roadmap CLI-41). A right can depend on the
|
|
3
|
-
* document: a `when` condition of
|
|
3
|
+
* document: a `when` condition of an app, or `accessAdmitItem()` of `@procedures/access`. The option
|
|
4
4
|
* `admission` of `createCollectionProcedures()` gives the rules as data:
|
|
5
5
|
*
|
|
6
6
|
* - `admit` examines each row `{ id, value }` that a read gives. It has the shape of `AdmitItem`,
|
|
@@ -201,6 +201,13 @@ function ignore(): void {}
|
|
|
201
201
|
* of the topic to `apply()`, one at a time, in the order of the bus.
|
|
202
202
|
*/
|
|
203
203
|
export class RowHintFilter {
|
|
204
|
+
/** The rules of the collection from the registry of the call. */
|
|
205
|
+
private readonly lookup: () => CollectionAdmission | null | undefined;
|
|
206
|
+
/** The invocation of the topic (`collectionInvocation()`): the rules get it. */
|
|
207
|
+
readonly invocation: Invocation | undefined;
|
|
208
|
+
/** The partition of the topic: `admitsAll` gets it. */
|
|
209
|
+
private readonly partition: string | undefined;
|
|
210
|
+
|
|
204
211
|
/** The rules from the registry: undefined when the registry does not know the collection. */
|
|
205
212
|
private rules: CollectionAdmission | null | undefined;
|
|
206
213
|
/** The result of `admitsAll` for one set of rules. */
|
|
@@ -213,13 +220,13 @@ export class RowHintFilter {
|
|
|
213
220
|
private tail: Promise<unknown> = Promise.resolve();
|
|
214
221
|
|
|
215
222
|
constructor(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
readonly invocation: Invocation | undefined,
|
|
220
|
-
/** The partition of the topic: `admitsAll` gets it. */
|
|
221
|
-
private readonly partition: string | undefined,
|
|
223
|
+
lookup: () => CollectionAdmission | null | undefined,
|
|
224
|
+
invocation: Invocation | undefined,
|
|
225
|
+
partition: string | undefined,
|
|
222
226
|
) {
|
|
227
|
+
this.lookup = lookup;
|
|
228
|
+
this.invocation = invocation;
|
|
229
|
+
this.partition = partition;
|
|
223
230
|
this.rules = lookup();
|
|
224
231
|
}
|
|
225
232
|
|
package/src/server/face-rules.ts
CHANGED
|
@@ -73,7 +73,7 @@ function exposeRule(expose: NonNullable<FaceRulesOptions["expose"]>): (path: Pro
|
|
|
73
73
|
*
|
|
74
74
|
* @example
|
|
75
75
|
* ```typescript
|
|
76
|
-
* const rules = faceRules({ narrowing: { effects: { writes: [] } }, expose: ["
|
|
76
|
+
* const rules = faceRules({ narrowing: { effects: { writes: [] } }, expose: ["api.*"] }, policy, "read");
|
|
77
77
|
* const handler = { ...inner, handle: (request) => inner.handle(rules.apply(request)) };
|
|
78
78
|
* ```
|
|
79
79
|
*/
|
|
@@ -146,7 +146,8 @@ export interface ProcedureServerOptions extends ServerOptions {
|
|
|
146
146
|
* the server answers `UNAUTHORIZED`. Only a local request (`LOCAL_CREDENTIALS`) skips it. The
|
|
147
147
|
* server refuses a request without credentials (CLI-1). Before, only the Express middleware of
|
|
148
148
|
* the peer examined the token, so a `ProcedureServer` with the HTTP or WebSocket transport of the
|
|
149
|
-
* core and the token of a host policy accepted each request.
|
|
149
|
+
* core and the token of a host policy accepted each request. An empty token means "refuse each
|
|
150
|
+
* network request": no presented token matches it, not even an empty one.
|
|
150
151
|
*/
|
|
151
152
|
token?: string | undefined;
|
|
152
153
|
|
|
@@ -235,16 +236,18 @@ export interface RegisterProcedureOptions {
|
|
|
235
236
|
}
|
|
236
237
|
|
|
237
238
|
/**
|
|
238
|
-
* True when the presented token is the expected one.
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
239
|
+
* True when the presented token is the expected one. An empty expected token matches nothing, not
|
|
240
|
+
* even an empty presented token, so a server with an empty token refuses each network request.
|
|
241
|
+
* The comparison reads each character of the presented token one time, so its time depends only
|
|
242
|
+
* on what the caller sent. The time tells neither the expected token nor its length. The function
|
|
243
|
+
* runs in a browser too, so it uses no `node:crypto`.
|
|
242
244
|
*/
|
|
243
245
|
export function tokenMatches(expected: string, presented: string | undefined): boolean {
|
|
244
246
|
if (presented === undefined) return false;
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
// An empty expected token and a difference of the lengths make the result false, after the same
|
|
248
|
+
// loop as an equal length. The modulo reuses the expected characters for a longer token.
|
|
249
|
+
let difference = (expected.length === 0 ? 1 : 0) | (expected.length ^ presented.length);
|
|
250
|
+
for (let index = 0; index < presented.length; index++) difference |= presented.charCodeAt(index) ^ expected.charCodeAt(index % expected.length);
|
|
248
251
|
return difference === 0;
|
|
249
252
|
}
|
|
250
253
|
|
package/src/server/types.ts
CHANGED
|
@@ -388,12 +388,17 @@ export class HandlerNotFoundError extends Error {
|
|
|
388
388
|
* before, the default was the string `"500"`).
|
|
389
389
|
*/
|
|
390
390
|
export class ServerError extends Error {
|
|
391
|
+
public readonly code: string;
|
|
392
|
+
public readonly retryable: boolean;
|
|
393
|
+
|
|
391
394
|
constructor(
|
|
392
395
|
message: string,
|
|
393
|
-
|
|
394
|
-
|
|
396
|
+
code: string = "INTERNAL_ERROR",
|
|
397
|
+
retryable: boolean = false
|
|
395
398
|
) {
|
|
396
399
|
super(message);
|
|
400
|
+
this.code = code;
|
|
401
|
+
this.retryable = retryable;
|
|
397
402
|
this.name = "ServerError";
|
|
398
403
|
}
|
|
399
404
|
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* skips the cases of that feature. Each skip thus is data in the test file of the face.
|
|
21
21
|
*
|
|
22
22
|
* The suite is the entry point `@procedures/client/testing`, so a face in another package (and
|
|
23
|
-
* a server of an app
|
|
23
|
+
* a server of an app) runs the same cases with the client that it uses. The
|
|
24
24
|
* suite imports no test runner: the caller gives `describe`, `it`, `afterEach` and `expect`
|
|
25
25
|
* (`ContractRunner`). Those of Vitest fit.
|
|
26
26
|
*/
|