@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/src/net.ts ADDED
@@ -0,0 +1,535 @@
1
+ /**
2
+ * Browser-safe networking helpers built around {@link urlBuilder}: a
3
+ * tolerant `URL` coercion + chainable builder that gracefully handles
4
+ * partial inputs (bare hosts, path-only strings, `{ url }` wrappers),
5
+ * a small IPv4 / IPv6 address + CIDR toolkit (parsing and membership
6
+ * lookups), and email-address parsing ({@link parseEmails} /
7
+ * {@link isEmail}) that normalizes the CSV / single-string / array
8
+ * inputs email recipient and allow-list fields accept into a clean
9
+ * `string[]`. No node-only imports, so this module is the canonical
10
+ * home for anything URL-, IP-, or email-shaped that also has to run in
11
+ * a Vite / Webpack / esbuild client bundle. Node-only network helpers
12
+ * (e.g. DNS resolution) belong in a node-tagged package, not here.
13
+ */
14
+
15
+ import type { NonFunctionKeys } from "./object";
16
+
17
+ const LOCAL_HOST_URL = new URL("http://localhost");
18
+ const URL_SCHEME_DEFAULT = "https";
19
+ const URL_SCHEME_PREFIX = /^([A-Za-z][A-Za-z0-9+.-]*:\/\/)/;
20
+ const URL_PATH_SEGMENT_TRIM = /^\/+|\/+$/g;
21
+ const URL_SCHEME_SEPARATOR = "://";
22
+
23
+ /** Total bit width of an address of each {@link IpVersion}. */
24
+ const IP_BITS: Readonly<Record<IpVersion, number>> = { 4: 32, 6: 128 };
25
+ const IPV4_OCTET = /^\d{1,3}$/;
26
+ const IPV6_HEXTET = /^[0-9a-fA-F]{1,4}$/;
27
+
28
+ /** Delimiters between addresses in a free-text / CSV email list. */
29
+ const EMAIL_LIST_SEPARATOR = /[\s,;]+/;
30
+ /** Pragmatic `local@domain.tld` shape check (not full RFC 5322). */
31
+ const EMAIL_ADDRESS = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
32
+
33
+ // ────────────────────────────────────────────────────────────────
34
+ // Types
35
+ // ────────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Anything {@link urlBuilder} (and {@link pathMatch}) know how to coerce
39
+ * into a URL:
40
+ *
41
+ * - A string: a host, a full URL, or a path / query / hash fragment.
42
+ * - A WHATWG `URL` instance.
43
+ * - Any object with a `url` field (e.g. a fetch `Request`, a Databricks
44
+ * `WorkspaceClient` config).
45
+ */
46
+ export type UrlLike = string | URL | { url: string };
47
+
48
+ /** IP protocol family: `4` for IPv4, `6` for IPv6. */
49
+ export type IpVersion = 4 | 6;
50
+
51
+ /**
52
+ * A parsed IP address. `value` is the address as an unsigned integer
53
+ * (a 32-bit range for v4, 128-bit for v6) held in a `bigint` so both
54
+ * families share one comparison / masking path. Produce one with
55
+ * {@link parseIp}.
56
+ */
57
+ export interface ParsedIp {
58
+ version: IpVersion;
59
+ /** The address as an unsigned integer (`bigint` for uniform v4/v6 math). */
60
+ value: bigint;
61
+ }
62
+
63
+ /**
64
+ * A parsed CIDR block (`10.0.0.0/8`, `2001:db8::/32`). `base` is the
65
+ * network address with all host bits cleared, so membership is a
66
+ * single masked compare (see {@link ipInCidr}). Produce one with
67
+ * {@link parseCidr}. Carries the normalized `cidr` string for logging
68
+ * and to key back to caller-side metadata.
69
+ */
70
+ export interface Cidr {
71
+ version: IpVersion;
72
+ /** Network address (host bits zeroed) as an unsigned `bigint`. */
73
+ base: bigint;
74
+ /** Prefix length in bits (the number after the `/`). */
75
+ prefix: number;
76
+ /** Normalized `<address>/<prefix>` string. */
77
+ cidr: string;
78
+ }
79
+
80
+ /** Settable, non-method `URL` properties - the keys {@link UrlBuilder.with} accepts. */
81
+ type UrlPropertyKey = NonFunctionKeys<UrlBuilderImpl>;
82
+
83
+ /**
84
+ * A `URL` subclass with chainable, copy-on-write helpers. Every mutating
85
+ * method returns a fresh builder rather than editing in place, so a base
86
+ * builder can be safely reused. Construct one via {@link urlBuilder}.
87
+ */
88
+ class UrlBuilderImpl extends URL {
89
+ constructor(url: URL) {
90
+ super(url);
91
+ }
92
+
93
+ /** The scheme without the trailing colon (`https`, not `https:`). */
94
+ get scheme(): string {
95
+ return this.protocol.slice(0, -1);
96
+ }
97
+
98
+ set scheme(value: string) {
99
+ this.protocol = value + ":";
100
+ }
101
+
102
+ /**
103
+ * Return a copy with a single `URL` property (`pathname`, `search`,
104
+ * `hostname`, `scheme`, ...) set to `value`, leaving this builder
105
+ * untouched.
106
+ */
107
+ with<K extends UrlPropertyKey>(key: K, value: UrlBuilderImpl[K]): UrlBuilder {
108
+ const next = new UrlBuilderImpl(this);
109
+ (next as any)[key] = value;
110
+ return new UrlBuilderImpl(next);
111
+ }
112
+
113
+ /**
114
+ * Return a copy with `pathSegments` appended to the current pathname.
115
+ * Segments may be strings or string arrays; each is trimmed of
116
+ * boundary slashes and blanks are dropped before joining with `/`.
117
+ */
118
+ withPathAppend(...pathSegments: (string | string[])[]): UrlBuilder {
119
+ return this.withPathReplace(this.pathname, ...pathSegments);
120
+ }
121
+
122
+ /**
123
+ * Return a copy whose pathname is `pathSegments` joined with `/`,
124
+ * replacing any existing path. Segments may be strings or string
125
+ * arrays; each is trimmed of boundary slashes and blanks are dropped.
126
+ */
127
+ withPathReplace(...pathSegments: (string | string[])[]): UrlBuilder {
128
+ const pathnameParts: string[] = [...pathSegments].flatMap((p) =>
129
+ Array.isArray(p) ? p : [p],
130
+ );
131
+ const pathname = pathnameParts
132
+ .map((p) => p.replace(URL_PATH_SEGMENT_TRIM, ""))
133
+ .filter(Boolean)
134
+ .join("/");
135
+ return this.with("pathname", "/" + pathname);
136
+ }
137
+
138
+ /**
139
+ * Test whether this URL's pathname is `path` or lives beneath it,
140
+ * matching on segment boundaries so `/api` matches `/api` and
141
+ * `/api/cool` but not `/apicool`. A missing leading slash on `path`
142
+ * is tolerated; query and hash are ignored (they aren't part of
143
+ * `pathname`). Matching is exact otherwise - trailing slashes are
144
+ * not normalized and `/` matches only the root.
145
+ */
146
+ pathMatches(path: string): boolean {
147
+ const pathname = this.pathname;
148
+ if (pathname == path) return true;
149
+ if (!path.startsWith("/")) path = "/" + path;
150
+ if (pathname == path) return true;
151
+ return pathname.startsWith(path + "/");
152
+ }
153
+ }
154
+
155
+ /** Public type for the {@link urlBuilder} return value. */
156
+ export type UrlBuilder = UrlBuilderImpl;
157
+
158
+ /** With no argument, resolves to the base origin (never `null`). */
159
+ export function urlBuilder(): UrlBuilder;
160
+
161
+ /**
162
+ * Coerce a {@link UrlLike} into a chainable {@link UrlBuilder}, or `null`
163
+ * when the input cannot be parsed into a URL. Never throws.
164
+ *
165
+ * - A `URL` instance or `{ url }` wrapper is adopted as-is.
166
+ * - A bare hostname (`"example.com"`) is upgraded to `https://`; an
167
+ * explicit scheme is preserved.
168
+ * - A path / query / hash fragment (`"/api"`, `"?q=1"`, `"#x"`) is
169
+ * resolved against {@link defaultUrl} (the browser origin, else
170
+ * `http://localhost`).
171
+ * - An empty / blank string or omitted input resolves to the base
172
+ * origin.
173
+ *
174
+ * @example
175
+ * urlBuilder("example.com"); // https://example.com/
176
+ * urlBuilder("http://x/path"); // http://x/path
177
+ * urlBuilder("/api/v2"); // http://localhost/api/v2
178
+ * urlBuilder({ url: "http://y" }); // http://y/
179
+ * urlBuilder(); // http://localhost/
180
+ */
181
+ export function urlBuilder(input?: UrlLike): UrlBuilder | null;
182
+
183
+ export function urlBuilder(input?: UrlLike): UrlBuilder | null {
184
+ if (input instanceof URL) {
185
+ return new UrlBuilderImpl(input);
186
+ }
187
+ if (input !== null && typeof input === "object" && "url" in input) {
188
+ input = input.url;
189
+ }
190
+ if (typeof input === "string") {
191
+ input = input.trim();
192
+ }
193
+ if (input) {
194
+ if (input.startsWith("/") || input.startsWith("?") || input.startsWith("#")) {
195
+ const joinedUrl = defaultUrl().toString().slice(0, -1) + input;
196
+ return urlBuilder(joinedUrl);
197
+ } else if (input.startsWith(URL_SCHEME_SEPARATOR)) {
198
+ input = `${URL_SCHEME_DEFAULT}${input}`;
199
+ } else {
200
+ const match = input.match(URL_SCHEME_PREFIX);
201
+ const schemePrefix = match?.[1];
202
+ if (schemePrefix) {
203
+ const rest = input.slice(schemePrefix.length);
204
+ input = `${schemePrefix}${rest || defaultUrl().hostname}`;
205
+ } else {
206
+ input = `${URL_SCHEME_DEFAULT}://${input}`;
207
+ }
208
+ }
209
+ } else {
210
+ return urlBuilder(defaultUrl());
211
+ }
212
+ const url = parseUrl(input);
213
+ return url ? urlBuilder(url) : null;
214
+ }
215
+
216
+ /**
217
+ * Convenience wrapper over {@link UrlBuilder.pathMatches}: coerce
218
+ * `input` via {@link urlBuilder} and test its pathname against `path`.
219
+ * Returns `false` for input that can't be parsed into a URL.
220
+ *
221
+ * @example
222
+ * pathMatch("/api/cool?q=1", "/api"); // true
223
+ * pathMatch("/apicool", "/api"); // false
224
+ * pathMatch("https://host/api", "/api"); // true
225
+ * pathMatch(request, "/api/v2"); // fetch Request
226
+ */
227
+ export function pathMatch(input: UrlLike, path: string): boolean {
228
+ const urlb = urlBuilder(input);
229
+ if (!urlb) return false;
230
+ return urlb.pathMatches(path);
231
+ }
232
+
233
+ /**
234
+ * Base origin for resolving path-only inputs. In a browser this is the
235
+ * current page's origin (`window.location.origin`), so `urlBuilder("/api")`
236
+ * reflects where the app is actually served from; on the server / in
237
+ * tests (no `window`, or an opaque `"null"` origin) it falls back to
238
+ * `http://localhost`.
239
+ */
240
+ function defaultUrl(): URL {
241
+ // Reach `window` via `globalThis` so this compiles without the DOM
242
+ // lib (it's `undefined` on the server / in workers without one).
243
+ const origin = (globalThis as { window?: { location?: { origin?: string } } }).window
244
+ ?.location?.origin;
245
+ if (origin && origin !== "null") {
246
+ const originUrl = parseUrl(origin);
247
+ if (originUrl) {
248
+ return originUrl;
249
+ }
250
+ }
251
+ return new URL(LOCAL_HOST_URL);
252
+ }
253
+
254
+ function parseUrl(input: string): URL | null {
255
+ if (input && input.includes(URL_SCHEME_SEPARATOR)) {
256
+ try {
257
+ return new URL(input);
258
+ } catch {}
259
+ }
260
+ return null;
261
+ }
262
+
263
+ // ────────────────────────────────────────────────────────────────
264
+ // Email addresses
265
+ // ────────────────────────────────────────────────────────────────
266
+
267
+ /** Options for {@link parseEmails}. */
268
+ export interface ParseEmailsOptions {
269
+ /** Lower-case every address (useful for allow-lists / matching). Off by default. */
270
+ lowercase?: boolean;
271
+ /**
272
+ * Drop later case-insensitive duplicates, keeping first-seen casing.
273
+ * On by default - a recipient or pattern list rarely wants repeats.
274
+ */
275
+ dedupe?: boolean;
276
+ }
277
+
278
+ /**
279
+ * Normalize the shapes an email address field accepts - a single string,
280
+ * a comma / semicolon / whitespace separated list, an array of either,
281
+ * or nothing - into a clean `string[]`. Each entry is split on the list
282
+ * separators, trimmed, and de-emptied; blanks and non-string array
283
+ * members are dropped. By default duplicates are removed
284
+ * case-insensitively (first-seen casing wins) and casing is otherwise
285
+ * preserved; pass `lowercase` to fold everything to lower case (e.g. for
286
+ * an allow-list). This is the one place recipient lists, CC/BCC inputs,
287
+ * and sender allow-lists across the repo agree on how free-text email
288
+ * input is read. Never throws; does not validate - pair with
289
+ * {@link isEmail} when you need to reject malformed addresses.
290
+ *
291
+ * @example
292
+ * parseEmails("a@x.com, b@y.com; a@x.com"); // ["a@x.com", "b@y.com"]
293
+ * parseEmails(["A@x.com", " b@y.com "]); // ["A@x.com", "b@y.com"]
294
+ * parseEmails("*@corp.com", { lowercase: true }); // ["*@corp.com"]
295
+ * parseEmails(undefined); // []
296
+ */
297
+ export function parseEmails(
298
+ input: string | readonly string[] | null | undefined,
299
+ options: ParseEmailsOptions = {},
300
+ ): string[] {
301
+ if (input === null || input === undefined) return [];
302
+ const { lowercase = false, dedupe = true } = options;
303
+ const entries = Array.isArray(input) ? input : [input as string];
304
+ const seen = new Set<string>();
305
+ const out: string[] = [];
306
+ for (const entry of entries) {
307
+ if (typeof entry !== "string") continue;
308
+ for (const token of entry.split(EMAIL_LIST_SEPARATOR)) {
309
+ const trimmed = token.trim();
310
+ if (!trimmed) continue;
311
+ const address = lowercase ? trimmed.toLowerCase() : trimmed;
312
+ if (dedupe) {
313
+ const key = address.toLowerCase();
314
+ if (seen.has(key)) continue;
315
+ seen.add(key);
316
+ }
317
+ out.push(address);
318
+ }
319
+ }
320
+ return out;
321
+ }
322
+
323
+ /**
324
+ * Pragmatic `local@domain.tld` shape check: non-empty local and domain
325
+ * parts around a single `@`, with a dotted domain and no whitespace.
326
+ * Deliberately loose (not full RFC 5322) - enough to catch obvious typos
327
+ * and give CLI / form feedback, not to gate delivery. Trims first. This
328
+ * is a purely syntactic test, so a wildcard allow-list pattern like
329
+ * `*@example.com` passes; distinguishing a pattern from a real address
330
+ * is the sender policy's job, not this validator's.
331
+ *
332
+ * @example
333
+ * isEmail("alice@example.com"); // true
334
+ * isEmail("nope"); // false (no @)
335
+ * isEmail("no@domain"); // false (no dotted domain)
336
+ */
337
+ export function isEmail(value: string): boolean {
338
+ return EMAIL_ADDRESS.test(value.trim());
339
+ }
340
+
341
+ // ────────────────────────────────────────────────────────────────
342
+ // IP addresses and CIDR blocks
343
+ // ────────────────────────────────────────────────────────────────
344
+
345
+ /**
346
+ * Parse an IPv4 (`"1.2.3.4"`) or IPv6 (`"2001:db8::1"`,
347
+ * `"::ffff:1.2.3.4"`, `"::1"`) address into a {@link ParsedIp}, or
348
+ * `null` when the input isn't a valid literal. Never throws. Surrounding
349
+ * whitespace, `[...]` brackets around an IPv6 literal, and an IPv6 zone
350
+ * id (`fe80::1%eth0`) are all tolerated. The `value` is returned as a
351
+ * `bigint` so v4 and v6 share one comparison path.
352
+ *
353
+ * @example
354
+ * parseIp("10.0.0.1"); // { version: 4, value: 167772161n }
355
+ * parseIp("2001:db8::1"); // { version: 6, value: ... }
356
+ * parseIp("not-an-ip"); // null
357
+ */
358
+ export function parseIp(input: string): ParsedIp | null {
359
+ let text = input.trim();
360
+ if (text.startsWith("[") && text.endsWith("]")) text = text.slice(1, -1);
361
+ if (text.includes(":")) {
362
+ // Drop an IPv6 zone id (`fe80::1%eth0`) - it isn't part of the address.
363
+ const zone = text.indexOf("%");
364
+ if (zone >= 0) text = text.slice(0, zone);
365
+ const value = parseIpv6(text);
366
+ return value === null ? null : { version: 6, value };
367
+ }
368
+ const value = parseIpv4(text);
369
+ return value === null ? null : { version: 4, value };
370
+ }
371
+
372
+ /**
373
+ * Parse a CIDR block (`"10.0.0.0/8"`, `"2001:db8::/32"`) into a
374
+ * {@link Cidr} with host bits cleared from `base`, or `null` when the
375
+ * input isn't a valid block (bad address, missing / out-of-range
376
+ * prefix). Never throws.
377
+ *
378
+ * @example
379
+ * parseCidr("10.0.0.0/8")?.base; // 167772160n (10.0.0.0)
380
+ * parseCidr("10.1.2.3/8")?.base; // 167772160n (host bits dropped)
381
+ * parseCidr("10.0.0.0/40"); // null (prefix > 32 for v4)
382
+ */
383
+ export function parseCidr(input: string): Cidr | null {
384
+ const text = input.trim();
385
+ const slash = text.lastIndexOf("/");
386
+ if (slash < 0) return null;
387
+ const prefixText = text.slice(slash + 1);
388
+ if (!IPV4_OCTET.test(prefixText)) return null;
389
+ const ip = parseIp(text.slice(0, slash));
390
+ if (!ip) return null;
391
+ const prefix = Number(prefixText);
392
+ const bits = IP_BITS[ip.version];
393
+ if (prefix > bits) return null;
394
+ const base = ip.value & networkMask(bits, prefix);
395
+ return {
396
+ version: ip.version,
397
+ base,
398
+ prefix,
399
+ cidr: `${text.slice(0, slash)}/${prefix}`,
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Test whether `ip` falls inside `cidr`. Both arguments accept either
405
+ * a string (parsed on the fly via {@link parseIp} / {@link parseCidr})
406
+ * or an already-parsed value - pass parsed values in hot loops to skip
407
+ * re-parsing. Returns `false` for unparseable input or a version
408
+ * mismatch (an IPv4 address is never inside an IPv6 block).
409
+ *
410
+ * @example
411
+ * ipInCidr("10.1.2.3", "10.0.0.0/8"); // true
412
+ * ipInCidr("11.0.0.1", "10.0.0.0/8"); // false
413
+ */
414
+ export function ipInCidr(ip: string | ParsedIp, cidr: string | Cidr): boolean {
415
+ const parsedIp = typeof ip === "string" ? parseIp(ip) : ip;
416
+ const parsedCidr = typeof cidr === "string" ? parseCidr(cidr) : cidr;
417
+ if (!parsedIp || !parsedCidr || parsedIp.version !== parsedCidr.version) {
418
+ return false;
419
+ }
420
+ const mask = networkMask(IP_BITS[parsedCidr.version], parsedCidr.prefix);
421
+ return (parsedIp.value & mask) === parsedCidr.base;
422
+ }
423
+
424
+ /**
425
+ * Return the first CIDR in `cidrs` that contains `ip`, or `null` when
426
+ * none match. `cidrs` holds pre-parsed {@link Cidr} values (or any
427
+ * object extending it), so callers can carry side metadata on each
428
+ * entry (e.g. a region tag) and read it straight off the returned
429
+ * match. Linear scan; parse the address once by passing a
430
+ * {@link ParsedIp}.
431
+ *
432
+ * @example
433
+ * const ranges = ["10.0.0.0/8", "192.168.0.0/16"]
434
+ * .map(parseCidr)
435
+ * .filter((c): c is Cidr => c !== null);
436
+ * findContainingCidr("10.1.2.3", ranges)?.cidr; // "10.0.0.0/8"
437
+ */
438
+ export function findContainingCidr<T extends Cidr>(
439
+ ip: string | ParsedIp,
440
+ cidrs: Iterable<T>,
441
+ ): T | null {
442
+ const parsedIp = typeof ip === "string" ? parseIp(ip) : ip;
443
+ if (!parsedIp) return null;
444
+ for (const cidr of cidrs) {
445
+ if (cidr.version !== parsedIp.version) continue;
446
+ const mask = networkMask(IP_BITS[cidr.version], cidr.prefix);
447
+ if ((parsedIp.value & mask) === cidr.base) return cidr;
448
+ }
449
+ return null;
450
+ }
451
+
452
+ /**
453
+ * Network mask for `prefix` leading bits of a `bits`-wide address as a
454
+ * `bigint`: the top `prefix` bits set, the low host bits cleared.
455
+ * `prefix === 0` yields `0n` (matches everything); `prefix === bits`
456
+ * yields the all-ones mask (matches a single address).
457
+ */
458
+ function networkMask(bits: number, prefix: number): bigint {
459
+ const hostBits = BigInt(bits - prefix);
460
+ const full = (1n << BigInt(bits)) - 1n;
461
+ return full ^ ((1n << hostBits) - 1n);
462
+ }
463
+
464
+ /** Parse a dotted-quad IPv4 literal into a 32-bit value, else `null`. */
465
+ function parseIpv4(input: string): bigint | null {
466
+ const parts = input.split(".");
467
+ if (parts.length !== 4) return null;
468
+ let value = 0n;
469
+ for (const part of parts) {
470
+ if (!IPV4_OCTET.test(part)) return null;
471
+ const octet = Number(part);
472
+ if (octet > 255) return null;
473
+ value = (value << 8n) | BigInt(octet);
474
+ }
475
+ return value;
476
+ }
477
+
478
+ /**
479
+ * Parse an IPv6 literal into a 128-bit value, else `null`. Handles `::`
480
+ * zero-compression (at most once) and a trailing embedded IPv4 group
481
+ * (`::ffff:1.2.3.4`). The zone id, if any, is expected to be stripped
482
+ * by {@link parseIp} before this is called.
483
+ */
484
+ function parseIpv6(input: string): bigint | null {
485
+ const halves = input.split("::");
486
+ if (halves.length > 2) return null;
487
+
488
+ const head = parseHextets(halves[0]!);
489
+ if (head === null) return null;
490
+
491
+ if (halves.length === 2) {
492
+ const tail = parseHextets(halves[1]!);
493
+ if (tail === null) return null;
494
+ // "::" must stand in for at least one all-zero hextet.
495
+ const missing = 8 - head.length - tail.length;
496
+ if (missing < 1) return null;
497
+ return hextetsToValue([...head, ...Array(missing).fill(0), ...tail]);
498
+ }
499
+
500
+ if (head.length !== 8) return null;
501
+ return hextetsToValue(head);
502
+ }
503
+
504
+ /**
505
+ * Parse one colon-separated run of IPv6 hextets, expanding a trailing
506
+ * embedded IPv4 group into its two hextets. An empty string yields an
507
+ * empty list (the side of a leading / trailing `::`).
508
+ */
509
+ function parseHextets(part: string): number[] | null {
510
+ if (part === "") return [];
511
+ const tokens = part.split(":");
512
+ const hextets: number[] = [];
513
+ for (let i = 0; i < tokens.length; i++) {
514
+ const token = tokens[i]!;
515
+ if (token.includes(".")) {
516
+ // A dotted-quad is only legal as the final group.
517
+ if (i !== tokens.length - 1) return null;
518
+ const v4 = parseIpv4(token);
519
+ if (v4 === null) return null;
520
+ hextets.push(Number((v4 >> 16n) & 0xffffn), Number(v4 & 0xffffn));
521
+ } else {
522
+ if (!IPV6_HEXTET.test(token)) return null;
523
+ hextets.push(parseInt(token, 16));
524
+ }
525
+ }
526
+ return hextets;
527
+ }
528
+
529
+ /** Fold exactly 8 hextets into a single 128-bit `bigint`, else `null`. */
530
+ function hextetsToValue(hextets: number[]): bigint | null {
531
+ if (hextets.length !== 8) return null;
532
+ let value = 0n;
533
+ for (const hextet of hextets) value = (value << 16n) | BigInt(hextet);
534
+ return value;
535
+ }
package/src/object.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Small value guards, coercions, object-shape types, and structural
3
+ * deep-equality: narrow parsed JSON to a record, coerce loose truthy/falsy
4
+ * strings to a boolean, describe object shapes (`NameLike`, `NonFunctionKeys`),
5
+ * and compare values with {@link deepEqual}. Dependency-free and browser-safe.
6
+ */
7
+
8
+ /** Minimal shape for objects that expose an optional `name` (e.g. AppKit plugins). */
9
+ export interface NameLike {
10
+ name?: string;
11
+ }
12
+
13
+ export type NonFunctionKeys<T> = {
14
+ [K in keyof T]: T[K] extends (...args: any[]) => any ? never : K;
15
+ }[keyof T];
16
+
17
+ /**
18
+ * Narrow `value` to a plain (non-array) object. Use as a type guard
19
+ * before indexing into / mutating parsed JSON so the access is
20
+ * type-safe.
21
+ *
22
+ * @example
23
+ * if (isRecord(parsed)) parsed.foo = 1;
24
+ */
25
+ export function isRecord(value: unknown): value is Record<string, unknown> {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+
29
+ /**
30
+ * Coerce a loose boolean-ish value to a real `boolean`, or `undefined`
31
+ * when it can't be interpreted. Recognizes `true`/`t`/`on`/`1`/`yes`/`y`
32
+ * and their negatives (case- and whitespace-insensitive for strings), as
33
+ * well as the numbers `1` and `0`.
34
+ */
35
+ export function toBoolean(value: unknown): boolean | undefined {
36
+ if (typeof value === "boolean") return value;
37
+ else if (typeof value === "string") {
38
+ value = value.trim().toLowerCase();
39
+ if (
40
+ value === "true" ||
41
+ value == "t" ||
42
+ value === "on" ||
43
+ value === "1" ||
44
+ value === "yes" ||
45
+ value === "y"
46
+ )
47
+ return true;
48
+ else if (
49
+ value === "false" ||
50
+ value == "f" ||
51
+ value === "off" ||
52
+ value === "0" ||
53
+ value === "no" ||
54
+ value === "n"
55
+ )
56
+ return false;
57
+ } else if (typeof value === "number") {
58
+ if (value === 1) return true;
59
+ else if (value === 0) return false;
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ /**
65
+ * Structural deep-equality with an optional custom comparator.
66
+ *
67
+ * {@link deepEqual} mirrors the semantics of the `fast-deep-equal`
68
+ * package (handled: nested plain objects/arrays, `Map`, `Set`, `Date`,
69
+ * `RegExp`, typed arrays, `NaN`, and `+0`/`-0` treated as equal) but is
70
+ * dependency-free so `@dbx-tools/shared-core` keeps no runtime deps.
71
+ *
72
+ * The optional `comparator` short-circuits the structural walk at any
73
+ * node: return `true`/`false` to force the result for that pair, or
74
+ * `undefined` to defer to the built-in comparison. It is invoked for the
75
+ * root pair and recursively for each nested pair, so a caller can, e.g.,
76
+ * compare two domain objects by id while letting everything else fall
77
+ * back to structural equality.
78
+ *
79
+ * @example
80
+ * deepEqual({ a: 1 }, { a: 1 }); // true
81
+ * deepEqual([1, 2], [1, 2]); // true
82
+ * deepEqual(a, b, (x, y) =>
83
+ * isEntity(x) && isEntity(y) ? x.id === y.id : undefined);
84
+ */
85
+ export type DeepEqualComparator = (a: unknown, b: unknown) => boolean | undefined;
86
+
87
+ export function deepEqual(a: unknown, b: unknown, comparator?: DeepEqualComparator): boolean {
88
+ if (comparator) {
89
+ const decided = comparator(a, b);
90
+ if (decided !== undefined) return decided;
91
+ }
92
+
93
+ if (a === b) return true;
94
+
95
+ if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) {
96
+ // NaN is the only value not equal to itself under `===`.
97
+ return a !== a && b !== b;
98
+ }
99
+
100
+ if (a.constructor !== b.constructor) return false;
101
+
102
+ if (Array.isArray(a)) {
103
+ const bArr = b as unknown[];
104
+ if (a.length !== bArr.length) return false;
105
+ for (let i = 0; i < a.length; i++) {
106
+ if (!deepEqual(a[i], bArr[i], comparator)) return false;
107
+ }
108
+ return true;
109
+ }
110
+
111
+ if (a instanceof Map) {
112
+ const bMap = b as Map<unknown, unknown>;
113
+ if (a.size !== bMap.size) return false;
114
+ for (const [key, value] of a) {
115
+ if (!bMap.has(key)) return false;
116
+ if (!deepEqual(value, bMap.get(key), comparator)) return false;
117
+ }
118
+ return true;
119
+ }
120
+
121
+ if (a instanceof Set) {
122
+ const bSet = b as Set<unknown>;
123
+ if (a.size !== bSet.size) return false;
124
+ for (const value of a) {
125
+ if (!bSet.has(value)) return false;
126
+ }
127
+ return true;
128
+ }
129
+
130
+ if (a instanceof Date) {
131
+ return a.getTime() === (b as Date).getTime();
132
+ }
133
+
134
+ if (a instanceof RegExp) {
135
+ const bRe = b as RegExp;
136
+ return a.source === bRe.source && a.flags === bRe.flags;
137
+ }
138
+
139
+ if (ArrayBuffer.isView(a) && !(a instanceof DataView)) {
140
+ const aArr = a as unknown as ArrayLike<number>;
141
+ const bArr = b as unknown as ArrayLike<number>;
142
+ if (aArr.length !== bArr.length) return false;
143
+ for (let i = 0; i < aArr.length; i++) {
144
+ if (aArr[i] !== bArr[i]) return false;
145
+ }
146
+ return true;
147
+ }
148
+
149
+ const aKeys = Object.keys(a as Record<string, unknown>);
150
+ const bKeys = Object.keys(b as Record<string, unknown>);
151
+ if (aKeys.length !== bKeys.length) return false;
152
+ for (const key of aKeys) {
153
+ if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
154
+ if (
155
+ !deepEqual(
156
+ (a as Record<string, unknown>)[key],
157
+ (b as Record<string, unknown>)[key],
158
+ comparator,
159
+ )
160
+ ) {
161
+ return false;
162
+ }
163
+ }
164
+ return true;
165
+ }