@expofp/utils 3.11.12 → 3.12.0

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/dist/index.d.ts CHANGED
@@ -3,12 +3,15 @@ export * from './lib/clone.js';
3
3
  export * from './lib/deep-freeze.js';
4
4
  export * from './lib/fetch-with-retry.js';
5
5
  export * from './lib/import-fs-promises.js';
6
+ export * from './lib/import-js-as-json.js';
6
7
  export * from './lib/import-json.js';
7
8
  export * from './lib/import-node-module.js';
8
9
  export * from './lib/import-runtime-json.js';
9
10
  export * from './lib/load-script.js';
10
11
  export * from './lib/make-local-path.js';
11
12
  export * from './lib/read-file-from-url.js';
13
+ export * from './lib/safe-decode.js';
14
+ export * from './lib/safe-local-storage.js';
12
15
  export * from './lib/safe-slugify.js';
13
16
  export * from './lib/short-hash.js';
14
17
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -3,11 +3,14 @@ export * from './lib/clone.js';
3
3
  export * from './lib/deep-freeze.js';
4
4
  export * from './lib/fetch-with-retry.js';
5
5
  export * from './lib/import-fs-promises.js';
6
+ export * from './lib/import-js-as-json.js';
6
7
  export * from './lib/import-json.js';
7
8
  export * from './lib/import-node-module.js';
8
9
  export * from './lib/import-runtime-json.js';
9
10
  export * from './lib/load-script.js';
10
11
  export * from './lib/make-local-path.js';
11
12
  export * from './lib/read-file-from-url.js';
13
+ export * from './lib/safe-decode.js';
14
+ export * from './lib/safe-local-storage.js';
12
15
  export * from './lib/safe-slugify.js';
13
16
  export * from './lib/short-hash.js';
@@ -0,0 +1,52 @@
1
+ export interface EvalJsToJsonOptions {
2
+ /**
3
+ * If set, return only these top-level names (missing ones are skipped) instead
4
+ * of every assigned global. Useful when a script assigns noise you don't want.
5
+ */
6
+ pick?: readonly string[];
7
+ /**
8
+ * Extra globals seeded into the sandbox before running — e.g. a `console` or a
9
+ * stub `document` the script expects. Seeded keys are inputs and are omitted
10
+ * from the result (unless the script overwrites one and you `pick` it).
11
+ */
12
+ globals?: Record<string, unknown>;
13
+ }
14
+ export interface ImportJsAsJsonOptions extends EvalJsToJsonOptions {
15
+ signal?: AbortSignal | null;
16
+ }
17
+ /**
18
+ * Evaluate a standalone (non-module) JavaScript snippet and return everything it
19
+ * assigned to the global scope as a plain object. **Works in both the browser and
20
+ * Node.js** — no `eval`-free CSP required beyond `unsafe-eval` (see caveats).
21
+ *
22
+ * The snippet runs inside `with (sandbox) { … }`, where `sandbox` is a Proxy that
23
+ * traps every free identifier. So, with no need to know the names in advance:
24
+ * - top-level `var` declarations (`var __data = {…}`) land in the sandbox
25
+ * - bare and browser-style assignments (`__x = …`, `window['__paths'] = …`,
26
+ * `self.x = …`), including dynamically-named ones (`window['__p' + i] = …`)
27
+ * - built-ins the script reads (`JSON`, `Math`, `document`, `fetch`, …) fall
28
+ * through to the real global object
29
+ *
30
+ * Not captured: top-level `let`/`const` and `function` *declarations* — they bind
31
+ * to the wrapper's own scope, not the sandbox. Values are whatever the script
32
+ * assigned, so the result is JSON-shaped only if the script assigned JSON-shaped
33
+ * values.
34
+ *
35
+ * Caveats — this is a data extractor, not a sandbox:
36
+ * - Requires `Proxy` and dynamic code eval; a strict CSP without `unsafe-eval`
37
+ * (or an ancient pre-`Proxy` WebView) will make it throw.
38
+ * - The script gets full access to the host's real globals and runs synchronously
39
+ * with no timeout or isolation. **Only evaluate code you trust** — validating the
40
+ * source's origin/trust is the caller's job.
41
+ */
42
+ export declare function evalJsToJson<T = Record<string, unknown>>(code: string, options?: EvalJsToJsonOptions): T;
43
+ /**
44
+ * Load a standalone JavaScript file and return its global assignments as a plain
45
+ * object. Fetches `http(s):` URLs (browser or Node) and reads `file:` URLs (Node
46
+ * only). Thin loader around {@link evalJsToJson} — see it for capture rules and
47
+ * the trust caveat.
48
+ *
49
+ * @param url Absolute `http:`/`https:` URL (any environment) or `file:` URL (Node).
50
+ */
51
+ export declare function importJsAsJson<T = Record<string, unknown>>(url: string, options?: ImportJsAsJsonOptions): Promise<T>;
52
+ //# sourceMappingURL=import-js-as-json.d.ts.map
@@ -0,0 +1,124 @@
1
+ /// <reference lib="dom" />
2
+ import debug from 'debug';
3
+ import { fetchWithRetry } from './fetch-with-retry.js';
4
+ const log = debug('efp:utils:importJsAsJson');
5
+ /**
6
+ * Import a Node.js builtin without the browser bundler trying to resolve it: the
7
+ * specifier is a runtime variable marked `@vite-ignore`, so Vite/Rollup leave it
8
+ * as a plain dynamic import. Only reached for `file:` URLs, which are Node-only.
9
+ */
10
+ async function importNodeBuiltin(specifier) {
11
+ return (await import(/* @vite-ignore */ specifier));
12
+ }
13
+ /** The real global object, looked up defensively (older WebViews lack `globalThis`). */
14
+ const globalRef = typeof globalThis !== 'undefined'
15
+ ? globalThis
16
+ : typeof self !== 'undefined'
17
+ ? self
18
+ : typeof window !== 'undefined'
19
+ ? window
20
+ : {};
21
+ /**
22
+ * Identifiers pointed back at the sandbox so browser-style scripts (`window.x = …`,
23
+ * `self.y = …`) run unmodified. Treated as inputs — never part of the result.
24
+ */
25
+ const SELF_REFERENCE_GLOBALS = ['window', 'self', 'globalThis'];
26
+ /**
27
+ * Evaluate a standalone (non-module) JavaScript snippet and return everything it
28
+ * assigned to the global scope as a plain object. **Works in both the browser and
29
+ * Node.js** — no `eval`-free CSP required beyond `unsafe-eval` (see caveats).
30
+ *
31
+ * The snippet runs inside `with (sandbox) { … }`, where `sandbox` is a Proxy that
32
+ * traps every free identifier. So, with no need to know the names in advance:
33
+ * - top-level `var` declarations (`var __data = {…}`) land in the sandbox
34
+ * - bare and browser-style assignments (`__x = …`, `window['__paths'] = …`,
35
+ * `self.x = …`), including dynamically-named ones (`window['__p' + i] = …`)
36
+ * - built-ins the script reads (`JSON`, `Math`, `document`, `fetch`, …) fall
37
+ * through to the real global object
38
+ *
39
+ * Not captured: top-level `let`/`const` and `function` *declarations* — they bind
40
+ * to the wrapper's own scope, not the sandbox. Values are whatever the script
41
+ * assigned, so the result is JSON-shaped only if the script assigned JSON-shaped
42
+ * values.
43
+ *
44
+ * Caveats — this is a data extractor, not a sandbox:
45
+ * - Requires `Proxy` and dynamic code eval; a strict CSP without `unsafe-eval`
46
+ * (or an ancient pre-`Proxy` WebView) will make it throw.
47
+ * - The script gets full access to the host's real globals and runs synchronously
48
+ * with no timeout or isolation. **Only evaluate code you trust** — validating the
49
+ * source's origin/trust is the caller's job.
50
+ */
51
+ export function evalJsToJson(code, options) {
52
+ if (typeof Proxy === 'undefined') {
53
+ throw new Error('evalJsToJson requires Proxy, which is unavailable in this environment');
54
+ }
55
+ const seeded = options?.globals ?? {};
56
+ const sandbox = { ...seeded };
57
+ const handler = {
58
+ // Trap every identifier lookup inside the `with` block into the sandbox…
59
+ has: () => true,
60
+ get: (target, key) => {
61
+ // …except `Symbol.unscopables`, which `with` consults to decide bindings.
62
+ if (key === Symbol.unscopables)
63
+ return undefined;
64
+ // Point window/self/globalThis at the sandbox so `window.x = …` lands here.
65
+ if (typeof key === 'string' && SELF_REFERENCE_GLOBALS.includes(key))
66
+ return scope;
67
+ // Script-assigned globals win; otherwise fall through to the real globals
68
+ // (JSON, Math, document, …) so the snippet can actually run.
69
+ if (key in target)
70
+ return target[key];
71
+ return globalRef[key];
72
+ },
73
+ set: (target, key, value) => {
74
+ target[key] = value;
75
+ return true;
76
+ },
77
+ };
78
+ const scope = new Proxy(sandbox, handler);
79
+ // A Function-constructed body is always sloppy-mode (even inside this strict ESM
80
+ // module), so `with` is legal here. The trailing newline stops a `//` line comment
81
+ // at the end of `code` from swallowing the closing brace.
82
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval -- evaluating the legacy `.js` payload is the whole purpose of this module
83
+ const run = new Function('__scope__', `with(__scope__){${code}\n}`);
84
+ run(scope);
85
+ const excluded = new Set([...SELF_REFERENCE_GLOBALS, ...Object.keys(seeded)]);
86
+ return collectGlobals(sandbox, excluded, options?.pick);
87
+ }
88
+ /**
89
+ * Load a standalone JavaScript file and return its global assignments as a plain
90
+ * object. Fetches `http(s):` URLs (browser or Node) and reads `file:` URLs (Node
91
+ * only). Thin loader around {@link evalJsToJson} — see it for capture rules and
92
+ * the trust caveat.
93
+ *
94
+ * @param url Absolute `http:`/`https:` URL (any environment) or `file:` URL (Node).
95
+ */
96
+ export async function importJsAsJson(url, options) {
97
+ log('Loading JS from', url);
98
+ const code = await loadJsSource(url, options?.signal ?? null);
99
+ return evalJsToJson(code, options);
100
+ }
101
+ async function loadJsSource(url, signal) {
102
+ const parsed = new URL(url);
103
+ if (parsed.protocol === 'file:') {
104
+ log('Read from file system', url);
105
+ const { readFile } = await importNodeBuiltin('node:fs/promises');
106
+ return readFile(parsed, { encoding: 'utf-8', signal: signal ?? undefined });
107
+ }
108
+ log('Fetch', url);
109
+ const response = await fetchWithRetry(url, { signal });
110
+ if (!response.ok) {
111
+ throw new Error(`Failed to fetch JS from ${url} (HTTP ${response.status})`);
112
+ }
113
+ return response.text();
114
+ }
115
+ function collectGlobals(sandbox, excluded, pick) {
116
+ const result = {};
117
+ const keys = pick ?? Object.keys(sandbox).filter((key) => !excluded.has(key));
118
+ for (const key of keys) {
119
+ if (Object.prototype.hasOwnProperty.call(sandbox, key)) {
120
+ result[key] = sandbox[key];
121
+ }
122
+ }
123
+ return result;
124
+ }
@@ -72,6 +72,13 @@ async function loadJson(url, fetchCache, signal) {
72
72
  return (await response.json());
73
73
  })();
74
74
  fetchCache.set(key, dataPromise);
75
+ // A rejection must not poison the URL for the cache's life (one transient
76
+ // fetch drop would replay as a permanent failure) — evict it so the next
77
+ // importJson retries.
78
+ dataPromise.catch(() => {
79
+ if (fetchCache.get(key) === dataPromise)
80
+ fetchCache.delete(key);
81
+ });
75
82
  return dataPromise;
76
83
  }
77
84
  async function readJsonFromFs(url) {
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `decodeURIComponent`, or the input unchanged where decoding throws. Query
3
+ * strings arrive from outside the SDK (shared links, embedding pages), so a
4
+ * malformed percent-escape like `?100%booth` is normal input — code on the
5
+ * boot/dispatch path must degrade to the raw string, never die on `URIError`.
6
+ */
7
+ export declare function safeDecode(value: string): string;
8
+ //# sourceMappingURL=safe-decode.d.ts.map
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `decodeURIComponent`, or the input unchanged where decoding throws. Query
3
+ * strings arrive from outside the SDK (shared links, embedding pages), so a
4
+ * malformed percent-escape like `?100%booth` is normal input — code on the
5
+ * boot/dispatch path must degrade to the raw string, never die on `URIError`.
6
+ */
7
+ export function safeDecode(value) {
8
+ try {
9
+ return decodeURIComponent(value);
10
+ }
11
+ catch {
12
+ return value;
13
+ }
14
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `window.localStorage`, or `null` where touching it throws. A cross-origin
3
+ * iframe with third-party cookies blocked throws a `SecurityError` on the
4
+ * property access itself (Chrome/Firefox), so bare `localStorage` reads are
5
+ * unsafe anywhere on the boot path — feature-detect through this instead.
6
+ * Node (no `window`) also lands on `null`. Reads on the returned store work in
7
+ * more environments than writes (quota-0 private modes throw on `setItem`
8
+ * only), so callers that write still need their own probe or try/catch.
9
+ */
10
+ export declare function safeLocalStorage(): Storage | null;
11
+ //# sourceMappingURL=safe-local-storage.d.ts.map
@@ -0,0 +1,18 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * `window.localStorage`, or `null` where touching it throws. A cross-origin
4
+ * iframe with third-party cookies blocked throws a `SecurityError` on the
5
+ * property access itself (Chrome/Firefox), so bare `localStorage` reads are
6
+ * unsafe anywhere on the boot path — feature-detect through this instead.
7
+ * Node (no `window`) also lands on `null`. Reads on the returned store work in
8
+ * more environments than writes (quota-0 private modes throw on `setItem`
9
+ * only), so callers that write still need their own probe or try/catch.
10
+ */
11
+ export function safeLocalStorage() {
12
+ try {
13
+ return window.localStorage;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expofp/utils",
3
- "version": "3.11.12",
3
+ "version": "3.12.0",
4
4
  "type": "module",
5
5
  "description": "ExpoFP SDK internal: shared utilities",
6
6
  "homepage": "https://developer.expofp.com/",
package/README.md DELETED
@@ -1,11 +0,0 @@
1
- # @expofp/utils
2
-
3
- Generic, domain-free utilities for the ExpoFP SDK. Bundled with the packages that depend on it — no
4
- separate install.
5
-
6
- **Scope rule:** strictly common-use helpers only — no domain logic. Nothing here may know about floor
7
- plans, booths, exhibitors, or events, or import from a domain package. The test: _could it ship in a
8
- general-purpose npm utility library unchanged?_ If not, it belongs next to the domain that owns it.
9
-
10
- - Build: `nx build utils`
11
- - Test: `nx test utils`