@f5xc-salesdemos/pi-utils 14.0.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/package.json +60 -0
- package/src/abortable.ts +85 -0
- package/src/async.ts +50 -0
- package/src/cli.ts +432 -0
- package/src/color.ts +204 -0
- package/src/dirs.ts +425 -0
- package/src/env.ts +84 -0
- package/src/format.ts +106 -0
- package/src/frontmatter.ts +118 -0
- package/src/fs-error.ts +56 -0
- package/src/glob.ts +189 -0
- package/src/hook-fetch.ts +30 -0
- package/src/index.ts +47 -0
- package/src/json.ts +10 -0
- package/src/logger.ts +204 -0
- package/src/mermaid-ascii.ts +31 -0
- package/src/mime.ts +159 -0
- package/src/peek-file.ts +114 -0
- package/src/postmortem.ts +197 -0
- package/src/procmgr.ts +326 -0
- package/src/prompt.ts +401 -0
- package/src/ptree.ts +386 -0
- package/src/ring.ts +169 -0
- package/src/snowflake.ts +136 -0
- package/src/stream.ts +316 -0
- package/src/temp.ts +77 -0
- package/src/type-guards.ts +11 -0
- package/src/which.ts +230 -0
package/src/ptree.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process tree management utilities for Bun subprocesses.
|
|
3
|
+
*
|
|
4
|
+
* - Track managed child processes for cleanup on shutdown (postmortem).
|
|
5
|
+
* - Drain stdout/stderr to avoid subprocess pipe deadlocks.
|
|
6
|
+
* - Cross-platform tree kill for process groups (Windows taskkill, Unix -pid).
|
|
7
|
+
* - Convenience helpers: captureText / execText, AbortSignal, timeouts.
|
|
8
|
+
*/
|
|
9
|
+
import type { Spawn, Subprocess } from "bun";
|
|
10
|
+
import { terminate } from "./procmgr";
|
|
11
|
+
|
|
12
|
+
type InMask = "pipe" | "ignore" | Buffer | Uint8Array | null;
|
|
13
|
+
|
|
14
|
+
/** A Bun subprocess with stdout/stderr always piped (stdin may vary). */
|
|
15
|
+
type PipedSubprocess<In extends InMask = InMask> = Subprocess<In, "pipe", "pipe">;
|
|
16
|
+
|
|
17
|
+
// ── Exceptions ───────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Base for all exceptions representing child process nonzero exit, killed, or
|
|
21
|
+
* cancellation.
|
|
22
|
+
*/
|
|
23
|
+
export abstract class Exception extends Error {
|
|
24
|
+
constructor(
|
|
25
|
+
message: string,
|
|
26
|
+
public readonly exitCode: number,
|
|
27
|
+
public readonly stderr: string,
|
|
28
|
+
) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = this.constructor.name;
|
|
31
|
+
}
|
|
32
|
+
abstract readonly aborted: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Exception for nonzero exit codes (not cancellation). */
|
|
36
|
+
export class NonZeroExitError extends Exception {
|
|
37
|
+
static readonly MAX_TRACE = 32 * 1024;
|
|
38
|
+
|
|
39
|
+
constructor(exitCode: number, stderr: string) {
|
|
40
|
+
super(`Process exited with code ${exitCode}:\n${stderr}`, exitCode, stderr);
|
|
41
|
+
}
|
|
42
|
+
get aborted() {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Exception for explicit process abortion (via signal). */
|
|
48
|
+
export class AbortError extends Exception {
|
|
49
|
+
constructor(
|
|
50
|
+
public readonly reason: unknown,
|
|
51
|
+
stderr: string,
|
|
52
|
+
) {
|
|
53
|
+
const msg = reason instanceof Error ? reason.message : String(reason ?? "aborted");
|
|
54
|
+
super(`Operation cancelled: ${msg}`, -1, stderr);
|
|
55
|
+
}
|
|
56
|
+
get aborted() {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Exception for process timeout. */
|
|
62
|
+
export class TimeoutError extends AbortError {
|
|
63
|
+
constructor(timeout: number, stderr: string) {
|
|
64
|
+
super(new Error(`Timed out after ${Math.round(timeout / 1000)}s`), stderr);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Wait / Exec types ────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/** Options for waiting for process exit and capturing output. */
|
|
71
|
+
export interface WaitOptions {
|
|
72
|
+
allowNonZero?: boolean;
|
|
73
|
+
allowAbort?: boolean;
|
|
74
|
+
stderr?: "full" | "buffer";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Result from wait and exec. */
|
|
78
|
+
export interface ExecResult {
|
|
79
|
+
stdout: string;
|
|
80
|
+
stderr: string;
|
|
81
|
+
exitCode: number | null;
|
|
82
|
+
ok: boolean;
|
|
83
|
+
exitError?: Exception;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── ChildProcess ─────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* ChildProcess wraps a managed subprocess, capturing stderr tail, providing
|
|
90
|
+
* cross-platform kill/detach logic plus AbortSignal integration.
|
|
91
|
+
*
|
|
92
|
+
* Stdout is exposed directly from the underlying Bun subprocess; consumers
|
|
93
|
+
* must read it (via text(), wait(), etc.) to prevent pipe deadlock.
|
|
94
|
+
* Stderr is eagerly drained into an internal buffer.
|
|
95
|
+
*/
|
|
96
|
+
export class ChildProcess<In extends InMask = InMask> {
|
|
97
|
+
#nothrow = false;
|
|
98
|
+
#stderrTail = "";
|
|
99
|
+
#stderrChunks: Uint8Array[] = [];
|
|
100
|
+
#exitReason?: Exception;
|
|
101
|
+
#exitReasonPending?: Exception;
|
|
102
|
+
#stderrDone: Promise<void>;
|
|
103
|
+
#exited: Promise<number>;
|
|
104
|
+
#stderrStream?: ReadableStream<Uint8Array>;
|
|
105
|
+
|
|
106
|
+
constructor(
|
|
107
|
+
readonly proc: PipedSubprocess<In>,
|
|
108
|
+
readonly exposeStderr: boolean,
|
|
109
|
+
) {
|
|
110
|
+
// Eagerly drain stderr into a truncated tail string + raw chunks.
|
|
111
|
+
const dec = new TextDecoder();
|
|
112
|
+
const trim = () => {
|
|
113
|
+
if (this.#stderrTail.length > NonZeroExitError.MAX_TRACE)
|
|
114
|
+
this.#stderrTail = this.#stderrTail.slice(-NonZeroExitError.MAX_TRACE);
|
|
115
|
+
};
|
|
116
|
+
let stderrStream = proc.stderr;
|
|
117
|
+
if (exposeStderr) {
|
|
118
|
+
const [teeStream, drainStream] = stderrStream.tee();
|
|
119
|
+
this.#stderrStream = teeStream;
|
|
120
|
+
stderrStream = drainStream;
|
|
121
|
+
}
|
|
122
|
+
this.#stderrDone = (async () => {
|
|
123
|
+
try {
|
|
124
|
+
for await (const chunk of stderrStream) {
|
|
125
|
+
this.#stderrChunks.push(chunk);
|
|
126
|
+
this.#stderrTail += dec.decode(chunk, { stream: true });
|
|
127
|
+
trim();
|
|
128
|
+
}
|
|
129
|
+
} catch {}
|
|
130
|
+
this.#stderrTail += dec.decode();
|
|
131
|
+
trim();
|
|
132
|
+
})();
|
|
133
|
+
|
|
134
|
+
// Normalize Bun's exited promise into our exitReason / exitedCleanly model.
|
|
135
|
+
const { promise, resolve, reject } = Promise.withResolvers<number>();
|
|
136
|
+
this.#exited = promise;
|
|
137
|
+
|
|
138
|
+
proc.exited
|
|
139
|
+
.catch(() => null)
|
|
140
|
+
.then(async exitCode => {
|
|
141
|
+
if (this.#exitReasonPending) {
|
|
142
|
+
this.#exitReason = this.#exitReasonPending;
|
|
143
|
+
reject(this.#exitReasonPending);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (exitCode === 0) {
|
|
147
|
+
resolve(0);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await this.#stderrDone;
|
|
152
|
+
|
|
153
|
+
if (exitCode !== null) {
|
|
154
|
+
this.#exitReason = new NonZeroExitError(exitCode, this.#stderrTail);
|
|
155
|
+
resolve(exitCode);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const ex = this.proc.killed
|
|
160
|
+
? new AbortError(new Error("process killed"), this.#stderrTail)
|
|
161
|
+
: new NonZeroExitError(-1, this.#stderrTail);
|
|
162
|
+
this.#exitReason = ex;
|
|
163
|
+
reject(ex);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Properties ───────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
get pid() {
|
|
170
|
+
return this.proc.pid;
|
|
171
|
+
}
|
|
172
|
+
get exited() {
|
|
173
|
+
return this.#exited;
|
|
174
|
+
}
|
|
175
|
+
get exitCode() {
|
|
176
|
+
return this.proc.exitCode;
|
|
177
|
+
}
|
|
178
|
+
get exitReason() {
|
|
179
|
+
return this.#exitReason;
|
|
180
|
+
}
|
|
181
|
+
get killed() {
|
|
182
|
+
return this.proc.killed;
|
|
183
|
+
}
|
|
184
|
+
get stdin(): Bun.SpawnOptions.WritableToIO<In> {
|
|
185
|
+
return this.proc.stdin;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Raw stdout stream. Must be consumed to prevent pipe deadlock. */
|
|
189
|
+
get stdout() {
|
|
190
|
+
return this.proc.stdout;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Optional stderr stream (only when requested in spawn options). */
|
|
194
|
+
get stderr() {
|
|
195
|
+
return this.#stderrStream;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get exitedCleanly(): Promise<number> {
|
|
199
|
+
if (this.#nothrow) return this.#exited;
|
|
200
|
+
return this.#exited.then(code => {
|
|
201
|
+
if (code !== 0) throw new NonZeroExitError(code, this.#stderrTail);
|
|
202
|
+
return code;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Returns the truncated stderr tail (last 32KB). */
|
|
207
|
+
peekStderr() {
|
|
208
|
+
return this.#stderrTail;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
nothrow(): this {
|
|
212
|
+
this.#nothrow = true;
|
|
213
|
+
return this;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
kill(reason?: Exception) {
|
|
217
|
+
if (reason && !this.#exitReasonPending) this.#exitReasonPending = reason;
|
|
218
|
+
if (!this.proc.killed) void terminate({ target: this.proc });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Output helpers ───────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
async text(): Promise<string> {
|
|
224
|
+
const p = new Response(this.stdout).text();
|
|
225
|
+
if (this.#nothrow) return p;
|
|
226
|
+
const [text] = await Promise.all([p, this.exitedCleanly]);
|
|
227
|
+
return text;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async blob(): Promise<Blob> {
|
|
231
|
+
const p = new Response(this.stdout).blob();
|
|
232
|
+
if (this.#nothrow) return p;
|
|
233
|
+
const [blob] = await Promise.all([p, this.exitedCleanly]);
|
|
234
|
+
return blob;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async json(): Promise<unknown> {
|
|
238
|
+
return new Response(this.stdout).json();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async arrayBuffer(): Promise<ArrayBuffer> {
|
|
242
|
+
return new Response(this.stdout).arrayBuffer();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async bytes(): Promise<Uint8Array> {
|
|
246
|
+
return new Response(this.stdout).bytes();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Wait ─────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
async wait(opts?: WaitOptions): Promise<ExecResult> {
|
|
252
|
+
const { allowNonZero = false, allowAbort = false, stderr: stderrMode = "buffer" } = opts ?? {};
|
|
253
|
+
|
|
254
|
+
const stdoutP = new Response(this.stdout).text();
|
|
255
|
+
const stderrP =
|
|
256
|
+
stderrMode === "full"
|
|
257
|
+
? this.#stderrDone.then(() => new TextDecoder().decode(Buffer.concat(this.#stderrChunks)))
|
|
258
|
+
: this.#stderrDone.then(() => this.#stderrTail);
|
|
259
|
+
|
|
260
|
+
const [stdout, stderr] = await Promise.all([stdoutP, stderrP]);
|
|
261
|
+
|
|
262
|
+
let exitError: Exception | undefined;
|
|
263
|
+
try {
|
|
264
|
+
await this.#exited;
|
|
265
|
+
} catch (err) {
|
|
266
|
+
if (err instanceof Exception) exitError = err;
|
|
267
|
+
else throw err;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!exitError) exitError = this.exitReason;
|
|
271
|
+
if (!exitError && this.exitCode !== null && this.exitCode !== 0) {
|
|
272
|
+
exitError = new NonZeroExitError(this.exitCode, this.#stderrTail);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const exitCode = this.exitCode ?? (exitError && !exitError.aborted ? exitError.exitCode : null);
|
|
276
|
+
const ok = exitCode === 0;
|
|
277
|
+
|
|
278
|
+
if (exitError) {
|
|
279
|
+
if ((exitError.aborted && !allowAbort) || (!exitError.aborted && !allowNonZero)) throw exitError;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { stdout, stderr, exitCode, ok, exitError };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Signal / timeout ─────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
attachSignal(signal: AbortSignal): void {
|
|
288
|
+
const onAbort = () => this.kill(new AbortError(signal.reason, "<cancelled>"));
|
|
289
|
+
if (signal.aborted) return void onAbort();
|
|
290
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
291
|
+
this.#exited.catch(() => {}).finally(() => signal.removeEventListener("abort", onAbort));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
attachTimeout(ms: number): void {
|
|
295
|
+
if (ms <= 0 || this.proc.killed) return;
|
|
296
|
+
Promise.race([
|
|
297
|
+
Bun.sleep(ms).then(() => true),
|
|
298
|
+
this.proc.exited.then(
|
|
299
|
+
() => false,
|
|
300
|
+
() => false,
|
|
301
|
+
),
|
|
302
|
+
]).then(timedOut => {
|
|
303
|
+
if (timedOut) this.kill(new TimeoutError(ms, this.#stderrTail));
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
[Symbol.dispose](): void {
|
|
308
|
+
if (this.proc.exitCode !== null) return;
|
|
309
|
+
this.kill(new AbortError("process disposed", this.#stderrTail));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Spawn / exec ─────────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
/** Options for child spawn. Always pipes stdout/stderr. */
|
|
316
|
+
type ChildSpawnOptions<In extends InMask = InMask> = Omit<
|
|
317
|
+
Spawn.SpawnOptions<In, "pipe", "pipe">,
|
|
318
|
+
"stdout" | "stderr" | "detached"
|
|
319
|
+
> & {
|
|
320
|
+
signal?: AbortSignal;
|
|
321
|
+
detached?: boolean;
|
|
322
|
+
stderr?: "full" | null;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
/** Spawn a child process with piped stdout/stderr. */
|
|
326
|
+
export function spawn<In extends InMask = InMask>(cmd: string[], opts?: ChildSpawnOptions<In>): ChildProcess<In> {
|
|
327
|
+
const { timeout = -1, signal, stderr, ...rest } = opts ?? {};
|
|
328
|
+
const child = Bun.spawn(cmd, {
|
|
329
|
+
stdin: "ignore",
|
|
330
|
+
stdout: "pipe",
|
|
331
|
+
stderr: "pipe",
|
|
332
|
+
windowsHide: true,
|
|
333
|
+
...rest,
|
|
334
|
+
});
|
|
335
|
+
const cp = new ChildProcess(child, stderr === "full");
|
|
336
|
+
if (signal) cp.attachSignal(signal);
|
|
337
|
+
if (timeout > 0) cp.attachTimeout(timeout);
|
|
338
|
+
return cp;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Options for exec. */
|
|
342
|
+
export interface ExecOptions extends Omit<ChildSpawnOptions, "stderr" | "stdin">, WaitOptions {
|
|
343
|
+
input?: string | Buffer | Uint8Array;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Spawn, wait, and return captured output. */
|
|
347
|
+
export async function exec(cmd: string[], opts?: ExecOptions): Promise<ExecResult> {
|
|
348
|
+
const { input, stderr, allowAbort, allowNonZero, ...spawnOpts } = opts ?? {};
|
|
349
|
+
const stdin = typeof input === "string" ? Buffer.from(input) : input;
|
|
350
|
+
const resolved: ChildSpawnOptions = stdin === undefined ? spawnOpts : { ...spawnOpts, stdin };
|
|
351
|
+
using child = spawn(cmd, resolved);
|
|
352
|
+
return await child.wait({ stderr, allowAbort, allowNonZero });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── Signal combinators ───────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
type SignalValue = AbortSignal | number | null | undefined;
|
|
358
|
+
|
|
359
|
+
/** Combine AbortSignals and timeout values into a single signal. */
|
|
360
|
+
export function combineSignals(...signals: SignalValue[]): AbortSignal | undefined {
|
|
361
|
+
let timeout: number | undefined;
|
|
362
|
+
|
|
363
|
+
let n = 0;
|
|
364
|
+
for (let i = 0; i < signals.length; i++) {
|
|
365
|
+
const s = signals[i];
|
|
366
|
+
if (s instanceof AbortSignal) {
|
|
367
|
+
if (s.aborted) return s;
|
|
368
|
+
if (i !== n) signals[n] = s;
|
|
369
|
+
n++;
|
|
370
|
+
} else if (typeof s === "number" && s > 0) {
|
|
371
|
+
timeout = timeout === undefined ? s : Math.min(timeout, s);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (timeout !== undefined) {
|
|
375
|
+
signals[n] = AbortSignal.timeout(timeout);
|
|
376
|
+
n++;
|
|
377
|
+
}
|
|
378
|
+
switch (n) {
|
|
379
|
+
case 0:
|
|
380
|
+
return undefined;
|
|
381
|
+
case 1:
|
|
382
|
+
return signals[0] as AbortSignal;
|
|
383
|
+
default:
|
|
384
|
+
return AbortSignal.any(signals.slice(0, n) as AbortSignal[]);
|
|
385
|
+
}
|
|
386
|
+
}
|
package/src/ring.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fixed-capacity circular buffer that supports efficient push/pop/shift/unshift operations.
|
|
3
|
+
* When the buffer is full, adding new items overwrites the oldest items (FIFO behavior).
|
|
4
|
+
*
|
|
5
|
+
* @template T The type of elements stored in the buffer.
|
|
6
|
+
*/
|
|
7
|
+
export class RingBuffer<T> {
|
|
8
|
+
#buf: (T | undefined)[];
|
|
9
|
+
#head = 0;
|
|
10
|
+
#size = 0;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates a new ring buffer with the specified capacity.
|
|
14
|
+
*
|
|
15
|
+
* @param capacity - The maximum number of elements the buffer can hold. Must be positive.
|
|
16
|
+
*/
|
|
17
|
+
constructor(public readonly capacity: number) {
|
|
18
|
+
this.#buf = new Array(capacity);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The number of elements currently in the buffer.
|
|
23
|
+
*/
|
|
24
|
+
get length(): number {
|
|
25
|
+
return this.#size;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether the buffer is at full capacity.
|
|
30
|
+
*/
|
|
31
|
+
get isFull(): boolean {
|
|
32
|
+
return this.#size === this.capacity;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether the buffer is empty (contains no elements).
|
|
37
|
+
*/
|
|
38
|
+
get isEmpty(): boolean {
|
|
39
|
+
return this.#size === 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Adds an item to the end of the buffer.
|
|
44
|
+
* If the buffer is full, the oldest item is overwritten and returned.
|
|
45
|
+
*
|
|
46
|
+
* @param item - The item to add.
|
|
47
|
+
* @returns The overwritten item if the buffer was full, otherwise `undefined`.
|
|
48
|
+
*/
|
|
49
|
+
push(item: T): T | undefined {
|
|
50
|
+
const idx = (this.#head + this.#size) % this.capacity;
|
|
51
|
+
const overwritten = this.#size === this.capacity ? this.#buf[idx] : undefined;
|
|
52
|
+
this.#buf[idx] = item;
|
|
53
|
+
if (this.#size === this.capacity) {
|
|
54
|
+
this.#head = (this.#head + 1) % this.capacity;
|
|
55
|
+
} else {
|
|
56
|
+
this.#size++;
|
|
57
|
+
}
|
|
58
|
+
return overwritten;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Removes and returns the first (oldest) item from the buffer.
|
|
63
|
+
*
|
|
64
|
+
* @returns The removed item, or `undefined` if the buffer is empty.
|
|
65
|
+
*/
|
|
66
|
+
shift(): T | undefined {
|
|
67
|
+
if (this.#size === 0) return undefined;
|
|
68
|
+
const item = this.#buf[this.#head];
|
|
69
|
+
this.#buf[this.#head] = undefined;
|
|
70
|
+
this.#head = (this.#head + 1) % this.capacity;
|
|
71
|
+
this.#size--;
|
|
72
|
+
return item;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Removes and returns the last (newest) item from the buffer.
|
|
77
|
+
*
|
|
78
|
+
* @returns The removed item, or `undefined` if the buffer is empty.
|
|
79
|
+
*/
|
|
80
|
+
pop(): T | undefined {
|
|
81
|
+
if (this.#size === 0) return undefined;
|
|
82
|
+
const idx = (this.#head + this.#size - 1) % this.capacity;
|
|
83
|
+
const item = this.#buf[idx];
|
|
84
|
+
this.#buf[idx] = undefined;
|
|
85
|
+
this.#size--;
|
|
86
|
+
return item;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Adds an item to the beginning of the buffer.
|
|
91
|
+
* If the buffer is full, the newest item is overwritten and returned.
|
|
92
|
+
*
|
|
93
|
+
* @param item - The item to add.
|
|
94
|
+
* @returns The overwritten item if the buffer was full, otherwise `undefined`.
|
|
95
|
+
*/
|
|
96
|
+
unshift(item: T): T | undefined {
|
|
97
|
+
this.#head = (this.#head - 1 + this.capacity) % this.capacity;
|
|
98
|
+
const overwritten = this.#size === this.capacity ? this.#buf[this.#head] : undefined;
|
|
99
|
+
this.#buf[this.#head] = item;
|
|
100
|
+
if (this.#size < this.capacity) this.#size++;
|
|
101
|
+
return overwritten;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Returns the element at the specified index without removing it.
|
|
106
|
+
* Supports negative indices (e.g., `-1` for the last element).
|
|
107
|
+
*
|
|
108
|
+
* @param index - The zero-based index, or negative index from the end.
|
|
109
|
+
* @returns The element at the index, or `undefined` if the index is out of bounds.
|
|
110
|
+
*/
|
|
111
|
+
at(index: number): T | undefined {
|
|
112
|
+
if (index < 0) index += this.#size;
|
|
113
|
+
if (index < 0 || index >= this.#size) return undefined;
|
|
114
|
+
return this.#buf[(this.#head + index) % this.capacity];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Returns the first (oldest) element without removing it.
|
|
119
|
+
*
|
|
120
|
+
* @returns The first element, or `undefined` if the buffer is empty.
|
|
121
|
+
*/
|
|
122
|
+
peek(): T | undefined {
|
|
123
|
+
return this.at(0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Returns the last (newest) element without removing it.
|
|
128
|
+
*
|
|
129
|
+
* @returns The last element, or `undefined` if the buffer is empty.
|
|
130
|
+
*/
|
|
131
|
+
peekBack(): T | undefined {
|
|
132
|
+
return this.at(this.#size - 1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Removes all elements from the buffer, resetting it to an empty state.
|
|
137
|
+
*/
|
|
138
|
+
clear(): void {
|
|
139
|
+
this.#buf.fill(undefined, 0, this.capacity);
|
|
140
|
+
this.#head = 0;
|
|
141
|
+
this.#size = 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Returns an iterator that yields elements in logical order (oldest to newest).
|
|
146
|
+
* Allows the buffer to be used with `for...of` loops and spread syntax.
|
|
147
|
+
*
|
|
148
|
+
* @yields Elements in FIFO order.
|
|
149
|
+
*/
|
|
150
|
+
*[Symbol.iterator](): Iterator<T> {
|
|
151
|
+
for (let i = 0; i < this.#size; i++) {
|
|
152
|
+
yield this.#buf[(this.#head + i) % this.capacity] as T;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Creates a new array containing all elements in logical order (oldest to newest).
|
|
158
|
+
*
|
|
159
|
+
* @returns A new array with all buffer elements.
|
|
160
|
+
*/
|
|
161
|
+
toArray(): T[] {
|
|
162
|
+
if (this.#head + this.#size <= this.capacity) {
|
|
163
|
+
return this.#buf.slice(this.#head, this.#head + this.#size) as T[];
|
|
164
|
+
}
|
|
165
|
+
const tail = this.#buf.slice(this.#head, this.capacity);
|
|
166
|
+
const head = this.#buf.slice(0, (this.#head + this.#size) % this.capacity);
|
|
167
|
+
return tail.concat(head) as T[];
|
|
168
|
+
}
|
|
169
|
+
}
|
package/src/snowflake.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// 16-bit hex lookup table (65536 entries) for fast conversion
|
|
2
|
+
const HEX4 = Array.from({ length: 65536 }, (_, i) => i.toString(16).padStart(4, "0"));
|
|
3
|
+
|
|
4
|
+
function randu32() {
|
|
5
|
+
return crypto.getRandomValues(new Uint32Array(1))[0];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const EPOCH = 1420070400000;
|
|
9
|
+
const MAX_SEQ = 0x3fffff;
|
|
10
|
+
|
|
11
|
+
// Snowflake as a hex string (16 chars, zero-padded).
|
|
12
|
+
//
|
|
13
|
+
// Since this is not distributed (no machine ID needed), we use an extended
|
|
14
|
+
// 22-bit sequence instead of the standard 10-bit machine ID + 12-bit sequence.
|
|
15
|
+
//
|
|
16
|
+
type Snowflake = string & { readonly __brand: unique symbol };
|
|
17
|
+
|
|
18
|
+
namespace Snowflake {
|
|
19
|
+
// Hex string validation pattern (16 lowercase hex chars).
|
|
20
|
+
//
|
|
21
|
+
export const PATTERN = /^[0-9a-f]{16}$/;
|
|
22
|
+
|
|
23
|
+
// Epoch timestamp.
|
|
24
|
+
//
|
|
25
|
+
export const EPOCH_TIMESTAMP = EPOCH;
|
|
26
|
+
|
|
27
|
+
// Maximum sequence number.
|
|
28
|
+
//
|
|
29
|
+
export const MAX_SEQUENCE = MAX_SEQ;
|
|
30
|
+
|
|
31
|
+
// Parses a hex string or bigint to bigint.
|
|
32
|
+
//
|
|
33
|
+
function toBigInt(value: Snowflake): bigint {
|
|
34
|
+
const hi = Number.parseInt(value.substring(0, 8), 16);
|
|
35
|
+
const lo = Number.parseInt(value.substring(8, 16), 16);
|
|
36
|
+
return (BigInt(hi) << 32n) | BigInt(lo);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Formats a sequence and timestamp into a snowflake hex string.
|
|
40
|
+
//
|
|
41
|
+
export function formatParts(dt: number, seq: number): Snowflake {
|
|
42
|
+
// Split dt into hi/lo to avoid exceeding Number.MAX_SAFE_INTEGER.
|
|
43
|
+
// dt is ~39 bits; dt<<22 would be ~61 bits, so we split at bit 10:
|
|
44
|
+
// lo32 = (dtLo << 22) | seq (10+22 = 32 bits, no overlap)
|
|
45
|
+
// hi32 = dtHi (~29 bits)
|
|
46
|
+
const dtLo = dt % 1024;
|
|
47
|
+
const hi = (dt - dtLo) / 1024; // dt >>> 10
|
|
48
|
+
const lo = ((dtLo << 22) | seq) >>> 0;
|
|
49
|
+
const hi1 = (hi >>> 16) & 0xffff;
|
|
50
|
+
const hi2 = hi & 0xffff;
|
|
51
|
+
const lo1 = (lo >>> 16) & 0xffff;
|
|
52
|
+
const lo2 = lo & 0xffff;
|
|
53
|
+
return `${HEX4[hi1]}${HEX4[hi2]}${HEX4[lo1]}${HEX4[lo2]}` as Snowflake;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Snowflake generator type.
|
|
57
|
+
//
|
|
58
|
+
export class Source {
|
|
59
|
+
#seq = 0;
|
|
60
|
+
constructor(sequence: number = randu32() & MAX_SEQ) {
|
|
61
|
+
this.#seq = sequence & MAX_SEQ;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Sequence number.
|
|
65
|
+
//
|
|
66
|
+
get sequence() {
|
|
67
|
+
return this.#seq & MAX_SEQ;
|
|
68
|
+
}
|
|
69
|
+
set sequence(v: number) {
|
|
70
|
+
this.#seq = v & MAX_SEQ;
|
|
71
|
+
}
|
|
72
|
+
reset() {
|
|
73
|
+
this.#seq = 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Generates the next value as a hex string.
|
|
77
|
+
//
|
|
78
|
+
generate(timestamp: number): Snowflake {
|
|
79
|
+
const seq = (this.#seq + 1) & MAX_SEQ;
|
|
80
|
+
const dt = timestamp - EPOCH;
|
|
81
|
+
this.#seq = seq;
|
|
82
|
+
return formatParts(dt, seq);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Gets the next snowflake given the timestamp.
|
|
87
|
+
//
|
|
88
|
+
const defaultSource = new Source();
|
|
89
|
+
export function next(timestamp = Date.now()): Snowflake {
|
|
90
|
+
return defaultSource.generate(timestamp);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Validates a snowflake hex string.
|
|
94
|
+
//
|
|
95
|
+
export function valid(value: string): value is Snowflake {
|
|
96
|
+
return value.length === 16 && PATTERN.test(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Returns the upper/lower boundaries for the given timestamp.
|
|
100
|
+
//
|
|
101
|
+
export function lowerbound(timelike: Date | number | Snowflake): Snowflake {
|
|
102
|
+
switch (typeof timelike) {
|
|
103
|
+
case "object": // Date
|
|
104
|
+
return formatParts(timelike.getTime() - EPOCH, 0);
|
|
105
|
+
case "number":
|
|
106
|
+
return formatParts(timelike - EPOCH, 0);
|
|
107
|
+
case "string": // Snowflake hex string
|
|
108
|
+
return timelike;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function upperbound(timelike: Date | number | Snowflake): Snowflake {
|
|
112
|
+
switch (typeof timelike) {
|
|
113
|
+
case "object": // Date
|
|
114
|
+
return formatParts(timelike.getTime() - EPOCH, MAX_SEQ);
|
|
115
|
+
case "number":
|
|
116
|
+
return formatParts(timelike - EPOCH, MAX_SEQ);
|
|
117
|
+
case "string": // Snowflake hex string
|
|
118
|
+
return timelike;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Returns the individual bits given the snowflake.
|
|
123
|
+
//
|
|
124
|
+
export function getSequence(value: Snowflake) {
|
|
125
|
+
return Number.parseInt(value.substring(8, 16), 16) & MAX_SEQ;
|
|
126
|
+
}
|
|
127
|
+
export function getTimestamp(value: Snowflake) {
|
|
128
|
+
const n = toBigInt(value) >> 22n;
|
|
129
|
+
return Number(n + BigInt(EPOCH));
|
|
130
|
+
}
|
|
131
|
+
export function getDate(value: Snowflake) {
|
|
132
|
+
return new Date(getTimestamp(value));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export { Snowflake };
|