@dbx-tools/shared-core 0.1.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/.projen/deps.json +29 -0
- package/.projen/files.json +11 -0
- package/.projen/tasks.json +121 -0
- package/README.md +220 -0
- package/index.ts +26 -0
- package/package.json +43 -0
- package/src/async.ts +209 -0
- package/src/error.ts +178 -0
- package/src/function.ts +91 -0
- package/src/hash.ts +261 -0
- package/src/http.ts +223 -0
- package/src/iterable.ts +790 -0
- package/src/log.ts +380 -0
- package/src/net.ts +535 -0
- package/src/object.ts +165 -0
- package/src/predicate.ts +151 -0
- package/src/string.ts +483 -0
- package/src/token.ts +136 -0
- package/test/tsconfig.json +14 -0
- package/tsconfig.json +40 -0
package/src/error.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error normalization helpers: collapse the ubiquitous
|
|
3
|
+
* `err instanceof Error ? err.message : String(err)` dance into a single
|
|
4
|
+
* call, walk `cause` / `AggregateError` chains, and coerce any thrown
|
|
5
|
+
* value into a real `Error`. Dependency-free and browser-safe.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { tokenizeWithOptions } from "./string";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Normalize any thrown value into an `Error`. Returns `value` unchanged
|
|
12
|
+
* when it already is an `Error`, otherwise wraps its {@link errorMessage}
|
|
13
|
+
* in a fresh `Error`. Use when a consumer needs a real `Error` object
|
|
14
|
+
* (React error state, `reject`, rethrow) rather than just a printable
|
|
15
|
+
* string.
|
|
16
|
+
*/
|
|
17
|
+
export function toError(value: unknown): Error {
|
|
18
|
+
return value instanceof Error ? value : new Error(errorMessage(value));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extract a human-readable message from any thrown value. Returns
|
|
23
|
+
* `value.message` when `value` is an `Error`, otherwise coerces via
|
|
24
|
+
* `String(value)`. Collapses the ubiquitous
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* err instanceof Error ? err.message : String(err)
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* dance into a single helper, useful for log attributes and any other
|
|
31
|
+
* "give me something printable" context.
|
|
32
|
+
*/
|
|
33
|
+
export function errorMessage(value: unknown): string {
|
|
34
|
+
const message = errorMessages(value).next().value;
|
|
35
|
+
return message ?? String(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Yield `message` / `errorCode` strings from every node in the error
|
|
40
|
+
* tree (see {@link errorNodes}). Used by {@link errorMessage} and
|
|
41
|
+
* message predicates elsewhere.
|
|
42
|
+
*/
|
|
43
|
+
export function* errorMessages(value: unknown): Generator<string, void, undefined> {
|
|
44
|
+
for (const node of errorNodes(value)) {
|
|
45
|
+
if (typeof node === "object") {
|
|
46
|
+
for (const key of ["message", "errorCode"]) {
|
|
47
|
+
if (key in node) {
|
|
48
|
+
const value = (node as Record<string, unknown>)[key];
|
|
49
|
+
if (typeof value === "string" && value) {
|
|
50
|
+
yield value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} else if (typeof node === "string" && node) {
|
|
55
|
+
yield node;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Depth-first walk of an error value: the root, then `errors` (e.g.
|
|
62
|
+
* `AggregateError`) and `cause` chains. Cycle-safe via a `seen` set.
|
|
63
|
+
*/
|
|
64
|
+
export function* errorNodes(err: unknown): Generator<NonNullable<unknown>, void, undefined> {
|
|
65
|
+
const seen = new Set<unknown>();
|
|
66
|
+
|
|
67
|
+
function* visit(node: unknown): Generator<NonNullable<unknown>, void, undefined> {
|
|
68
|
+
if (node === undefined || node === null) return;
|
|
69
|
+
if (Array.isArray(node)) {
|
|
70
|
+
for (const child of node) {
|
|
71
|
+
yield* visit(child);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (seen.has(node)) return;
|
|
76
|
+
seen.add(node);
|
|
77
|
+
yield node;
|
|
78
|
+
if (typeof node === "object") {
|
|
79
|
+
for (const key of ["errors", "cause"]) {
|
|
80
|
+
if (key in node) {
|
|
81
|
+
const value = (node as Record<string, unknown>)[key];
|
|
82
|
+
yield* visit(value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
yield* visit(err);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Lazy view over a thrown value for HTTP-status + message classification.
|
|
92
|
+
* Status comes from the last positive `statusCode` / `code` on the error tree;
|
|
93
|
+
* messages/tokens come from every `message` / `errorCode` field (including
|
|
94
|
+
* `cause` and `AggregateError.errors`). Build with {@link errorContext}.
|
|
95
|
+
*/
|
|
96
|
+
export type ErrorContext = ErrorContextImpl;
|
|
97
|
+
|
|
98
|
+
class ErrorContextImpl {
|
|
99
|
+
private _statusCode: number | undefined;
|
|
100
|
+
private _messages: string[] | undefined;
|
|
101
|
+
private _messageTokens: string[] | undefined;
|
|
102
|
+
|
|
103
|
+
constructor(private readonly err: NonNullable<unknown>) {}
|
|
104
|
+
|
|
105
|
+
/** Last positive `statusCode` / `code` found on the error tree, else `undefined` (0 ignored). */
|
|
106
|
+
get statusCode(): number | undefined {
|
|
107
|
+
if (this._statusCode === undefined) {
|
|
108
|
+
outer: for (const node of errorNodes(this.err)) {
|
|
109
|
+
if (typeof node !== "object" || node === null) continue;
|
|
110
|
+
for (const key of ["statusCode", "code"] as const) {
|
|
111
|
+
if (!(key in node)) continue;
|
|
112
|
+
const value = (node as Record<string, unknown>)[key];
|
|
113
|
+
if (typeof value === "number" && value > 99 && value < 600) {
|
|
114
|
+
this._statusCode = value;
|
|
115
|
+
break outer;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (this._statusCode === undefined) {
|
|
120
|
+
this._statusCode = -1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return this._statusCode == -1 ? undefined : this._statusCode;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Every `message` / `errorCode` string in the error tree. */
|
|
127
|
+
get messages(): string[] {
|
|
128
|
+
if (this._messages === undefined) {
|
|
129
|
+
this._messages = [...errorMessages(this.err)];
|
|
130
|
+
}
|
|
131
|
+
return this._messages;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Lowercased tokens from {@link messages}. */
|
|
135
|
+
get messageTokens(): string[] {
|
|
136
|
+
if (this._messageTokens === undefined) {
|
|
137
|
+
this._messageTokens = [...tokenizeWithOptions({ lowerCase: true }, ...this.messages)];
|
|
138
|
+
}
|
|
139
|
+
return this._messageTokens;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** True for any 4xx status or message tokens `not exist` / `not found`. */
|
|
143
|
+
get notAccessible(): boolean {
|
|
144
|
+
if (this.hasStatusCode(4)) return true;
|
|
145
|
+
return this.hasMessage("not", "exist") || this.hasMessage("not", "found");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Match HTTP status. Pass a full code (`404`) or a class (`4` for any 4xx).
|
|
150
|
+
* Extra filters are OR'd. `false` when no status is on the error tree.
|
|
151
|
+
*/
|
|
152
|
+
hasStatusCode(statusCodeFilter: number, ...statusCodeFilters: number[]): boolean {
|
|
153
|
+
const code = this.statusCode;
|
|
154
|
+
if (code) {
|
|
155
|
+
for (const filter of [statusCodeFilter, ...statusCodeFilters]) {
|
|
156
|
+
const match = (filter < 100 ? Math.trunc(code / 100) : code) === filter;
|
|
157
|
+
if (match) return true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* True when every token from the filter phrase(s) appears in
|
|
165
|
+
* {@link messageTokens}. Each argument is tokenized on non-alphanumeric
|
|
166
|
+
* boundaries (e.g. `hasMessage("not", "found")` or `hasMessage("not found")`).
|
|
167
|
+
*/
|
|
168
|
+
hasMessage(messageFilter: string, ...messageFilters: string[]): boolean {
|
|
169
|
+
return [messageFilter, ...messageFilters]
|
|
170
|
+
.flatMap((filter) => Array.from(tokenizeWithOptions({ lowerCase: true }, filter)))
|
|
171
|
+
.every((filterToken) => this.messageTokens.includes(filterToken));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Build an {@link ErrorContext} for status + message checks. `null` / `undefined` become `{}`. */
|
|
176
|
+
export function errorContext(err: unknown): ErrorContext {
|
|
177
|
+
return new ErrorContextImpl(err ?? {});
|
|
178
|
+
}
|
package/src/function.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export interface MemoizeOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Time-to-live in milliseconds. The cached value expires `ttlMs` after
|
|
4
|
+
* it was stored, so the next call past that recomputes; a rejection is
|
|
5
|
+
* also evicted so a later call retries rather than replaying the error.
|
|
6
|
+
* Omitted or `<= 0` means a successful value is cached forever (the default).
|
|
7
|
+
* Errors are never cached - see {@link memoize}.
|
|
8
|
+
*
|
|
9
|
+
* Use for periodically-refreshed data (published IP ranges, feature
|
|
10
|
+
* flags, anything fetched once and reused across requests).
|
|
11
|
+
*/
|
|
12
|
+
ttlMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Run a zero-argument factory once; later calls return the same result.
|
|
17
|
+
* The memoized function mirrors the factory's sync / async nature: a
|
|
18
|
+
* sync factory yields a sync getter (`() => T`), an async / thenable
|
|
19
|
+
* factory yields a promise-returning getter (`() => Promise<T>`) whose
|
|
20
|
+
* concurrent callers share the one in-flight promise until it settles.
|
|
21
|
+
*
|
|
22
|
+
* Errors are never cached: a sync factory that throws propagates the
|
|
23
|
+
* throw, and an async factory that rejects evicts the cached promise, so
|
|
24
|
+
* in both cases the next call retries. Pass `{ ttlMs }` to also expire
|
|
25
|
+
* and recompute a successful value after a window; without a TTL a
|
|
26
|
+
* success is cached forever.
|
|
27
|
+
*
|
|
28
|
+
* For an async factory the TTL window starts when the promise
|
|
29
|
+
* *resolves*, not when it was created - a slow in-flight request never
|
|
30
|
+
* counts as already-expired, and concurrent callers keep sharing the one
|
|
31
|
+
* pending promise until it settles.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* const ranges = functionModule.memoize(fetchIpRanges, { ttlMs: 24 * 60 * 60 * 1000 });
|
|
35
|
+
* await ranges(); // fetches
|
|
36
|
+
* await ranges(); // cached until 24h later
|
|
37
|
+
*/
|
|
38
|
+
export function memoize<T>(
|
|
39
|
+
factory: () => PromiseLike<T>,
|
|
40
|
+
options?: MemoizeOptions,
|
|
41
|
+
): () => Promise<T>;
|
|
42
|
+
export function memoize<T>(factory: () => T, options?: MemoizeOptions): () => T;
|
|
43
|
+
|
|
44
|
+
export function memoize<T>(
|
|
45
|
+
factory: () => T | PromiseLike<T>,
|
|
46
|
+
options?: MemoizeOptions,
|
|
47
|
+
): () => T | Promise<T> {
|
|
48
|
+
const ttlMs = options?.ttlMs ?? 0;
|
|
49
|
+
let cache: { value: T | Promise<T>; expiresAt: number } | undefined;
|
|
50
|
+
return () => {
|
|
51
|
+
if (cache === undefined || (ttlMs > 0 && Date.now() >= cache.expiresAt)) {
|
|
52
|
+
const result = factory();
|
|
53
|
+
if (isThenable(result)) {
|
|
54
|
+
const pending = Promise.resolve(result);
|
|
55
|
+
// `Infinity` keeps the entry unexpired while in flight (so a slow
|
|
56
|
+
// request isn't refetched and concurrent callers share it); the
|
|
57
|
+
// TTL window is stamped from resolution below.
|
|
58
|
+
const entry = { value: pending, expiresAt: Infinity };
|
|
59
|
+
cache = entry;
|
|
60
|
+
void pending.then(
|
|
61
|
+
() => {
|
|
62
|
+
entry.expiresAt = Date.now() + ttlMs;
|
|
63
|
+
},
|
|
64
|
+
// Never cache a rejection: evict so a later call retries.
|
|
65
|
+
() => {
|
|
66
|
+
if (cache === entry) cache = undefined;
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
} else {
|
|
70
|
+
cache = { value: result, expiresAt: Date.now() + ttlMs };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return cache.value;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Duck-type any value with a callable `.then` as a thenable. */
|
|
78
|
+
function isThenable<T>(value: T | PromiseLike<T>): value is PromiseLike<T> {
|
|
79
|
+
if (value !== null) {
|
|
80
|
+
if (value instanceof Promise) {
|
|
81
|
+
return true;
|
|
82
|
+
} else if (
|
|
83
|
+
typeof value === "object" &&
|
|
84
|
+
"then" in value &&
|
|
85
|
+
typeof (value as PromiseLike<T>).then === "function"
|
|
86
|
+
) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short, deterministic non-cryptographic hashing and id minting.
|
|
3
|
+
*
|
|
4
|
+
* {@link fnvHash} / {@link fnvHashWithOptions} produce a stable FNV-1a
|
|
5
|
+
* digest over arbitrary structured input; {@link toBase32} encodes a
|
|
6
|
+
* 32-bit integer compactly; {@link id} mints v4 UUIDs (or short hex
|
|
7
|
+
* slices). All browser-safe - built on `globalThis.crypto`, no
|
|
8
|
+
* `node:crypto` import. **Never** use these for tokens, signatures, or
|
|
9
|
+
* anything an attacker shouldn't be able to forge.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Mint a v4 UUID, or a short hex slice of one when `length` is set.
|
|
14
|
+
*
|
|
15
|
+
* - `id()` returns a full RFC 4122 v4 UUID. Pick this when global
|
|
16
|
+
* uniqueness matters: long-running batches, ids that cross a storage /
|
|
17
|
+
* process boundary, anything that may collide across machines.
|
|
18
|
+
* - `id(length)` returns the first `length` hex chars of a fresh UUID
|
|
19
|
+
* with dashes stripped (e.g. `id(8) -> "a3f1c92b"`). Pick this when the
|
|
20
|
+
* id has to be short / typeable and the scope is bounded - cache keys
|
|
21
|
+
* local to a request, slug suffixes. `length <= 0` throws.
|
|
22
|
+
*
|
|
23
|
+
* Built on `globalThis.crypto.randomUUID()` so the same function works in
|
|
24
|
+
* Node (>= 19) and modern browsers without a polyfill.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* id(); // "123e4567-e89b-12d3-a456-426614174000"
|
|
28
|
+
* id(8); // "a3f1c92b"
|
|
29
|
+
*/
|
|
30
|
+
export function id(length?: number): string {
|
|
31
|
+
if (length !== undefined && length <= 0) {
|
|
32
|
+
throw new Error("Length must be greater than 0");
|
|
33
|
+
}
|
|
34
|
+
const id = globalThis.crypto.randomUUID();
|
|
35
|
+
if (length !== undefined) {
|
|
36
|
+
return id.replace(/-/g, "").slice(0, length);
|
|
37
|
+
}
|
|
38
|
+
return id;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Short, deterministic FNV-1a hash over one or more values. Wraps
|
|
43
|
+
* {@link fnvHashWithOptions} with all defaults: 6-char Crockford-style
|
|
44
|
+
* base-32 output (digits + lowercase, minus `i`/`l`/`o`/`u`).
|
|
45
|
+
* Browser-safe (no `node:crypto`).
|
|
46
|
+
*
|
|
47
|
+
* Accepts any mix of primitives, arrays, plain objects, `Map`s, and
|
|
48
|
+
* `Set`s; nested structures are walked deterministically so the hash is
|
|
49
|
+
* order-stable for objects / maps / sets and order-sensitive for arrays.
|
|
50
|
+
* Cycles are detected and folded into a `circular:` marker.
|
|
51
|
+
*
|
|
52
|
+
* Use for cache keys, slug suffixes, log correlation ids, and other
|
|
53
|
+
* "give me something short and stable" needs - **never** for tokens or
|
|
54
|
+
* signatures. FNV-1a is a non-cryptographic hash.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* fnvHash("databricks-claude-sonnet-4-6"); // "k3p9q7"
|
|
58
|
+
* fnvHash([1, 2, 3]) !== fnvHash([3, 2, 1]);
|
|
59
|
+
*/
|
|
60
|
+
export function fnvHash(...values: unknown[]): string {
|
|
61
|
+
return fnvHashWithOptions({}, ...values);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Configurable counterpart to {@link fnvHash}.
|
|
66
|
+
*
|
|
67
|
+
* Options:
|
|
68
|
+
* - `length` (default `6`): number of base-32 chars to return. Capped
|
|
69
|
+
* at 7 - the underlying digest is 32 bits, which base-32-encodes to
|
|
70
|
+
* at most 7 chars. Output is left-padded with the alphabet's zero
|
|
71
|
+
* character so short digests still hit the requested width.
|
|
72
|
+
* - `alphabet` (default Crockford-style
|
|
73
|
+
* `"0123456789abcdefghjkmnpqrstvwxyz"`): 32 distinct characters used
|
|
74
|
+
* to encode the digest. Throws when not exactly 32 unique chars.
|
|
75
|
+
* - `digest` (default `0x811c9dc5`, the FNV-1a offset basis): the seed
|
|
76
|
+
* the running digest starts from. Useful for namespacing so
|
|
77
|
+
* otherwise-identical inputs hashed under different namespaces never
|
|
78
|
+
* collide, and for chaining hashes across pipeline stages.
|
|
79
|
+
*
|
|
80
|
+
* The hash is **not** stable across changes to the alphabet or `length` -
|
|
81
|
+
* those tune the output, not the digest input.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* fnvHashWithOptions({ length: 4 }, "user@example.com"); // 4 chars
|
|
85
|
+
* fnvHashWithOptions({ digest: nsHash }, key) !== fnvHash(key); // namespaced
|
|
86
|
+
*/
|
|
87
|
+
export function fnvHashWithOptions(
|
|
88
|
+
options: { length?: number; alphabet?: string; digest?: number } = {},
|
|
89
|
+
...values: unknown[]
|
|
90
|
+
): string {
|
|
91
|
+
const { length = 6 } = options;
|
|
92
|
+
|
|
93
|
+
let digest = options.digest ?? 0x811c9dc5;
|
|
94
|
+
|
|
95
|
+
for (const value of hashAttributes(values)) {
|
|
96
|
+
for (let i = 0; i < value.length; i++) {
|
|
97
|
+
digest ^= value.charCodeAt(i);
|
|
98
|
+
digest = Math.imul(digest, 0x01000193);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const alphabet = base32Alphabet(options.alphabet);
|
|
102
|
+
return toBase32(digest, alphabet, true).padStart(7, alphabet[0]).slice(0, Math.min(length, 7));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Walk an arbitrary value as a stream of canonicalized string tokens
|
|
107
|
+
* suitable for feeding into a streaming hash like FNV-1a. Used by
|
|
108
|
+
* {@link fnvHashWithOptions} so structured inputs hash deterministically
|
|
109
|
+
* without a stringification round-trip through `JSON.stringify` (which
|
|
110
|
+
* silently drops `undefined`, has no canonical key order, and can't
|
|
111
|
+
* represent cycles).
|
|
112
|
+
*
|
|
113
|
+
* Canonicalization rules:
|
|
114
|
+
*
|
|
115
|
+
* - `null` / `undefined` collapse to `null:`.
|
|
116
|
+
* - Primitives (`string` / `number` / `boolean`) are tagged with their
|
|
117
|
+
* `typeof` so `"1"` and `1` produce different digests.
|
|
118
|
+
* - Arrays preserve order: `[1,2]` and `[2,1]` hash differently.
|
|
119
|
+
* - Plain objects emit keys in lexical order of each key's own
|
|
120
|
+
* hash-token stream, so `{a:1,b:2}` and `{b:2,a:1}` collapse.
|
|
121
|
+
* - `Map` keys go through the same key-sort path as objects.
|
|
122
|
+
* - `Set`s are sorted by each element's hash-token stream and emit only
|
|
123
|
+
* the elements, so insertion order doesn't leak into the digest.
|
|
124
|
+
* - Cycles emit `circular:` and stop descending.
|
|
125
|
+
* - Anything else falls through to a `${typeof}:${JSON.stringify}`
|
|
126
|
+
* token.
|
|
127
|
+
*/
|
|
128
|
+
function* hashAttributes(input: any, seen?: WeakSet<object>): Generator<string> {
|
|
129
|
+
if (input === null || input === undefined) {
|
|
130
|
+
yield "null:";
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const inputType = typeof input;
|
|
135
|
+
if (inputType === "string" || inputType === "number" || inputType === "boolean") {
|
|
136
|
+
yield `${inputType}:`;
|
|
137
|
+
yield input.toString();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
seen ??= new WeakSet<object>();
|
|
141
|
+
|
|
142
|
+
if (inputType === "object") {
|
|
143
|
+
if (seen.has(input)) {
|
|
144
|
+
yield "circular:";
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
seen.add(input);
|
|
148
|
+
try {
|
|
149
|
+
if (Array.isArray(input)) {
|
|
150
|
+
yield "[";
|
|
151
|
+
for (const item of input) {
|
|
152
|
+
yield* hashAttributes(item, seen);
|
|
153
|
+
yield ",";
|
|
154
|
+
}
|
|
155
|
+
yield "]";
|
|
156
|
+
return;
|
|
157
|
+
} else {
|
|
158
|
+
const hashAttributeKeys = (keys: Array<unknown>) => {
|
|
159
|
+
return keys
|
|
160
|
+
.map((key) => {
|
|
161
|
+
const keyHashAttributes = [...hashAttributes(key, seen)];
|
|
162
|
+
return {
|
|
163
|
+
key,
|
|
164
|
+
keyHashAttributes,
|
|
165
|
+
sortKey: keyHashAttributes.join("\0"),
|
|
166
|
+
};
|
|
167
|
+
})
|
|
168
|
+
.sort((a, b) => a.sortKey.localeCompare(b.sortKey));
|
|
169
|
+
};
|
|
170
|
+
if (input instanceof Set) {
|
|
171
|
+
yield "[";
|
|
172
|
+
for (const hashAttributeKey of hashAttributeKeys(Array.from(input))) {
|
|
173
|
+
yield* hashAttributeKey.keyHashAttributes;
|
|
174
|
+
yield ",";
|
|
175
|
+
}
|
|
176
|
+
yield "]";
|
|
177
|
+
return;
|
|
178
|
+
} else {
|
|
179
|
+
yield "{";
|
|
180
|
+
const keys = input instanceof Map ? Array.from(input.keys()) : Object.keys(input);
|
|
181
|
+
for (const hashAttributeKey of hashAttributeKeys(keys)) {
|
|
182
|
+
const value =
|
|
183
|
+
input instanceof Map
|
|
184
|
+
? input.get(hashAttributeKey.key)
|
|
185
|
+
: input[hashAttributeKey.key as keyof typeof input];
|
|
186
|
+
yield* hashAttributeKey.keyHashAttributes;
|
|
187
|
+
yield ":";
|
|
188
|
+
yield* hashAttributes(value, seen);
|
|
189
|
+
yield ",";
|
|
190
|
+
}
|
|
191
|
+
yield "}";
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
seen.delete(input);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
yield `${inputType}:${JSON.stringify(input)}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Default Crockford-style base-32 alphabet: digits `0-9` then lowercase
|
|
204
|
+
* letters with `i`, `l`, `o`, `u` removed. Output is safe to drop into
|
|
205
|
+
* URLs, filenames, and `[A-Za-z0-9_-]`-bound marker captures.
|
|
206
|
+
*/
|
|
207
|
+
const BASE32_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolve a caller-supplied alphabet against the default. Returns the
|
|
211
|
+
* default when the caller passed nothing; otherwise validates the
|
|
212
|
+
* override is exactly 32 unique chars. Throws on bad alphabets so callers
|
|
213
|
+
* fail fast instead of producing silently-degraded encodings.
|
|
214
|
+
*/
|
|
215
|
+
function base32Alphabet(alphabet?: string): string {
|
|
216
|
+
if (alphabet === undefined) return BASE32_ALPHABET;
|
|
217
|
+
else if (new Set(alphabet).size !== 32) {
|
|
218
|
+
throw new Error("Base32 alphabet must contain 32 unique characters");
|
|
219
|
+
}
|
|
220
|
+
return alphabet;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Encode a 32-bit unsigned integer as base-32 using the default
|
|
225
|
+
* Crockford-style alphabet (or `alphabet` when provided). The encoding
|
|
226
|
+
* has **no** zero-padding by default - `toBase32(0)` returns the
|
|
227
|
+
* alphabet's zero character, otherwise the result is the minimal number
|
|
228
|
+
* of digits that fits the value. Pad / truncate at the call site when you
|
|
229
|
+
* need a fixed width.
|
|
230
|
+
*
|
|
231
|
+
* `disableAlphabetValidation` skips the unique-32-char check for hot
|
|
232
|
+
* paths that have already validated the alphabet. The function still
|
|
233
|
+
* requires `alphabet.length === 32` either way.
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* toBase32(0); // "0"
|
|
237
|
+
* toBase32(31); // "z"
|
|
238
|
+
* toBase32(0xdeadbe); // "6vmtw"
|
|
239
|
+
*/
|
|
240
|
+
export function toBase32(
|
|
241
|
+
value: number,
|
|
242
|
+
alphabet?: string,
|
|
243
|
+
disableAlphabetValidation?: boolean,
|
|
244
|
+
): string {
|
|
245
|
+
if (!disableAlphabetValidation) {
|
|
246
|
+
alphabet = base32Alphabet(alphabet);
|
|
247
|
+
}
|
|
248
|
+
if (alphabet!.length !== 32) {
|
|
249
|
+
throw new Error(`Base32 alphabet must contain exactly 32 characters, got ${alphabet!.length}`);
|
|
250
|
+
}
|
|
251
|
+
value >>>= 0;
|
|
252
|
+
if (value === 0) {
|
|
253
|
+
return alphabet![0]!;
|
|
254
|
+
}
|
|
255
|
+
let result = "";
|
|
256
|
+
while (value > 0) {
|
|
257
|
+
result = alphabet![value & 31] + result;
|
|
258
|
+
value >>>= 5;
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
}
|