@proj-airi/stream-kit 0.0.0-stage → 0.15.2
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.md +152 -2
- package/dist/index.d.mts +429 -0
- package/dist/index.mjs +778 -0
- package/package.json +34 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
//#region src/batcher.ts
|
|
2
|
+
const scheduleTimeout = (callback, delayMs) => {
|
|
3
|
+
const timer = setTimeout(callback, delayMs);
|
|
4
|
+
return () => clearTimeout(timer);
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Groups items into batches, like `groupedWithin`. A batch flushes when it has `maxItems` items, or `maxWaitMs`
|
|
8
|
+
* after its first item, whichever comes first.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const batcher = createBatcher<TraceRecord>({ maxWaitMs: 50, maxItems: 500, flush: records => send({ records }) })
|
|
12
|
+
* batcher.push(record)
|
|
13
|
+
*/
|
|
14
|
+
function createBatcher(options) {
|
|
15
|
+
const maxItems = options.maxItems ?? Number.POSITIVE_INFINITY;
|
|
16
|
+
if (!Number.isFinite(options.maxWaitMs) || options.maxWaitMs < 0) throw new Error("maxWaitMs must be finite and nonnegative");
|
|
17
|
+
if (maxItems < 1) throw new Error("maxItems must be at least 1");
|
|
18
|
+
const schedule = options.schedule ?? scheduleTimeout;
|
|
19
|
+
let items = [];
|
|
20
|
+
let deadline = 0;
|
|
21
|
+
let cancelTimer;
|
|
22
|
+
let closed = false;
|
|
23
|
+
function flush() {
|
|
24
|
+
cancelTimer?.();
|
|
25
|
+
cancelTimer = void 0;
|
|
26
|
+
if (items.length === 0) return;
|
|
27
|
+
const batch = items;
|
|
28
|
+
items = [];
|
|
29
|
+
options.flush(batch);
|
|
30
|
+
}
|
|
31
|
+
function push(item) {
|
|
32
|
+
if (closed) return;
|
|
33
|
+
if (items.length > 0 && options.now && options.now() >= deadline) flush();
|
|
34
|
+
items.push(item);
|
|
35
|
+
if (items.length >= maxItems) {
|
|
36
|
+
flush();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (items.length === 1) {
|
|
40
|
+
deadline = (options.now?.() ?? 0) + options.maxWaitMs;
|
|
41
|
+
cancelTimer = schedule(flush, options.maxWaitMs);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
push,
|
|
46
|
+
flush,
|
|
47
|
+
close() {
|
|
48
|
+
closed = true;
|
|
49
|
+
flush();
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/catch-up-feed.ts
|
|
55
|
+
/**
|
|
56
|
+
* A live feed in which a new subscriber first receives the backlog, then `caughtUp`, then the live messages.
|
|
57
|
+
*
|
|
58
|
+
* Guarantee: no message is lost or repeated between the backlog and the live messages, if the owner updates the
|
|
59
|
+
* state behind `backlog` before each `publish`. `subscribe` copies the backlog first. A `publish` during the backlog
|
|
60
|
+
* delivery, for example from a listener, waits and goes to the new subscriber after `caughtUp`. A subscriber added
|
|
61
|
+
* during a `publish` does not receive that message live, because its backlog already has it.
|
|
62
|
+
*
|
|
63
|
+
* The backlog and `caughtUp` always arrive synchronously inside `subscribe`. The strategy applies to live messages.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* const feed = createCatchUpFeed<Message>({ backlog: () => buffer.values(), caughtUp: () => ({ kind: 'caughtUp' }) })
|
|
67
|
+
* buffer.push(record)
|
|
68
|
+
* feed.publish({ kind: 'record', record })
|
|
69
|
+
*/
|
|
70
|
+
function createCatchUpFeed(options) {
|
|
71
|
+
const subscribers = /* @__PURE__ */ new Set();
|
|
72
|
+
function publish(message) {
|
|
73
|
+
for (const subscriber of [...subscribers]) {
|
|
74
|
+
if (!subscriber.active) continue;
|
|
75
|
+
if (subscriber.held) subscriber.held.push(message);
|
|
76
|
+
else subscriber.deliver(message);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function subscribe(listener, subscribeOptions = {}) {
|
|
80
|
+
const strategy = subscribeOptions.strategy ?? "unbounded";
|
|
81
|
+
const subscriber = {
|
|
82
|
+
listener,
|
|
83
|
+
active: true,
|
|
84
|
+
held: [],
|
|
85
|
+
deliver: listener
|
|
86
|
+
};
|
|
87
|
+
if (strategy !== "unbounded") subscriber.deliver = createBoundedDelivery(subscriber, strategy, subscribeOptions.capacity ?? 256);
|
|
88
|
+
const backlog = [...options.backlog()];
|
|
89
|
+
if (options.caughtUp) backlog.push(options.caughtUp());
|
|
90
|
+
subscribers.add(subscriber);
|
|
91
|
+
const unsubscribe = () => {
|
|
92
|
+
subscriber.active = false;
|
|
93
|
+
subscriber.held = void 0;
|
|
94
|
+
subscribers.delete(subscriber);
|
|
95
|
+
};
|
|
96
|
+
try {
|
|
97
|
+
for (const message of backlog) listener(message);
|
|
98
|
+
const held = subscriber.held;
|
|
99
|
+
for (let index = 0; index < held.length; index++) subscriber.deliver(held[index]);
|
|
100
|
+
subscriber.held = void 0;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
unsubscribe();
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
return { unsubscribe };
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
publish,
|
|
109
|
+
subscribe,
|
|
110
|
+
get subscribers() {
|
|
111
|
+
return subscribers.size;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function createBoundedDelivery(subscriber, strategy, capacity) {
|
|
116
|
+
if (!Number.isInteger(capacity) || capacity < 1) throw new Error("capacity must be a positive integer");
|
|
117
|
+
const queue = [];
|
|
118
|
+
let scheduled = false;
|
|
119
|
+
function drain() {
|
|
120
|
+
scheduled = false;
|
|
121
|
+
while (subscriber.active && queue.length > 0) {
|
|
122
|
+
const message = queue.shift();
|
|
123
|
+
try {
|
|
124
|
+
subscriber.listener(message);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (queue.length > 0) schedule();
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
queue.length = 0;
|
|
131
|
+
}
|
|
132
|
+
function schedule() {
|
|
133
|
+
if (!scheduled) {
|
|
134
|
+
scheduled = true;
|
|
135
|
+
queueMicrotask(drain);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return (message) => {
|
|
139
|
+
if (queue.length >= capacity) {
|
|
140
|
+
if (strategy === "dropping") return;
|
|
141
|
+
queue.shift();
|
|
142
|
+
}
|
|
143
|
+
queue.push(message);
|
|
144
|
+
schedule();
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/cooldown.ts
|
|
149
|
+
/**
|
|
150
|
+
* Limits an action to once per window for each key, for example one flight recorder dump per source.
|
|
151
|
+
*
|
|
152
|
+
* The cooldown keeps one time for each key that it has seen.
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* const cooldown = createCooldown({ ms: 30_000 })
|
|
156
|
+
* if (cooldown.tryAcquire(source, now))
|
|
157
|
+
* dump()
|
|
158
|
+
*/
|
|
159
|
+
function createCooldown(options) {
|
|
160
|
+
const acquiredAt = /* @__PURE__ */ new Map();
|
|
161
|
+
return { tryAcquire(key, now) {
|
|
162
|
+
const last = acquiredAt.get(key);
|
|
163
|
+
if (last !== void 0 && now - last < options.ms) return false;
|
|
164
|
+
acquiredAt.set(key, now);
|
|
165
|
+
return true;
|
|
166
|
+
} };
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/demand-union.ts
|
|
170
|
+
const ALL = "*";
|
|
171
|
+
/**
|
|
172
|
+
* The union of what each member wants, for example the tracks of all viewers.
|
|
173
|
+
*
|
|
174
|
+
* A producer reads the union to skip work that no member wants. One `'*'` member makes the union `'*'`.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* const demand = new DemandUnion<string>({ onChange: union => source.setWanted(union) })
|
|
178
|
+
* demand.set('viewer-1', ['audio.level'])
|
|
179
|
+
* demand.set('viewer-2', '*') // onChange('*')
|
|
180
|
+
* demand.delete('viewer-2') // onChange(Set { 'audio.level' })
|
|
181
|
+
*/
|
|
182
|
+
var DemandUnion = class {
|
|
183
|
+
options;
|
|
184
|
+
members = /* @__PURE__ */ new Map();
|
|
185
|
+
current = /* @__PURE__ */ new Set();
|
|
186
|
+
constructor(options = {}) {
|
|
187
|
+
this.options = options;
|
|
188
|
+
}
|
|
189
|
+
get union() {
|
|
190
|
+
return this.current;
|
|
191
|
+
}
|
|
192
|
+
set(id, demand) {
|
|
193
|
+
this.members.set(id, demand === ALL ? ALL : new Set(demand));
|
|
194
|
+
this.update();
|
|
195
|
+
}
|
|
196
|
+
delete(id) {
|
|
197
|
+
if (this.members.delete(id)) this.update();
|
|
198
|
+
}
|
|
199
|
+
update() {
|
|
200
|
+
const next = this.compute();
|
|
201
|
+
if (sameDemand(next, this.current)) return;
|
|
202
|
+
this.current = next;
|
|
203
|
+
this.options.onChange?.(next);
|
|
204
|
+
}
|
|
205
|
+
compute() {
|
|
206
|
+
const union = /* @__PURE__ */ new Set();
|
|
207
|
+
for (const demand of this.members.values()) {
|
|
208
|
+
if (demand === ALL) return ALL;
|
|
209
|
+
for (const key of demand) union.add(key);
|
|
210
|
+
}
|
|
211
|
+
return union;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
function sameDemand(a, b) {
|
|
215
|
+
if (a === ALL || b === ALL) return a === b;
|
|
216
|
+
if (a.size !== b.size) return false;
|
|
217
|
+
for (const key of a) if (!b.has(key)) return false;
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/latest.ts
|
|
222
|
+
/**
|
|
223
|
+
* Creates a runner that keeps only the newest input.
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* const runner = createLatest<string>(async (text, signal) => cache.set(text, await rephrase(text, signal)), { debounceMs: 600 })
|
|
227
|
+
* runner.push('hello')
|
|
228
|
+
* runner.push('hello world') // aborts the run for 'hello'
|
|
229
|
+
*/
|
|
230
|
+
function createLatest(run, options = {}) {
|
|
231
|
+
const debounceMs = options.debounceMs ?? 0;
|
|
232
|
+
if (!Number.isFinite(debounceMs) || debounceMs < 0) throw new Error("Debounce must be finite and nonnegative");
|
|
233
|
+
const closed = new AbortController();
|
|
234
|
+
const key = options.key ?? ((value) => value);
|
|
235
|
+
const none = Symbol("none");
|
|
236
|
+
let lastKey = none;
|
|
237
|
+
let timer;
|
|
238
|
+
let active;
|
|
239
|
+
let running;
|
|
240
|
+
let waiting = Promise.withResolvers();
|
|
241
|
+
let pending = false;
|
|
242
|
+
function settleIfIdle() {
|
|
243
|
+
if (!pending && !running) {
|
|
244
|
+
waiting.resolve();
|
|
245
|
+
waiting = Promise.withResolvers();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function start(value) {
|
|
249
|
+
pending = false;
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
active = controller;
|
|
252
|
+
const signal = AbortSignal.any([controller.signal, closed.signal]);
|
|
253
|
+
running = (async () => {
|
|
254
|
+
try {
|
|
255
|
+
await run(value, signal);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (!signal.aborted) options.onError?.(error);
|
|
258
|
+
} finally {
|
|
259
|
+
if (active === controller) {
|
|
260
|
+
active = void 0;
|
|
261
|
+
running = void 0;
|
|
262
|
+
}
|
|
263
|
+
settleIfIdle();
|
|
264
|
+
}
|
|
265
|
+
})();
|
|
266
|
+
}
|
|
267
|
+
function push(value) {
|
|
268
|
+
if (closed.signal.aborted) return;
|
|
269
|
+
const next = key(value);
|
|
270
|
+
if (Object.is(next, lastKey)) return;
|
|
271
|
+
lastKey = next;
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
active?.abort("Superseded by a newer input");
|
|
274
|
+
active = void 0;
|
|
275
|
+
running = void 0;
|
|
276
|
+
pending = true;
|
|
277
|
+
if (debounceMs > 0) timer = setTimeout(start, debounceMs, value);
|
|
278
|
+
else start(value);
|
|
279
|
+
}
|
|
280
|
+
function cancelPending() {
|
|
281
|
+
if (!pending || running) return;
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
pending = false;
|
|
284
|
+
lastKey = none;
|
|
285
|
+
settleIfIdle();
|
|
286
|
+
}
|
|
287
|
+
function close(reason = "Runner closed") {
|
|
288
|
+
if (closed.signal.aborted) return;
|
|
289
|
+
clearTimeout(timer);
|
|
290
|
+
pending = false;
|
|
291
|
+
closed.abort(reason);
|
|
292
|
+
active = void 0;
|
|
293
|
+
running = void 0;
|
|
294
|
+
settleIfIdle();
|
|
295
|
+
}
|
|
296
|
+
options.signal?.addEventListener("abort", () => close(typeof options.signal?.reason === "string" ? options.signal.reason : void 0), {
|
|
297
|
+
once: true,
|
|
298
|
+
signal: closed.signal
|
|
299
|
+
});
|
|
300
|
+
if (options.signal?.aborted) close();
|
|
301
|
+
return {
|
|
302
|
+
push,
|
|
303
|
+
cancelPending,
|
|
304
|
+
idle: () => pending || running ? waiting.promise : Promise.resolve(),
|
|
305
|
+
close
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region src/lease-table.ts
|
|
310
|
+
/**
|
|
311
|
+
* Keys that stay alive while their owner renews them, for example a remote source with a heartbeat, or a tab that
|
|
312
|
+
* repeats its announcement. The caller decides when to call `expired`, usually from a periodic tick.
|
|
313
|
+
*
|
|
314
|
+
* @example
|
|
315
|
+
* const leases = createLeaseTable<string>({ ttlMs: 12_000 })
|
|
316
|
+
* leases.touch('tab-1', now)
|
|
317
|
+
* for (const tab of leases.expired(later))
|
|
318
|
+
* forgetTab(tab)
|
|
319
|
+
*/
|
|
320
|
+
function createLeaseTable(options) {
|
|
321
|
+
const touchedAt = /* @__PURE__ */ new Map();
|
|
322
|
+
return {
|
|
323
|
+
touch(key, now) {
|
|
324
|
+
touchedAt.set(key, now);
|
|
325
|
+
},
|
|
326
|
+
expired(now) {
|
|
327
|
+
const keys = [];
|
|
328
|
+
for (const [key, at] of touchedAt) if (now - at > options.ttlMs) keys.push(key);
|
|
329
|
+
for (const key of keys) touchedAt.delete(key);
|
|
330
|
+
return keys;
|
|
331
|
+
},
|
|
332
|
+
delete(key) {
|
|
333
|
+
touchedAt.delete(key);
|
|
334
|
+
},
|
|
335
|
+
has: (key) => touchedAt.has(key),
|
|
336
|
+
keys: () => touchedAt.keys()
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/queue.ts
|
|
341
|
+
function createQueue(options) {
|
|
342
|
+
const queue = [];
|
|
343
|
+
let draining = false;
|
|
344
|
+
const internalEventListeners = {
|
|
345
|
+
enqueue: [],
|
|
346
|
+
dequeue: [],
|
|
347
|
+
process: [],
|
|
348
|
+
error: [],
|
|
349
|
+
result: [],
|
|
350
|
+
drain: []
|
|
351
|
+
};
|
|
352
|
+
const internalHandlerEventListeners = {};
|
|
353
|
+
function on(eventName, listener) {
|
|
354
|
+
internalEventListeners[eventName].push(listener);
|
|
355
|
+
}
|
|
356
|
+
function excludeFirstArgument([, ...params]) {
|
|
357
|
+
return params;
|
|
358
|
+
}
|
|
359
|
+
function emit(...args) {
|
|
360
|
+
switch (args[0]) {
|
|
361
|
+
case "enqueue":
|
|
362
|
+
internalEventListeners.enqueue.forEach((listener) => listener(...excludeFirstArgument(args)));
|
|
363
|
+
break;
|
|
364
|
+
case "dequeue":
|
|
365
|
+
internalEventListeners.dequeue.forEach((listener) => listener(...excludeFirstArgument(args)));
|
|
366
|
+
break;
|
|
367
|
+
case "process":
|
|
368
|
+
internalEventListeners.process.forEach((listener) => listener(...excludeFirstArgument(args)));
|
|
369
|
+
break;
|
|
370
|
+
case "error":
|
|
371
|
+
internalEventListeners.error.forEach((listener) => listener(...excludeFirstArgument(args)));
|
|
372
|
+
break;
|
|
373
|
+
case "result":
|
|
374
|
+
internalEventListeners.result.forEach((listener) => listener(...excludeFirstArgument(args)));
|
|
375
|
+
break;
|
|
376
|
+
case "drain": internalEventListeners.drain.forEach((listener) => listener(...excludeFirstArgument(args)));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function onHandlerEvent(eventName, listener) {
|
|
380
|
+
internalHandlerEventListeners[eventName] = internalHandlerEventListeners[eventName] || [];
|
|
381
|
+
internalHandlerEventListeners[eventName].push(listener);
|
|
382
|
+
}
|
|
383
|
+
function emitHandlerEvent(eventName, ...params) {
|
|
384
|
+
(internalHandlerEventListeners[eventName] || []).forEach((listener) => listener(...params));
|
|
385
|
+
}
|
|
386
|
+
function enqueue(payload) {
|
|
387
|
+
queue.push(payload);
|
|
388
|
+
emit("enqueue", payload, queue.length);
|
|
389
|
+
if (!draining) {
|
|
390
|
+
draining = true;
|
|
391
|
+
drain();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function clear() {
|
|
395
|
+
queue.length = 0;
|
|
396
|
+
}
|
|
397
|
+
async function drain() {
|
|
398
|
+
while (queue.length > 0) {
|
|
399
|
+
const payload = queue.shift();
|
|
400
|
+
emit("dequeue", payload, queue.length);
|
|
401
|
+
for (const handler of options.handlers) {
|
|
402
|
+
emit("process", payload, handler);
|
|
403
|
+
try {
|
|
404
|
+
emit("result", payload, await handler({
|
|
405
|
+
data: payload,
|
|
406
|
+
emit: emitHandlerEvent
|
|
407
|
+
}), handler);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
emit("error", payload, err, handler);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
draining = false;
|
|
415
|
+
emit("drain");
|
|
416
|
+
}
|
|
417
|
+
function length() {
|
|
418
|
+
return queue.length;
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
enqueue,
|
|
422
|
+
clear,
|
|
423
|
+
length,
|
|
424
|
+
on,
|
|
425
|
+
onHandlerEvent
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
//#endregion
|
|
429
|
+
//#region src/retention-buffer.ts
|
|
430
|
+
/** Compacts the storage when this many dropped slots wait at its head. */
|
|
431
|
+
const COMPACT_AFTER = 1024;
|
|
432
|
+
/**
|
|
433
|
+
* Keeps the newest items within a byte limit and an age limit, in insertion order.
|
|
434
|
+
*
|
|
435
|
+
* Only the head can drop, so the age limit assumes that `ageOf` grows with insertion order. An older item behind a
|
|
436
|
+
* newer one stays until the newer one drops. A `push` applies only the byte limit, because the age limit needs `now`.
|
|
437
|
+
* One item that is larger than `maxBytes` drops at once. Eviction costs time for the dropped items only.
|
|
438
|
+
*
|
|
439
|
+
* @example
|
|
440
|
+
* const buffer = new RetentionBuffer<Entry>({ maxBytes: 8_000_000, maxAgeMs: 600_000, sizeOf: e => e.bytes, ageOf: e => e.observedAt })
|
|
441
|
+
* buffer.push(entry)
|
|
442
|
+
* buffer.evict(now) // call it from a periodic tick
|
|
443
|
+
*/
|
|
444
|
+
var RetentionBuffer = class {
|
|
445
|
+
options;
|
|
446
|
+
entries = [];
|
|
447
|
+
head = 0;
|
|
448
|
+
totalBytes = 0;
|
|
449
|
+
constructor(options) {
|
|
450
|
+
this.options = options;
|
|
451
|
+
}
|
|
452
|
+
get size() {
|
|
453
|
+
return this.entries.length - this.head;
|
|
454
|
+
}
|
|
455
|
+
get bytes() {
|
|
456
|
+
return this.totalBytes;
|
|
457
|
+
}
|
|
458
|
+
push(item) {
|
|
459
|
+
const bytes = this.options.sizeOf(item);
|
|
460
|
+
this.entries.push({
|
|
461
|
+
item,
|
|
462
|
+
bytes
|
|
463
|
+
});
|
|
464
|
+
this.totalBytes += bytes;
|
|
465
|
+
this.evictWhile(() => this.totalBytes > (this.options.maxBytes ?? Number.POSITIVE_INFINITY));
|
|
466
|
+
}
|
|
467
|
+
/** Drops the items older than the age limit, then the oldest items over the byte limit. Returns the dropped items. */
|
|
468
|
+
evict(now) {
|
|
469
|
+
const cutoff = now - (this.options.maxAgeMs ?? Number.POSITIVE_INFINITY);
|
|
470
|
+
const maxBytes = this.options.maxBytes ?? Number.POSITIVE_INFINITY;
|
|
471
|
+
return this.evictWhile((entry) => this.options.ageOf(entry.item) < cutoff || this.totalBytes > maxBytes);
|
|
472
|
+
}
|
|
473
|
+
/** The kept items, oldest first. Do not push or evict while you iterate. */
|
|
474
|
+
*values() {
|
|
475
|
+
for (let index = this.head; index < this.entries.length; index++) yield this.entries[index].item;
|
|
476
|
+
}
|
|
477
|
+
/** Drops every item without `onEvict`. */
|
|
478
|
+
clear() {
|
|
479
|
+
this.entries = [];
|
|
480
|
+
this.head = 0;
|
|
481
|
+
this.totalBytes = 0;
|
|
482
|
+
}
|
|
483
|
+
evictWhile(shouldDrop) {
|
|
484
|
+
const dropped = [];
|
|
485
|
+
while (this.head < this.entries.length) {
|
|
486
|
+
const entry = this.entries[this.head];
|
|
487
|
+
if (!shouldDrop(entry)) break;
|
|
488
|
+
this.totalBytes -= entry.bytes;
|
|
489
|
+
dropped.push(entry.item);
|
|
490
|
+
this.head++;
|
|
491
|
+
}
|
|
492
|
+
if (dropped.length === 0) return dropped;
|
|
493
|
+
if (this.head >= COMPACT_AFTER && this.head * 2 >= this.entries.length) {
|
|
494
|
+
this.entries = this.entries.slice(this.head);
|
|
495
|
+
this.head = 0;
|
|
496
|
+
}
|
|
497
|
+
this.options.onEvict?.(dropped);
|
|
498
|
+
return dropped;
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
//#endregion
|
|
502
|
+
//#region src/scope.ts
|
|
503
|
+
/**
|
|
504
|
+
* Creates a scope that closes when `parent` aborts.
|
|
505
|
+
*
|
|
506
|
+
* @example
|
|
507
|
+
* const scope = createScope(options.signal)
|
|
508
|
+
* scope.defer(() => track.stop())
|
|
509
|
+
* await scope.close('Recording finished')
|
|
510
|
+
*/
|
|
511
|
+
function createScope(parent, onCleanupError = console.error) {
|
|
512
|
+
const controller = new AbortController();
|
|
513
|
+
const cleanups = [];
|
|
514
|
+
const settled = Promise.withResolvers();
|
|
515
|
+
async function runCleanups() {
|
|
516
|
+
for (const cleanup of cleanups.splice(0).reverse()) try {
|
|
517
|
+
await cleanup();
|
|
518
|
+
} catch (error) {
|
|
519
|
+
onCleanupError(error);
|
|
520
|
+
}
|
|
521
|
+
settled.resolve();
|
|
522
|
+
}
|
|
523
|
+
function close(reason) {
|
|
524
|
+
if (!controller.signal.aborted) {
|
|
525
|
+
controller.abort(reason);
|
|
526
|
+
runCleanups();
|
|
527
|
+
}
|
|
528
|
+
return settled.promise;
|
|
529
|
+
}
|
|
530
|
+
parent?.addEventListener("abort", () => void close(parent.reason), {
|
|
531
|
+
once: true,
|
|
532
|
+
signal: controller.signal
|
|
533
|
+
});
|
|
534
|
+
if (parent?.aborted) close(parent.reason);
|
|
535
|
+
return {
|
|
536
|
+
signal: controller.signal,
|
|
537
|
+
closed: settled.promise,
|
|
538
|
+
defer(cleanup) {
|
|
539
|
+
if (controller.signal.aborted) Promise.resolve().then(cleanup).catch(onCleanupError);
|
|
540
|
+
else cleanups.push(cleanup);
|
|
541
|
+
},
|
|
542
|
+
child() {
|
|
543
|
+
return createScope(controller.signal, onCleanupError);
|
|
544
|
+
},
|
|
545
|
+
close
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
//#endregion
|
|
549
|
+
//#region src/sequence-tracker.ts
|
|
550
|
+
/**
|
|
551
|
+
* Accepts each sequence number once, in any arrival order.
|
|
552
|
+
*
|
|
553
|
+
* Numbers below `nextSeq` and repeats are not new. A number above `nextSeq` waits in a set until the numbers before
|
|
554
|
+
* it arrive. `nextSeq - 1` is the end of the unbroken prefix, so a reader can resume from it.
|
|
555
|
+
*
|
|
556
|
+
* @example
|
|
557
|
+
* const tracker = new SequenceTracker()
|
|
558
|
+
* tracker.accept(1) // => true, waits for 0
|
|
559
|
+
* tracker.accept(0) // => true, nextSeq is now 2
|
|
560
|
+
* tracker.accept(1) // => false
|
|
561
|
+
*/
|
|
562
|
+
var SequenceTracker = class {
|
|
563
|
+
next;
|
|
564
|
+
ahead = /* @__PURE__ */ new Set();
|
|
565
|
+
constructor(firstSeq = 0) {
|
|
566
|
+
this.next = firstSeq;
|
|
567
|
+
}
|
|
568
|
+
/** The lowest number that did not arrive yet. */
|
|
569
|
+
get nextSeq() {
|
|
570
|
+
return this.next;
|
|
571
|
+
}
|
|
572
|
+
/** Returns true when `seq` is new. */
|
|
573
|
+
accept(seq) {
|
|
574
|
+
if (seq < this.next || this.ahead.has(seq)) return false;
|
|
575
|
+
this.ahead.add(seq);
|
|
576
|
+
while (this.ahead.delete(this.next)) this.next++;
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
//#endregion
|
|
581
|
+
//#region src/stream.ts
|
|
582
|
+
/**
|
|
583
|
+
* The optional cancellation handler owns resources that feed this readable output.
|
|
584
|
+
* `strategy` measures unread values. Writers check `desiredSize` because a push source cannot wait.
|
|
585
|
+
*/
|
|
586
|
+
function createPushStream(onCancel, strategy) {
|
|
587
|
+
let closed = false;
|
|
588
|
+
let controller = null;
|
|
589
|
+
return {
|
|
590
|
+
stream: new ReadableStream({
|
|
591
|
+
start(ctrl) {
|
|
592
|
+
controller = ctrl;
|
|
593
|
+
},
|
|
594
|
+
cancel(reason) {
|
|
595
|
+
closed = true;
|
|
596
|
+
onCancel?.(reason);
|
|
597
|
+
}
|
|
598
|
+
}, strategy),
|
|
599
|
+
write(value) {
|
|
600
|
+
if (!controller || closed) return;
|
|
601
|
+
controller.enqueue(value);
|
|
602
|
+
},
|
|
603
|
+
close() {
|
|
604
|
+
if (!controller || closed) return;
|
|
605
|
+
closed = true;
|
|
606
|
+
controller.close();
|
|
607
|
+
},
|
|
608
|
+
error(err) {
|
|
609
|
+
if (!controller || closed) return;
|
|
610
|
+
closed = true;
|
|
611
|
+
controller.error(err);
|
|
612
|
+
},
|
|
613
|
+
isClosed() {
|
|
614
|
+
return closed;
|
|
615
|
+
},
|
|
616
|
+
desiredSize() {
|
|
617
|
+
return controller?.desiredSize ?? null;
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
async function readStream(stream, handler) {
|
|
622
|
+
const reader = stream.getReader();
|
|
623
|
+
try {
|
|
624
|
+
while (true) {
|
|
625
|
+
const { value, done } = await reader.read();
|
|
626
|
+
if (done) break;
|
|
627
|
+
await handler(value);
|
|
628
|
+
}
|
|
629
|
+
} finally {
|
|
630
|
+
reader.releaseLock();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
//#endregion
|
|
634
|
+
//#region src/task.ts
|
|
635
|
+
function reasonFrom(value, fallback) {
|
|
636
|
+
if (typeof value === "string") return value;
|
|
637
|
+
if (value instanceof Error) return value.message;
|
|
638
|
+
return fallback;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Creates a task that settles by an explicit call.
|
|
642
|
+
*
|
|
643
|
+
* @example
|
|
644
|
+
* const task = createTask<string>({ signal: parent.signal, timeoutMs: 5000 })
|
|
645
|
+
* task.defer(() => socket.close())
|
|
646
|
+
* task.finish('ok')
|
|
647
|
+
* await task.done // => { status: 'finished', value: 'ok' }
|
|
648
|
+
*/
|
|
649
|
+
function createTask(options = {}) {
|
|
650
|
+
if (options.timeoutMs !== void 0 && (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 0)) throw new Error("Task timeout must be finite and nonnegative");
|
|
651
|
+
const controller = new AbortController();
|
|
652
|
+
const completion = Promise.withResolvers();
|
|
653
|
+
const cleanups = [];
|
|
654
|
+
const cleaned = Promise.withResolvers();
|
|
655
|
+
const onCleanupError = options.onCleanupError ?? console.error;
|
|
656
|
+
const parent = options.signal;
|
|
657
|
+
let settled = false;
|
|
658
|
+
let timer;
|
|
659
|
+
async function runCleanups() {
|
|
660
|
+
for (const cleanup of cleanups.splice(0).reverse()) try {
|
|
661
|
+
await cleanup();
|
|
662
|
+
} catch (error) {
|
|
663
|
+
onCleanupError(error);
|
|
664
|
+
}
|
|
665
|
+
cleaned.resolve();
|
|
666
|
+
}
|
|
667
|
+
function settle(outcome) {
|
|
668
|
+
if (settled) return;
|
|
669
|
+
settled = true;
|
|
670
|
+
clearTimeout(timer);
|
|
671
|
+
parent?.removeEventListener("abort", onParentAbort);
|
|
672
|
+
if (outcome.status !== "finished") controller.abort(outcome.status === "failed" ? outcome.error : outcome.reason);
|
|
673
|
+
completion.resolve(outcome);
|
|
674
|
+
runCleanups();
|
|
675
|
+
}
|
|
676
|
+
function onParentAbort() {
|
|
677
|
+
settle({
|
|
678
|
+
status: "cancelled",
|
|
679
|
+
reason: reasonFrom(parent?.reason, "Parent task closed")
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
const task = {
|
|
683
|
+
signal: controller.signal,
|
|
684
|
+
done: completion.promise,
|
|
685
|
+
closed: cleaned.promise,
|
|
686
|
+
get settled() {
|
|
687
|
+
return settled;
|
|
688
|
+
},
|
|
689
|
+
defer(cleanup) {
|
|
690
|
+
if (settled) Promise.resolve().then(cleanup).catch(onCleanupError);
|
|
691
|
+
else cleanups.push(cleanup);
|
|
692
|
+
},
|
|
693
|
+
finish: (value) => settle({
|
|
694
|
+
status: "finished",
|
|
695
|
+
value
|
|
696
|
+
}),
|
|
697
|
+
cancel: (reason) => settle({
|
|
698
|
+
status: "cancelled",
|
|
699
|
+
reason
|
|
700
|
+
}),
|
|
701
|
+
fail: (error) => settle({
|
|
702
|
+
status: "failed",
|
|
703
|
+
error
|
|
704
|
+
})
|
|
705
|
+
};
|
|
706
|
+
if (parent?.aborted) {
|
|
707
|
+
onParentAbort();
|
|
708
|
+
return task;
|
|
709
|
+
}
|
|
710
|
+
parent?.addEventListener("abort", onParentAbort, { once: true });
|
|
711
|
+
if (options.timeoutMs !== void 0) timer = setTimeout(() => task.fail(/* @__PURE__ */ new Error("Task timed out")), options.timeoutMs);
|
|
712
|
+
return task;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Runs `work` as one task. The returned value finishes the task, and a thrown error fails it.
|
|
716
|
+
* A call to `cancel` or `fail` inside `work` wins over its later return.
|
|
717
|
+
*
|
|
718
|
+
* @example
|
|
719
|
+
* const task = runTask(async ({ signal }) => fetch(url, { signal }).then(response => response.text()))
|
|
720
|
+
* const outcome = await task.done
|
|
721
|
+
*/
|
|
722
|
+
function runTask(work, options = {}) {
|
|
723
|
+
const task = createTask(options);
|
|
724
|
+
if (task.settled) return task;
|
|
725
|
+
(async () => {
|
|
726
|
+
try {
|
|
727
|
+
task.finish(await work(task));
|
|
728
|
+
} catch (cause) {
|
|
729
|
+
task.fail(cause instanceof Error ? cause : new Error("Task failed", { cause }));
|
|
730
|
+
}
|
|
731
|
+
})();
|
|
732
|
+
return task;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Merges outcomes. A failure wins over a cancellation, and a cancellation wins over success.
|
|
736
|
+
*
|
|
737
|
+
* @example
|
|
738
|
+
* worstOutcome([{ status: 'finished', value: undefined }, { status: 'cancelled', reason: 'Closed' }])
|
|
739
|
+
* // => { status: 'cancelled', reason: 'Closed' }
|
|
740
|
+
*/
|
|
741
|
+
function worstOutcome(outcomes) {
|
|
742
|
+
const failed = outcomes.find((outcome) => outcome.status === "failed");
|
|
743
|
+
if (failed) return failed;
|
|
744
|
+
return outcomes.find((outcome) => outcome.status === "cancelled") ?? {
|
|
745
|
+
status: "finished",
|
|
746
|
+
value: void 0
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
//#endregion
|
|
750
|
+
//#region src/watermark.ts
|
|
751
|
+
/**
|
|
752
|
+
* Tracks the newest event time of a stream and marks events that arrive too late.
|
|
753
|
+
*
|
|
754
|
+
* A late event is still an event. The caller decides what to do with it, for example count it or flag it in a view.
|
|
755
|
+
*
|
|
756
|
+
* @example
|
|
757
|
+
* const watermark = new Watermark({ allowedLatenessMs: 2000 })
|
|
758
|
+
* watermark.observe(10_000) // => { late: false }
|
|
759
|
+
* watermark.observe(7_000) // => { late: true }
|
|
760
|
+
*/
|
|
761
|
+
var Watermark = class {
|
|
762
|
+
options;
|
|
763
|
+
newestTime = Number.NEGATIVE_INFINITY;
|
|
764
|
+
constructor(options) {
|
|
765
|
+
this.options = options;
|
|
766
|
+
}
|
|
767
|
+
/** The newest time observed. `-Infinity` before the first event. */
|
|
768
|
+
get newest() {
|
|
769
|
+
return this.newestTime;
|
|
770
|
+
}
|
|
771
|
+
observe(time) {
|
|
772
|
+
const late = time < this.newestTime - this.options.allowedLatenessMs;
|
|
773
|
+
this.newestTime = Math.max(this.newestTime, time);
|
|
774
|
+
return { late };
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
//#endregion
|
|
778
|
+
export { DemandUnion, RetentionBuffer, SequenceTracker, Watermark, createBatcher, createCatchUpFeed, createCooldown, createLatest, createLeaseTable, createPushStream, createQueue, createScope, createTask, readStream, runTask, worstOutcome };
|