@dbx-tools/shared-core 0.3.27 → 0.3.29

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 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 { async, error, hash, http, log, net, object, brand, string } from "@dbx-tools/shared-core";
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
- - String normalization helpers for slugs, identifiers, unique labels, and prompt
25
- descriptions.
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
- - `string` - tokenization, slugs, identifiers, descriptions, pluralization, and
220
- HTML escaping.
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
@@ -22,7 +22,7 @@
22
22
  "publishConfig": {
23
23
  "access": "public"
24
24
  },
25
- "version": "0.3.27",
25
+ "version": "0.3.29",
26
26
  "types": "index.ts",
27
27
  "type": "module",
28
28
  "exports": {
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 = token.charAt(0).toUpperCase() + token.slice(1);
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
- /** Decode a JWT segment (base64url with standard base64 padding). */
18
- function decodeJwtSegment(segment: string): string {
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
- const binary = atob(padded);
22
- const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
23
- return new TextDecoder().decode(bytes);
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
- try {
59
- const payload = JSON.parse(decodeJwtSegment(parts[1]!));
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,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
+ });