@dbx-tools/shared-core 0.3.28 → 0.3.30
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/README.md +54 -5
- package/index.ts +1 -0
- package/package.json +2 -2
- package/src/async.ts +28 -0
- package/src/hash.ts +27 -3
- package/src/json.ts +63 -0
- package/src/string.ts +52 -1
- package/src/token.ts +15 -13
- package/test/async.test.ts +44 -0
- package/test/hash.test.ts +55 -0
- package/test/json.test.ts +42 -0
- package/test/string.test.ts +43 -0
package/README.md
CHANGED
|
@@ -7,7 +7,18 @@ browsers, workers, CLIs, and shared schema packages. Modules are exported as
|
|
|
7
7
|
namespaces so call sites stay explicit:
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
async,
|
|
12
|
+
brand,
|
|
13
|
+
error,
|
|
14
|
+
hash,
|
|
15
|
+
http,
|
|
16
|
+
json,
|
|
17
|
+
log,
|
|
18
|
+
net,
|
|
19
|
+
object,
|
|
20
|
+
string,
|
|
21
|
+
} from "@dbx-tools/shared-core";
|
|
11
22
|
```
|
|
12
23
|
|
|
13
24
|
Node-only helpers live in [`@dbx-tools/core`](../../node/core). AppKit and
|
|
@@ -21,8 +32,10 @@ Key features:
|
|
|
21
32
|
status/message extraction.
|
|
22
33
|
- Deterministic non-cryptographic hashes and short ids for cache keys, slugs,
|
|
23
34
|
and generated names.
|
|
24
|
-
-
|
|
25
|
-
|
|
35
|
+
- Non-throwing JSON parsing for untrusted input, with record narrowing so parsed
|
|
36
|
+
data is not cast blindly.
|
|
37
|
+
- String normalization helpers for slugs, identifiers, unique labels, human
|
|
38
|
+
labels, config lists, and prompt descriptions.
|
|
26
39
|
- Object/iterable, predicate, HTTP, cookie, network, token, memoization, and
|
|
27
40
|
logging helpers that avoid Node-only dependencies.
|
|
28
41
|
- Namespace exports that make utility call sites explicit without creating a
|
|
@@ -91,6 +104,26 @@ const suffix = hash.fnvHashWithOptions({ length: 6 }, longName);
|
|
|
91
104
|
These hashes are deterministic and non-cryptographic. Use them for cache keys,
|
|
92
105
|
slug suffixes, and trace-stable identifiers, not secrets or signatures.
|
|
93
106
|
|
|
107
|
+
## Parsing Untrusted JSON
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
const body = json.parseRecord(await readRequestText(req)) ?? {};
|
|
111
|
+
const chunk = json.parse<StreamChunk>(sseData);
|
|
112
|
+
if (!chunk) continue;
|
|
113
|
+
|
|
114
|
+
const settings = json.parse<Settings>(process.env.SETTINGS, DEFAULT_SETTINGS);
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`json.parse()` returns the fallback (or `undefined`) instead of throwing, so a
|
|
118
|
+
malformed request body, env var, config file, subprocess stdout, or third-party
|
|
119
|
+
response does not need its own `try`/`catch`. `json.parseRecord()` additionally
|
|
120
|
+
narrows to `Record<string, unknown>`, which a bare `JSON.parse(...) as Record<...>`
|
|
121
|
+
does not: it rejects a parsed `null`, array, or scalar rather than letting it
|
|
122
|
+
through as an object.
|
|
123
|
+
|
|
124
|
+
Reach for `JSON.parse` directly only when a throw is the correct outcome, such as
|
|
125
|
+
reading a file this repo generated itself.
|
|
126
|
+
|
|
94
127
|
## Strings And Descriptions
|
|
95
128
|
|
|
96
129
|
```ts
|
|
@@ -107,6 +140,21 @@ const description = string.toDescription([
|
|
|
107
140
|
ids, schema ids, and generated labels consistent. `toDescription()` turns nested
|
|
108
141
|
description data into prompt/tool text without hand-concatenating paragraphs.
|
|
109
142
|
|
|
143
|
+
Three helpers exist so call sites stop re-implementing them:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
const label = string.toLabel("web_search"); // "Web Search"
|
|
147
|
+
const name = string.capitalize(segment); // no charAt(0).toUpperCase() idiom
|
|
148
|
+
const host = string.trimToEmpty(parsed.host); // unknown JSON field -> string
|
|
149
|
+
const allowed = string.parseList(process.env.ALLOWED_URLS);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`toLabel()` and `capitalize()` are the humanizers for identifiers and path
|
|
153
|
+
segments. `trimToNull()` / `trimToEmpty()` / `firstNonEmpty()` coerce an unknown
|
|
154
|
+
field off parsed JSON. `parseList()` normalizes a config value that may arrive as
|
|
155
|
+
an array or as one comma/whitespace-separated env string, de-duplicating as it
|
|
156
|
+
goes.
|
|
157
|
+
|
|
110
158
|
## Objects And Predicates
|
|
111
159
|
|
|
112
160
|
```ts
|
|
@@ -216,8 +264,9 @@ without paying formatting cost when disabled.
|
|
|
216
264
|
- `async` - polling, sleep, and abort-signal wiring.
|
|
217
265
|
- `error` - unknown-error normalization and HTTP-ish error context.
|
|
218
266
|
- `hash` - ids, FNV hashes, and base32 encoding.
|
|
219
|
-
- `
|
|
220
|
-
|
|
267
|
+
- `json` - non-throwing `parse()` and record-narrowing `parseRecord()`.
|
|
268
|
+
- `string` - tokenization, slugs, identifiers, human labels, string coercion,
|
|
269
|
+
config lists, descriptions, pluralization, and HTML escaping.
|
|
221
270
|
- `object` - record checks, boolean coercion, deep equality, shape types, and
|
|
222
271
|
lazy sequence transforms + collection helpers.
|
|
223
272
|
- `predicate` - composable boolean/type predicates.
|
package/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * as error from "./src/error";
|
|
|
8
8
|
export * as functionModule from "./src/function";
|
|
9
9
|
export * as hash from "./src/hash";
|
|
10
10
|
export * as http from "./src/http";
|
|
11
|
+
export * as json from "./src/json";
|
|
11
12
|
export * as log from "./src/log";
|
|
12
13
|
export * as net from "./src/net";
|
|
13
14
|
export * as object from "./src/object";
|
package/package.json
CHANGED
|
@@ -15,14 +15,14 @@
|
|
|
15
15
|
"consola": "^3.4.2"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"zod": "
|
|
18
|
+
"zod": "4.3.6"
|
|
19
19
|
},
|
|
20
20
|
"main": "index.ts",
|
|
21
21
|
"license": "UNLICENSED",
|
|
22
22
|
"publishConfig": {
|
|
23
23
|
"access": "public"
|
|
24
24
|
},
|
|
25
|
-
"version": "0.3.
|
|
25
|
+
"version": "0.3.30",
|
|
26
26
|
"types": "index.ts",
|
|
27
27
|
"type": "module",
|
|
28
28
|
"exports": {
|
package/src/async.ts
CHANGED
|
@@ -183,6 +183,34 @@ export function tieAbortSignal(child: AbortController, parent?: AbortSignal): vo
|
|
|
183
183
|
});
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Combine several optional cancellation sources into one signal that aborts
|
|
188
|
+
* as soon as any of them does.
|
|
189
|
+
*
|
|
190
|
+
* The usual caller is an operation that has to honor more than one source at
|
|
191
|
+
* once - a caller's own signal (a closed connection, an agent run being
|
|
192
|
+
* cancelled) plus one derived from a timeout - where the awaited I/O accepts
|
|
193
|
+
* only a single signal. Absent sources are ignored, and a lone signal is
|
|
194
|
+
* returned as-is so the common path allocates nothing. Returns `undefined`
|
|
195
|
+
* only when every input is absent, which callers can pass straight through
|
|
196
|
+
* to an optional `signal` parameter.
|
|
197
|
+
*
|
|
198
|
+
* Aborting an input aborts the result (carrying that input's `reason`);
|
|
199
|
+
* nothing propagates back the other way.
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* await fetch(url, { signal: combineAbortSignals(req.signal, timeout.signal) });
|
|
203
|
+
*/
|
|
204
|
+
export function combineAbortSignals(
|
|
205
|
+
...signals: (AbortSignal | undefined)[]
|
|
206
|
+
): AbortSignal | undefined {
|
|
207
|
+
const present = signals.filter((signal): signal is AbortSignal => signal !== undefined);
|
|
208
|
+
if (present.length <= 1) return present[0];
|
|
209
|
+
const combined = new AbortController();
|
|
210
|
+
for (const signal of present) tieAbortSignal(combined, signal);
|
|
211
|
+
return combined.signal;
|
|
212
|
+
}
|
|
213
|
+
|
|
186
214
|
/**
|
|
187
215
|
* Promisified `setTimeout` that wakes up early (and rejects with
|
|
188
216
|
* `signal.reason`) when `signal` aborts mid-wait. Short-circuits to a
|
package/src/hash.ts
CHANGED
|
@@ -22,8 +22,13 @@
|
|
|
22
22
|
* id has to be short / typeable and the scope is bounded - cache keys
|
|
23
23
|
* local to a request, slug suffixes. `length <= 0` throws.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* Prefers `crypto.randomUUID()`, which covers Node (>= 19) and a browser on
|
|
26
|
+
* a secure origin. Browsers gate `randomUUID` behind a secure context, so a
|
|
27
|
+
* page served over plain http (a LAN dev host) has `crypto` but not that
|
|
28
|
+
* method; this package is browser-safe and its callers mint ids on the
|
|
29
|
+
* render path, so it degrades instead of throwing: `getRandomValues` when
|
|
30
|
+
* present, else `Math.random`. Every branch returns a well-formed v4 UUID -
|
|
31
|
+
* only the entropy source differs.
|
|
27
32
|
*
|
|
28
33
|
* @example
|
|
29
34
|
* id(); // "123e4567-e89b-12d3-a456-426614174000"
|
|
@@ -33,13 +38,32 @@ export function id(length?: number): string {
|
|
|
33
38
|
if (length !== undefined && length <= 0) {
|
|
34
39
|
throw new Error("Length must be greater than 0");
|
|
35
40
|
}
|
|
36
|
-
const id =
|
|
41
|
+
const id = uuidV4();
|
|
37
42
|
if (length !== undefined) {
|
|
38
43
|
return id.replace(/-/g, "").slice(0, length);
|
|
39
44
|
}
|
|
40
45
|
return id;
|
|
41
46
|
}
|
|
42
47
|
|
|
48
|
+
/** A v4 UUID from the strongest randomness source this runtime offers. */
|
|
49
|
+
function uuidV4(): string {
|
|
50
|
+
const webCrypto = globalThis.crypto as Crypto | undefined;
|
|
51
|
+
if (typeof webCrypto?.randomUUID === "function") return webCrypto.randomUUID();
|
|
52
|
+
|
|
53
|
+
const bytes = new Uint8Array(16);
|
|
54
|
+
if (typeof webCrypto?.getRandomValues === "function") {
|
|
55
|
+
webCrypto.getRandomValues(bytes);
|
|
56
|
+
} else {
|
|
57
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
58
|
+
}
|
|
59
|
+
// Stamp the version (4) and variant (10xx) fields RFC 4122 requires.
|
|
60
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
|
61
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
|
62
|
+
|
|
63
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
64
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
43
67
|
/**
|
|
44
68
|
* Short, deterministic FNV-1a hash over one or more values. Wraps
|
|
45
69
|
* {@link fnvHashWithOptions} with all defaults: 6-char Crockford-style
|
package/src/json.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe JSON helpers for the parse-untrusted-text case.
|
|
3
|
+
*
|
|
4
|
+
* `JSON.parse` throws on malformed input and returns `any`, so almost every
|
|
5
|
+
* caller in this repo wrapped it in the same try/catch and then re-narrowed the
|
|
6
|
+
* result by hand. {@link parse} collapses the try/catch (a bad document yields
|
|
7
|
+
* the fallback instead of throwing) and {@link parseRecord} adds the narrowing
|
|
8
|
+
* that untrusted JSON almost always needs before it can be indexed.
|
|
9
|
+
*
|
|
10
|
+
* Use these when the document comes from OUTSIDE the process - a request body,
|
|
11
|
+
* an env var, a config file, a subprocess's stdout, a third-party API. Keep
|
|
12
|
+
* bare `JSON.parse` where a throw is the correct outcome (an internally
|
|
13
|
+
* produced document that must be well-formed).
|
|
14
|
+
*
|
|
15
|
+
* These deliberately do NOT validate shape beyond "is it a record". Reach for
|
|
16
|
+
* a zod schema when the payload has a contract; these only get you from `string`
|
|
17
|
+
* to a safely typed starting point.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { isRecord } from "./object";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse JSON text, returning `fallback` (default `undefined`) instead of
|
|
26
|
+
* throwing when `text` is absent or malformed.
|
|
27
|
+
*
|
|
28
|
+
* The return type is caller-asserted, exactly like `JSON.parse` - this only
|
|
29
|
+
* removes the try/catch, it does not validate. Prefer {@link parseRecord} or a
|
|
30
|
+
* schema when the result is indexed.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* const config = json.parse<Config>(await readFile(path, "utf8"));
|
|
34
|
+
* const tags = json.parse(process.env.TAGS, []);
|
|
35
|
+
*/
|
|
36
|
+
export function parse<T = unknown>(text: unknown, fallback: T): T;
|
|
37
|
+
export function parse<T = unknown>(text: unknown): T | undefined;
|
|
38
|
+
export function parse<T = unknown>(text: unknown, fallback?: T): T | undefined {
|
|
39
|
+
if (typeof text !== "string" || text.trim().length === 0) return fallback;
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(text) as T;
|
|
42
|
+
} catch {
|
|
43
|
+
return fallback;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse JSON text that is expected to be an object, returning `undefined` when
|
|
49
|
+
* it is absent, malformed, or parses to a non-record (an array, a bare string,
|
|
50
|
+
* `null`, ...).
|
|
51
|
+
*
|
|
52
|
+
* This is the common case for manifests, env-var config blobs, and untyped API
|
|
53
|
+
* payloads: {@link parse} followed by {@link isRecord}, so the result can be
|
|
54
|
+
* indexed without an `as Record<string, unknown>` cast.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* const manifest = json.parseRecord(await readFile("package.json", "utf8")) ?? {};
|
|
58
|
+
* const name = manifest.name;
|
|
59
|
+
*/
|
|
60
|
+
export function parseRecord(text: unknown): Record<string, unknown> | undefined {
|
|
61
|
+
const parsed = parse(text);
|
|
62
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
63
|
+
}
|
package/src/string.ts
CHANGED
|
@@ -115,7 +115,7 @@ export function* tokenizeWithOptions(
|
|
|
115
115
|
for (const tokenMatch of stringValue.matchAll(regexp)) {
|
|
116
116
|
let token = tokenMatch[0]!;
|
|
117
117
|
if (opts.lowerCase) token = token.toLowerCase();
|
|
118
|
-
if (opts.capitalize) token =
|
|
118
|
+
if (opts.capitalize) token = capitalize(token);
|
|
119
119
|
if (!token) continue;
|
|
120
120
|
for (const override of TOKENIZE_OVERRIDES) {
|
|
121
121
|
token = override(token, opts);
|
|
@@ -205,6 +205,57 @@ export function trimToNull(value: unknown): string | null {
|
|
|
205
205
|
return trimmed ? trimmed : null;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
// Config lists arrive either already split (an array) or as one env-var string
|
|
209
|
+
// with entries separated by commas and/or whitespace.
|
|
210
|
+
const LIST_SEPARATOR_REGEXP = /[\s,]+/;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Normalize a config list that may arrive as an array or as a single
|
|
214
|
+
* comma/whitespace-separated string: split, apply `transform`, drop empties,
|
|
215
|
+
* and de-duplicate (first occurrence wins).
|
|
216
|
+
*
|
|
217
|
+
* This is the shape every allow-list / fallback-order setting in this repo
|
|
218
|
+
* takes, because the same value can come from typed config (`string[]`) or from
|
|
219
|
+
* an environment variable (`"a, b c"`). Pass `transform` to normalize entries
|
|
220
|
+
* as they are read; it defaults to trimming.
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* parseList("docs.example.com, *.databricks.com");
|
|
224
|
+
* parseList(process.env.MODEL_FALLBACKS);
|
|
225
|
+
* parseList(raw, normalizeUrlPattern);
|
|
226
|
+
*/
|
|
227
|
+
export function parseList(
|
|
228
|
+
raw: string | readonly string[] | undefined | null,
|
|
229
|
+
transform: (entry: string) => string = (entry) => entry.trim(),
|
|
230
|
+
): string[] {
|
|
231
|
+
const entries =
|
|
232
|
+
typeof raw === "string" ? raw.split(LIST_SEPARATOR_REGEXP) : Array.isArray(raw) ? raw : [];
|
|
233
|
+
const out: string[] = [];
|
|
234
|
+
const seen = new Set<string>();
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
const normalized = transform(entry);
|
|
237
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
238
|
+
seen.add(normalized);
|
|
239
|
+
out.push(normalized);
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* {@link trimToNull} with an empty-string miss instead of `null`, for callers
|
|
246
|
+
* that build a string unconditionally and treat "absent" as "".
|
|
247
|
+
*
|
|
248
|
+
* Reading loosely-typed JSON is the motivating case: a field that should be a
|
|
249
|
+
* string may be missing or the wrong type, and the caller wants `""` rather
|
|
250
|
+
* than a null check at every access.
|
|
251
|
+
*
|
|
252
|
+
* @example
|
|
253
|
+
* const title = trimToEmpty(record.title); // always a string
|
|
254
|
+
*/
|
|
255
|
+
export function trimToEmpty(value: unknown): string {
|
|
256
|
+
return trimToNull(value) ?? "";
|
|
257
|
+
}
|
|
258
|
+
|
|
208
259
|
/**
|
|
209
260
|
* Trim the first usable string out of `value`. Returns `null` when
|
|
210
261
|
* `value` is `undefined`, `null`, an empty string, or an array whose
|
package/src/token.ts
CHANGED
|
@@ -8,19 +8,27 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { forEachHeaderValue, type HeaderLike } from "./http";
|
|
11
|
+
import * as json from "./json";
|
|
11
12
|
import { isLevelEnabled, logger } from "./log";
|
|
12
13
|
|
|
13
14
|
const BEARER_PREFIX_REGEX = /^bearer\s+/i;
|
|
14
15
|
const SPLIT_REGEX = /\s+|\s*,\s*/;
|
|
15
16
|
const log = logger("shared/token");
|
|
16
17
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Decode a JWT segment (base64url with standard base64 padding), or
|
|
20
|
+
* `undefined` when the segment is not valid base64.
|
|
21
|
+
*/
|
|
22
|
+
function decodeJwtSegment(segment: string): string | undefined {
|
|
19
23
|
const normalized = segment.replace(/-/g, "+").replace(/_/g, "/");
|
|
20
24
|
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
try {
|
|
26
|
+
const binary = atob(padded);
|
|
27
|
+
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
|
28
|
+
return new TextDecoder().decode(bytes);
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
/**
|
|
@@ -55,14 +63,8 @@ export function getAccessTokenPayload(
|
|
|
55
63
|
}
|
|
56
64
|
const parts = input.split(".", 4);
|
|
57
65
|
if (parts.length === 2 || parts.length === 3) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (typeof payload === "object" && payload !== null) {
|
|
61
|
-
accessTokenPayload = payload as Record<string, unknown>;
|
|
62
|
-
}
|
|
63
|
-
} catch {
|
|
64
|
-
// Malformed JWT payload; fall through to empty object.
|
|
65
|
-
}
|
|
66
|
+
// A malformed segment yields undefined; fall through to empty object.
|
|
67
|
+
accessTokenPayload = json.parseRecord(decodeJwtSegment(parts[1]!));
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { async } from "../index";
|
|
4
|
+
|
|
5
|
+
describe("async.combineAbortSignals", () => {
|
|
6
|
+
it("returns undefined when every source is absent", () => {
|
|
7
|
+
assert.equal(async.combineAbortSignals(), undefined);
|
|
8
|
+
assert.equal(async.combineAbortSignals(undefined, undefined), undefined);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("passes a lone signal through without wrapping it", () => {
|
|
12
|
+
const { signal } = new AbortController();
|
|
13
|
+
assert.equal(async.combineAbortSignals(signal), signal);
|
|
14
|
+
assert.equal(async.combineAbortSignals(undefined, signal, undefined), signal);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("aborts when any source aborts, carrying that source's reason", () => {
|
|
18
|
+
for (const index of [0, 1, 2]) {
|
|
19
|
+
const controllers = [new AbortController(), new AbortController(), new AbortController()];
|
|
20
|
+
const combined = async.combineAbortSignals(...controllers.map((c) => c.signal));
|
|
21
|
+
assert.equal(combined?.aborted, false);
|
|
22
|
+
controllers[index]!.abort(new Error(`source ${index}`));
|
|
23
|
+
assert.equal(combined?.aborted, true);
|
|
24
|
+
assert.equal((combined?.reason as Error).message, `source ${index}`);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("is already aborted when a source aborted before combining", () => {
|
|
29
|
+
const early = new AbortController();
|
|
30
|
+
early.abort(new Error("gone"));
|
|
31
|
+
const combined = async.combineAbortSignals(early.signal, new AbortController().signal);
|
|
32
|
+
assert.equal(combined?.aborted, true);
|
|
33
|
+
assert.equal((combined?.reason as Error).message, "gone");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not propagate back to the sources", () => {
|
|
37
|
+
const first = new AbortController();
|
|
38
|
+
const second = new AbortController();
|
|
39
|
+
const combined = async.combineAbortSignals(first.signal, second.signal);
|
|
40
|
+
first.abort();
|
|
41
|
+
assert.equal(combined?.aborted, true);
|
|
42
|
+
assert.equal(second.signal.aborted, false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import { hash } from "../index";
|
|
4
|
+
|
|
5
|
+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
6
|
+
|
|
7
|
+
const original = globalThis.crypto;
|
|
8
|
+
|
|
9
|
+
const withCrypto = (value: unknown, body: () => void) => {
|
|
10
|
+
Object.defineProperty(globalThis, "crypto", { value, configurable: true, writable: true });
|
|
11
|
+
try {
|
|
12
|
+
body();
|
|
13
|
+
} finally {
|
|
14
|
+
Object.defineProperty(globalThis, "crypto", {
|
|
15
|
+
value: original,
|
|
16
|
+
configurable: true,
|
|
17
|
+
writable: true,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("hash.id", () => {
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
assert.equal(globalThis.crypto, original);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("mints a v4 UUID from crypto.randomUUID when available", () => {
|
|
28
|
+
assert.match(hash.id(), UUID_V4);
|
|
29
|
+
assert.notEqual(hash.id(), hash.id());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns a short hex slice when a length is given", () => {
|
|
33
|
+
assert.match(hash.id(8), /^[0-9a-f]{8}$/);
|
|
34
|
+
assert.equal(hash.id(1).length, 1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects a non-positive length", () => {
|
|
38
|
+
assert.throws(() => hash.id(0), /greater than 0/);
|
|
39
|
+
assert.throws(() => hash.id(-1), /greater than 0/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("falls back to getRandomValues where randomUUID is absent (plain-http browser)", () => {
|
|
43
|
+
withCrypto({ getRandomValues: original.getRandomValues.bind(original) }, () => {
|
|
44
|
+
assert.match(hash.id(), UUID_V4);
|
|
45
|
+
assert.notEqual(hash.id(), hash.id());
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("falls back to Math.random where crypto is absent entirely", () => {
|
|
50
|
+
withCrypto(undefined, () => {
|
|
51
|
+
assert.match(hash.id(), UUID_V4);
|
|
52
|
+
assert.match(hash.id(12), /^[0-9a-f]{12}$/);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { json } from "../index";
|
|
4
|
+
|
|
5
|
+
describe("json.parse", () => {
|
|
6
|
+
it("parses a well-formed document", () => {
|
|
7
|
+
assert.deepEqual(json.parse('{"a":1}'), { a: 1 });
|
|
8
|
+
assert.deepEqual(json.parse("[1,2]"), [1, 2]);
|
|
9
|
+
assert.equal(json.parse('"text"'), "text");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns the fallback for malformed JSON instead of throwing", () => {
|
|
13
|
+
assert.equal(json.parse("{not json"), undefined);
|
|
14
|
+
assert.deepEqual(json.parse("{not json", { ok: false }), { ok: false });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("treats absent / blank / non-string input as a miss", () => {
|
|
18
|
+
assert.equal(json.parse(undefined), undefined);
|
|
19
|
+
assert.equal(json.parse(null), undefined);
|
|
20
|
+
assert.equal(json.parse(" "), undefined);
|
|
21
|
+
assert.equal(json.parse(42), undefined);
|
|
22
|
+
assert.deepEqual(json.parse(undefined, []), []);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("json.parseRecord", () => {
|
|
27
|
+
it("returns the object for a record document", () => {
|
|
28
|
+
assert.deepEqual(json.parseRecord('{"name":"pkg"}'), { name: "pkg" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects documents that parse to a non-record", () => {
|
|
32
|
+
assert.equal(json.parseRecord("[1,2]"), undefined);
|
|
33
|
+
assert.equal(json.parseRecord('"text"'), undefined);
|
|
34
|
+
assert.equal(json.parseRecord("null"), undefined);
|
|
35
|
+
assert.equal(json.parseRecord("7"), undefined);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns undefined for malformed or absent input", () => {
|
|
39
|
+
assert.equal(json.parseRecord("{oops"), undefined);
|
|
40
|
+
assert.equal(json.parseRecord(undefined), undefined);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { string } from "../index";
|
|
4
|
+
|
|
5
|
+
describe("string.trimToEmpty", () => {
|
|
6
|
+
it("trims a string", () => {
|
|
7
|
+
assert.equal(string.trimToEmpty(" hi "), "hi");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("yields an empty string for a non-string or blank value", () => {
|
|
11
|
+
assert.equal(string.trimToEmpty(undefined), "");
|
|
12
|
+
assert.equal(string.trimToEmpty(null), "");
|
|
13
|
+
assert.equal(string.trimToEmpty(42), "");
|
|
14
|
+
assert.equal(string.trimToEmpty(" "), "");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("string.parseList", () => {
|
|
19
|
+
it("splits a comma / whitespace separated string", () => {
|
|
20
|
+
assert.deepEqual(string.parseList("a, b c,d"), ["a", "b", "c", "d"]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("accepts an array and trims its entries", () => {
|
|
24
|
+
assert.deepEqual(string.parseList([" a ", "b"]), ["a", "b"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("drops empties and de-duplicates, first occurrence winning", () => {
|
|
28
|
+
assert.deepEqual(string.parseList("a,,b, a ,b"), ["a", "b"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns an empty list for absent input", () => {
|
|
32
|
+
assert.deepEqual(string.parseList(undefined), []);
|
|
33
|
+
assert.deepEqual(string.parseList(null), []);
|
|
34
|
+
assert.deepEqual(string.parseList(""), []);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("applies a transform and de-duplicates on the transformed value", () => {
|
|
38
|
+
assert.deepEqual(
|
|
39
|
+
string.parseList("A, a, B", (entry) => entry.trim().toLowerCase()),
|
|
40
|
+
["a", "b"],
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
});
|