@expofp/utils 0.0.0-experimental.2d6c681

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.
@@ -0,0 +1,17 @@
1
+ export * from './lib/build-zip-archive.js';
2
+ export * from './lib/clone.js';
3
+ export * from './lib/deep-freeze.js';
4
+ export * from './lib/fetch-with-retry.js';
5
+ export * from './lib/import-fs-promises.js';
6
+ export * from './lib/import-js-as-json.js';
7
+ export * from './lib/import-json.js';
8
+ export * from './lib/import-node-module.js';
9
+ export * from './lib/import-runtime-json.js';
10
+ export * from './lib/load-script.js';
11
+ export * from './lib/make-local-path.js';
12
+ export * from './lib/read-file-from-url.js';
13
+ export * from './lib/safe-decode.js';
14
+ export * from './lib/safe-local-storage.js';
15
+ export * from './lib/safe-slugify.js';
16
+ export * from './lib/short-hash.js';
17
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ export * from './lib/build-zip-archive.js';
2
+ export * from './lib/clone.js';
3
+ export * from './lib/deep-freeze.js';
4
+ export * from './lib/fetch-with-retry.js';
5
+ export * from './lib/import-fs-promises.js';
6
+ export * from './lib/import-js-as-json.js';
7
+ export * from './lib/import-json.js';
8
+ export * from './lib/import-node-module.js';
9
+ export * from './lib/import-runtime-json.js';
10
+ export * from './lib/load-script.js';
11
+ export * from './lib/make-local-path.js';
12
+ export * from './lib/read-file-from-url.js';
13
+ export * from './lib/safe-decode.js';
14
+ export * from './lib/safe-local-storage.js';
15
+ export * from './lib/safe-slugify.js';
16
+ export * from './lib/short-hash.js';
@@ -0,0 +1,5 @@
1
+ export declare function buildZipArchive(files: AsyncIterable<{
2
+ path: string;
3
+ data: Uint8Array;
4
+ }>): Promise<Blob>;
5
+ //# sourceMappingURL=build-zip-archive.d.ts.map
@@ -0,0 +1,40 @@
1
+ import debug from 'debug';
2
+ import JSZip from 'jszip';
3
+ const log = debug('efp:utils:buildZipArchive');
4
+ const CONCURRENCY = 10;
5
+ async function processConcurrently(iterator, concurrency, processor) {
6
+ const activePromises = new Set();
7
+ for await (const item of iterator) {
8
+ const promise = Promise.resolve(processor(item)).then(() => {
9
+ activePromises.delete(promise);
10
+ });
11
+ activePromises.add(promise);
12
+ if (activePromises.size >= concurrency) {
13
+ await Promise.race(activePromises);
14
+ }
15
+ }
16
+ await Promise.all(activePromises);
17
+ }
18
+ export async function buildZipArchive(files) {
19
+ const zip = new JSZip();
20
+ // const bundle = await makeOfflineBundle(manifest);
21
+ let hasFiles = false;
22
+ await processConcurrently(files, CONCURRENCY, (file) => {
23
+ log('Adding file to zip:', file.path, 'size:', file.data.byteLength);
24
+ zip.file(file.path, file.data);
25
+ hasFiles = true;
26
+ });
27
+ // for (const extraFile of extraFiles ?? []) {
28
+ // log('Adding extra file to zip:', extraFile.path, 'size:', extraFile.data.byteLength);
29
+ // zip.file(extraFile.path, extraFile.data);
30
+ // hasFiles = true;
31
+ // }
32
+ if (!hasFiles) {
33
+ throw new Error('No files were added to the ZIP archive');
34
+ }
35
+ // Generate archive
36
+ const blob = await zip.generateAsync({ type: 'blob' });
37
+ // console.info('Manifest for HTML:', offlineData.manifest);
38
+ log('Generated offline ZIP bundle, size:', blob.size);
39
+ return blob;
40
+ }
@@ -0,0 +1,3 @@
1
+ export declare function deepClone<T>(obj: T): T;
2
+ export declare function shallowClone<T>(obj: T): T;
3
+ //# sourceMappingURL=clone.d.ts.map
@@ -0,0 +1,14 @@
1
+ export function deepClone(obj) {
2
+ if ('structuredClone' in globalThis) {
3
+ return globalThis.structuredClone(obj);
4
+ }
5
+ return JSON.parse(JSON.stringify(obj));
6
+ }
7
+ export function shallowClone(obj) {
8
+ if (obj === null || typeof obj !== 'object')
9
+ return obj;
10
+ if (Array.isArray(obj)) {
11
+ return obj.slice();
12
+ }
13
+ return { ...obj };
14
+ }
@@ -0,0 +1,2 @@
1
+ export declare function deepFreeze<T>(obj: T): T;
2
+ //# sourceMappingURL=deep-freeze.d.ts.map
@@ -0,0 +1,11 @@
1
+ export function deepFreeze(obj) {
2
+ if (obj === null || typeof obj !== 'object')
3
+ return obj;
4
+ Object.getOwnPropertyNames(obj).forEach((prop) => {
5
+ const value = obj[prop];
6
+ if (value && typeof value === 'object') {
7
+ deepFreeze(value);
8
+ }
9
+ });
10
+ return Object.freeze(obj);
11
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Fetches a resource from the network, with retry support in Node.js environments.
3
+ * In browser environments, it uses the native `fetch` API.
4
+ * In Node.js environments, it uses the `fetch-retry` library to add retry capabilities.
5
+ * Node.js are more prone to transient network errors, so retries can help improve reliability.
6
+ */
7
+ export declare const fetchWithRetry: (...args: Parameters<typeof window.fetch>) => ReturnType<typeof window.fetch>;
8
+ //# sourceMappingURL=fetch-with-retry.d.ts.map
@@ -0,0 +1,21 @@
1
+ /// <reference lib="dom" />
2
+ let fn;
3
+ /**
4
+ * Fetches a resource from the network, with retry support in Node.js environments.
5
+ * In browser environments, it uses the native `fetch` API.
6
+ * In Node.js environments, it uses the `fetch-retry` library to add retry capabilities.
7
+ * Node.js are more prone to transient network errors, so retries can help improve reliability.
8
+ */
9
+ export const fetchWithRetry = async function fetch(...args) {
10
+ if (!fn) {
11
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
12
+ fn = window.fetch.bind(window);
13
+ }
14
+ else {
15
+ const fr = await import('fetch-retry');
16
+ const wrappedFetch = fr.default(globalThis.fetch);
17
+ fn = wrappedFetch;
18
+ }
19
+ }
20
+ return fn(...args);
21
+ };
@@ -0,0 +1,2 @@
1
+ export declare function importFsPromises(): Promise<typeof import("node:fs/promises")>;
2
+ //# sourceMappingURL=import-fs-promises.d.ts.map
@@ -0,0 +1,4 @@
1
+ import { importNodeModule } from './import-node-module.js';
2
+ export async function importFsPromises() {
3
+ return importNodeModule('node:fs/promises');
4
+ }
@@ -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
+ }
@@ -0,0 +1,11 @@
1
+ export interface ImportJsonOptions {
2
+ forceFetch?: boolean;
3
+ fetchCache?: Map<string, Promise<unknown>>;
4
+ /**
5
+ * Callback invoked when a URL is fetched or imported or read from the file system.
6
+ */
7
+ importCallback?: (url: string) => void;
8
+ signal?: AbortSignal | null;
9
+ }
10
+ export declare function importJson<T = unknown>(url: string, options?: ImportJsonOptions): Promise<T>;
11
+ //# sourceMappingURL=import-json.d.ts.map
@@ -0,0 +1,91 @@
1
+ import debug from 'debug';
2
+ import { deepFreeze } from './deep-freeze.js';
3
+ import { fetchWithRetry } from './fetch-with-retry.js';
4
+ import { importFsPromises } from './import-fs-promises.js';
5
+ const log = debug('efp:utils:importJson');
6
+ const jsonFrozen = new WeakSet();
7
+ // undefined = untried, function = works, null = known broken (Safari 14, CSP, etc.)
8
+ let importJsonNative;
9
+ // ET: this is a workaround for Vite that analyzes dynamic imports and removes 'with' option
10
+ // to be removed when Vite supports it properly
11
+ // Cons: can have CSP issues in some environments.
12
+ // Lazy + try/caught: Safari 14's parser rejects the two-argument import() form at
13
+ // new Function() construction time, before any feature-detect call site can run.
14
+ function getImportJsonNative() {
15
+ if (importJsonNative === undefined) {
16
+ try {
17
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
18
+ importJsonNative = new Function('url', 'return import(url, { with: { type: "json" } });');
19
+ }
20
+ catch {
21
+ log('Dynamic import not available');
22
+ importJsonNative = null;
23
+ }
24
+ }
25
+ return importJsonNative ?? undefined;
26
+ }
27
+ export async function importJson(url, options) {
28
+ const forceFetch = options?.forceFetch ?? false;
29
+ const fetchCache = options?.fetchCache ?? new Map();
30
+ const signal = options?.signal ?? null;
31
+ let result;
32
+ const native = !forceFetch ? getImportJsonNative() : undefined;
33
+ if (native) {
34
+ try {
35
+ log('Dynamic import', url);
36
+ const module = await native(url);
37
+ result = module.default;
38
+ }
39
+ catch {
40
+ log('Dynamic import not available');
41
+ importJsonNative = null;
42
+ }
43
+ }
44
+ if (result === undefined) {
45
+ if (url.startsWith('file:') && !forceFetch) {
46
+ log('Read from file system', url);
47
+ result = await readJsonFromFs(new URL(url));
48
+ }
49
+ else {
50
+ log('Fetch', url);
51
+ result = await loadJson(url, fetchCache, signal);
52
+ }
53
+ }
54
+ options?.importCallback?.(url);
55
+ // Guard against accidental mutations of the imported JSON
56
+ if (typeof result === 'object' && result !== null && !jsonFrozen.has(result)) {
57
+ deepFreeze(result);
58
+ jsonFrozen.add(result);
59
+ }
60
+ return result;
61
+ }
62
+ async function loadJson(url, fetchCache, signal) {
63
+ const key = '__loadJson__' + url;
64
+ if (fetchCache.has(key)) {
65
+ return fetchCache.get(key);
66
+ }
67
+ const dataPromise = (async () => {
68
+ const response = await fetchWithRetry(url, { signal });
69
+ if (!response.ok) {
70
+ throw new Error(`Failed to fetch JSON from ${url}`);
71
+ }
72
+ return (await response.json());
73
+ })();
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
+ });
82
+ return dataPromise;
83
+ }
84
+ async function readJsonFromFs(url) {
85
+ if (url.protocol !== 'file:') {
86
+ throw new Error(`readJsonFromFs only supports file: URLs, got ${url.href}`);
87
+ }
88
+ const fs = await importFsPromises();
89
+ const data = await fs.readFile(url, { encoding: 'utf-8' });
90
+ return JSON.parse(data);
91
+ }
@@ -0,0 +1,2 @@
1
+ export declare function importNodeModule<T>(moduleName: string): Promise<T>;
2
+ //# sourceMappingURL=import-node-module.d.ts.map
@@ -0,0 +1,16 @@
1
+ export async function importNodeModule(moduleName) {
2
+ if (typeof process === 'undefined' || !process.versions?.node) {
3
+ throw new Error('importNodeModule can only be used in Node.js environments');
4
+ }
5
+ try {
6
+ // prevents every bundler (Vite, Rollup, webpack alike) from analyzing the specifier
7
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call
8
+ return (await new Function('moduleName', 'return import(moduleName)')(moduleName));
9
+ }
10
+ catch {
11
+ // A `new Function` scope has no dynamic-import callback under module
12
+ // runners like vitest's — fall back to a literal import, hidden from
13
+ // bundlers by the magic comments instead.
14
+ return (await import(/* @vite-ignore */ /* webpackIgnore: true */ moduleName));
15
+ }
16
+ }
@@ -0,0 +1,2 @@
1
+ export declare function importRuntimeJson<T = unknown>(fileName: string): Promise<T>;
2
+ //# sourceMappingURL=import-runtime-json.d.ts.map
@@ -0,0 +1,10 @@
1
+ import debug from 'debug';
2
+ import { importJson } from './import-json.js';
3
+ const log = debug('efp:utils:importRuntimeJson');
4
+ export async function importRuntimeJson(fileName) {
5
+ // ignore because vite may rewrite '.' to data:...
6
+ const baseUrl = new URL(/* @vite-ignore */ '.', import.meta.url).href;
7
+ const url = new URL(fileName, baseUrl);
8
+ log('Loading runtime JSON from', url.href);
9
+ return await importJson(url.href);
10
+ }
@@ -0,0 +1,3 @@
1
+ /** @deprecated we should never load/eval scripts */
2
+ export declare function loadScript(scriptUrl: string, signal?: AbortSignal): Promise<void>;
3
+ //# sourceMappingURL=load-script.d.ts.map
@@ -0,0 +1,108 @@
1
+ /// <reference lib="dom" />
2
+ import debug from 'debug';
3
+ import { fetchWithRetry } from './fetch-with-retry.js';
4
+ const log = debug('efp:utils:loadScript');
5
+ function isBrowser() {
6
+ return typeof window !== 'undefined' && typeof document !== 'undefined';
7
+ }
8
+ function isAbortError(err) {
9
+ return err instanceof Error && err.name === 'AbortError';
10
+ }
11
+ /** @deprecated we should never load/eval scripts */
12
+ export async function loadScript(scriptUrl, signal) {
13
+ log('Loading', scriptUrl);
14
+ if (isBrowser()) {
15
+ await loadInBrowser(scriptUrl, signal);
16
+ }
17
+ else {
18
+ await loadInNode(scriptUrl, signal);
19
+ }
20
+ }
21
+ /* -------------------- Browser implementation -------------------- */
22
+ function loadInBrowser(scriptUrl, signal) {
23
+ return new Promise((resolve, reject) => {
24
+ const script = document.createElement('script');
25
+ script.src = scriptUrl;
26
+ script.async = true;
27
+ const cleanup = () => {
28
+ script.onload = null;
29
+ script.onerror = null;
30
+ script.remove();
31
+ };
32
+ const onAbort = () => {
33
+ cleanup();
34
+ reject(new Error('Script load aborted'));
35
+ };
36
+ if (signal?.aborted) {
37
+ cleanup();
38
+ reject(new Error('Script load aborted'));
39
+ return;
40
+ }
41
+ signal?.addEventListener('abort', onAbort, { once: true });
42
+ script.onload = () => {
43
+ signal?.removeEventListener('abort', onAbort);
44
+ cleanup();
45
+ resolve();
46
+ };
47
+ script.onerror = () => {
48
+ signal?.removeEventListener('abort', onAbort);
49
+ cleanup();
50
+ reject(new Error(`Failed to load script: ${scriptUrl}`));
51
+ };
52
+ document.head.appendChild(script);
53
+ });
54
+ }
55
+ /* -------------------- Node implementation -------------------- */
56
+ async function loadInNode(scriptUrl, signal) {
57
+ if (signal?.aborted) {
58
+ throw new Error('Script load aborted');
59
+ }
60
+ let res;
61
+ try {
62
+ res = await fetchWithRetry(scriptUrl, { signal });
63
+ }
64
+ catch (err) {
65
+ // Node / WHATWG fetch uses AbortError on abort
66
+ if (isAbortError(err)) {
67
+ throw new Error('Script load aborted', { cause: err });
68
+ }
69
+ throw err;
70
+ }
71
+ if (!res.ok) {
72
+ throw new Error(`Failed to load script in Node (HTTP ${res.status}): ${scriptUrl}`);
73
+ }
74
+ // If it was aborted between headers and body:
75
+ if (signal?.aborted) {
76
+ throw new Error('Script load aborted');
77
+ }
78
+ let code;
79
+ try {
80
+ code = await res.text();
81
+ }
82
+ catch (err) {
83
+ if (isAbortError(err)) {
84
+ throw new Error('Script load aborted', { cause: err });
85
+ }
86
+ throw err;
87
+ }
88
+ if (signal?.aborted) {
89
+ throw new Error('Script load aborted');
90
+ }
91
+ const g = globalThis;
92
+ const prevWindow = g.window;
93
+ g.window = g;
94
+ try {
95
+ // Indirect eval -> global scope in Node
96
+ // Assumes the loaded script attaches itself to globalThis/window/global.
97
+ (0, eval)(code);
98
+ }
99
+ finally {
100
+ // Restore previous window (if any)
101
+ if (prevWindow === undefined) {
102
+ delete g.window;
103
+ }
104
+ else {
105
+ g.window = prevWindow;
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Makes a local file path from a URL, preserving extension and automatically adding hash for files.
3
+ *
4
+ * @param url Full URL object (e.g., new URL('http://demo.expofp.com/data/path/to/file.ext?version=12345'))
5
+ * @param filenameSuffix Optional suffix to add before file extension (e.g., '_small', '_medium', '_large'). Not allowed for directories.
6
+ *
7
+ * @returns
8
+ * For files: http-demo-expofp-com/data/path/to/fullhashoffullurl/filename_small.ext (hash is automatically added based on full URL including query params)
9
+ * For directories: http-demo-expofp-com/data/path/to/dir/ (no hash added, trailing slash preserved)
10
+ *
11
+ * @remarks
12
+ * - All path segments (protocol, domain, directories) are slugified to avoid special characters
13
+ * - Filename is slugified and truncated to max 50 characters (excluding extension)
14
+ * - Hash is automatically added for files (based on full URL including query params)
15
+ * - Hash is never added for directories (paths ending with /)
16
+ * - Query parameters affect the hash for files, making different versions have unique local paths
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * // File with query params
21
+ * const url1 = new URL('http://demo.expofp.com/image.jpg?v=1');
22
+ * await makeLocalPath(url1); // http-demo-expofp-com/ABC123XYZ/image.jpg
23
+ *
24
+ * const url2 = new URL('http://demo.expofp.com/image.jpg?v=2');
25
+ * await makeLocalPath(url2); // http-demo-expofp-com/DEF456UVW/image.jpg (different hash)
26
+ *
27
+ * // File with suffix
28
+ * const url3 = new URL('http://demo.expofp.com/image.jpg');
29
+ * await makeLocalPath(url3, '_small'); // http-demo-expofp-com/GHI789RST/image_small.jpg
30
+ *
31
+ * // Directory
32
+ * const url4 = new URL('http://demo.expofp.com/data/');
33
+ * await makeLocalPath(url4); // http-demo-expofp-com/data/
34
+ * ```
35
+ */
36
+ export declare function makeLocalPath(url: URL, filenameSuffix?: string): Promise<string>;
37
+ //# sourceMappingURL=make-local-path.d.ts.map
@@ -0,0 +1,100 @@
1
+ import debug from 'debug';
2
+ import { safeSlugify } from './safe-slugify.js';
3
+ import { shortHash } from './short-hash.js';
4
+ const log = debug('efp:utils:makeLocalPath');
5
+ /**
6
+ * Makes a local file path from a URL, preserving extension and automatically adding hash for files.
7
+ *
8
+ * @param url Full URL object (e.g., new URL('http://demo.expofp.com/data/path/to/file.ext?version=12345'))
9
+ * @param filenameSuffix Optional suffix to add before file extension (e.g., '_small', '_medium', '_large'). Not allowed for directories.
10
+ *
11
+ * @returns
12
+ * For files: http-demo-expofp-com/data/path/to/fullhashoffullurl/filename_small.ext (hash is automatically added based on full URL including query params)
13
+ * For directories: http-demo-expofp-com/data/path/to/dir/ (no hash added, trailing slash preserved)
14
+ *
15
+ * @remarks
16
+ * - All path segments (protocol, domain, directories) are slugified to avoid special characters
17
+ * - Filename is slugified and truncated to max 50 characters (excluding extension)
18
+ * - Hash is automatically added for files (based on full URL including query params)
19
+ * - Hash is never added for directories (paths ending with /)
20
+ * - Query parameters affect the hash for files, making different versions have unique local paths
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * // File with query params
25
+ * const url1 = new URL('http://demo.expofp.com/image.jpg?v=1');
26
+ * await makeLocalPath(url1); // http-demo-expofp-com/ABC123XYZ/image.jpg
27
+ *
28
+ * const url2 = new URL('http://demo.expofp.com/image.jpg?v=2');
29
+ * await makeLocalPath(url2); // http-demo-expofp-com/DEF456UVW/image.jpg (different hash)
30
+ *
31
+ * // File with suffix
32
+ * const url3 = new URL('http://demo.expofp.com/image.jpg');
33
+ * await makeLocalPath(url3, '_small'); // http-demo-expofp-com/GHI789RST/image_small.jpg
34
+ *
35
+ * // Directory
36
+ * const url4 = new URL('http://demo.expofp.com/data/');
37
+ * await makeLocalPath(url4); // http-demo-expofp-com/data/
38
+ * ```
39
+ */
40
+ export async function makeLocalPath(url, filenameSuffix) {
41
+ // // Normalize baseUrl to end with /
42
+ // const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
43
+ // // Combine to get full URL
44
+ // const fullUrl = new URL(relativeUrl || '', normalizedBase).href;
45
+ // Parse the URL
46
+ // const url = new URL(fullUrl);
47
+ // Check if this is a directory (ends with / or is empty/root)
48
+ const isDirectory = url.pathname.endsWith('/') || url.pathname === '';
49
+ const addHash = !isDirectory;
50
+ // Validate parameters for directories
51
+ if (isDirectory && filenameSuffix) {
52
+ throw new Error('filenameSuffix is not allowed for directory paths');
53
+ }
54
+ // Create hash if requested (using full URL)
55
+ const hash = addHash ? await shortHash(url.href) : null;
56
+ // Slugify protocol and domain (e.g., "http-demo-expofp-com")
57
+ const protocol = safeSlugify(url.protocol.replace(':', ''));
58
+ const domain = safeSlugify(url.hostname);
59
+ const port = url.port ? `-${url.port}` : '';
60
+ const protocolDomain = `${protocol}-${domain}${port}`;
61
+ // Parse the path
62
+ const pathParts = url.pathname.split('/').filter(Boolean);
63
+ if (isDirectory) {
64
+ // For directories, all parts are directory parts
65
+ const slugifiedDirs = pathParts.map((part) => safeSlugify(part));
66
+ // Build the full local path with trailing /
67
+ const pathSegments = hash
68
+ ? [protocolDomain, ...slugifiedDirs, hash]
69
+ : [protocolDomain, ...slugifiedDirs];
70
+ return pathSegments.join('/') + '/';
71
+ }
72
+ // For files: Extract filename (last part) and directory parts
73
+ const filename = pathParts.pop() ?? '';
74
+ const directoryParts = pathParts;
75
+ // Slugify directory parts
76
+ const slugifiedDirs = directoryParts.map((part) => safeSlugify(part));
77
+ // Extract extension and base filename
78
+ const lastDotIndex = filename.lastIndexOf('.');
79
+ const extension = lastDotIndex !== -1 ? filename.slice(lastDotIndex) : '';
80
+ const baseFilename = lastDotIndex !== -1 ? filename.slice(0, lastDotIndex) : filename;
81
+ // Slugify the base filename
82
+ let slugifiedFilename = safeSlugify(baseFilename);
83
+ // Ensure filename is max 50 characters (not including extension)
84
+ const maxFilenameLength = 50;
85
+ if (slugifiedFilename.length > maxFilenameLength) {
86
+ log(`Filename truncated from ${slugifiedFilename.length} to ${maxFilenameLength} characters: ${slugifiedFilename}`);
87
+ slugifiedFilename = slugifiedFilename.slice(0, maxFilenameLength);
88
+ }
89
+ // Add suffix if provided (before extension)
90
+ if (filenameSuffix) {
91
+ slugifiedFilename = `${slugifiedFilename}${filenameSuffix}`;
92
+ }
93
+ // Combine filename with extension
94
+ const finalFilename = `${slugifiedFilename}${extension}`;
95
+ // Build the full local path
96
+ const pathSegments = hash
97
+ ? [protocolDomain, ...slugifiedDirs, hash, finalFilename]
98
+ : [protocolDomain, ...slugifiedDirs, finalFilename];
99
+ return pathSegments.join('/');
100
+ }
@@ -0,0 +1,11 @@
1
+ type ReadFileFromUrlResult = {
2
+ ok: true;
3
+ data: Uint8Array;
4
+ } | {
5
+ ok: false;
6
+ };
7
+ export declare function readFileFromUrl(url: URL, options?: {
8
+ signal?: AbortSignal;
9
+ }): Promise<ReadFileFromUrlResult>;
10
+ export {};
11
+ //# sourceMappingURL=read-file-from-url.d.ts.map
@@ -0,0 +1,17 @@
1
+ import { fetchWithRetry } from './fetch-with-retry.js';
2
+ import { importFsPromises } from './import-fs-promises.js';
3
+ export async function readFileFromUrl(url, options) {
4
+ if (url.protocol === 'file:') {
5
+ const { readFile } = await importFsPromises();
6
+ const data = await readFile(url, { signal: options?.signal });
7
+ return { ok: true, data };
8
+ }
9
+ else {
10
+ const response = await fetchWithRetry(url, { signal: options?.signal });
11
+ if (!response.ok) {
12
+ return { ok: false };
13
+ }
14
+ const arrayBuffer = await response.arrayBuffer();
15
+ return { ok: true, data: new Uint8Array(arrayBuffer) };
16
+ }
17
+ }
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Generates a filesystem-safe slug from input string, ensuring uniqueness.
3
+ * @param input
4
+ * @returns
5
+ */
6
+ export declare function safeSlugify(input: string): string;
7
+ //# sourceMappingURL=safe-slugify.d.ts.map
@@ -0,0 +1,80 @@
1
+ // Module-level caches
2
+ const inputToSlug = new Map();
3
+ const slugToInput = new Map();
4
+ const WINDOWS_RESERVED = new Set([
5
+ 'con',
6
+ 'prn',
7
+ 'aux',
8
+ 'nul',
9
+ 'com1',
10
+ 'com2',
11
+ 'com3',
12
+ 'com4',
13
+ 'com5',
14
+ 'com6',
15
+ 'com7',
16
+ 'com8',
17
+ 'com9',
18
+ 'lpt1',
19
+ 'lpt2',
20
+ 'lpt3',
21
+ 'lpt4',
22
+ 'lpt5',
23
+ 'lpt6',
24
+ 'lpt7',
25
+ 'lpt8',
26
+ 'lpt9',
27
+ ]);
28
+ function makeBaseSlug(input) {
29
+ let slug = input
30
+ .normalize('NFKD')
31
+ // All non letters/numbers → "-"
32
+ .replace(/[^\p{Letter}\p{Number}]+/gu, '-')
33
+ // Collapse multiple "-"
34
+ .replace(/-+/g, '-')
35
+ // Trim "-" from start/end
36
+ .replace(/^-|-$/g, '')
37
+ .toLowerCase();
38
+ // Strip forbidden Windows characters just in case
39
+ slug = slug.replace(/[<>:"/\\|?*]/g, '');
40
+ // Windows forbids trailing space/period
41
+ slug = slug.replace(/[. ]+$/g, '');
42
+ // Fallback if everything was stripped
43
+ if (!slug)
44
+ slug = 'file';
45
+ // Avoid bare reserved device names
46
+ if (WINDOWS_RESERVED.has(slug)) {
47
+ slug += '-file';
48
+ }
49
+ return slug;
50
+ }
51
+ /**
52
+ * Generates a filesystem-safe slug from input string, ensuring uniqueness.
53
+ * @param input
54
+ * @returns
55
+ */
56
+ export function safeSlugify(input) {
57
+ // If we've seen this exact input before, return the same slug
58
+ const existing = inputToSlug.get(input);
59
+ if (existing)
60
+ return existing;
61
+ const base = makeBaseSlug(input);
62
+ let candidate = base;
63
+ let counter = 2;
64
+ while (true) {
65
+ const existingInput = slugToInput.get(candidate);
66
+ if (!existingInput) {
67
+ // Free slug → claim it for this input
68
+ slugToInput.set(candidate, input);
69
+ inputToSlug.set(input, candidate);
70
+ return candidate;
71
+ }
72
+ if (existingInput === input) {
73
+ // Same input somehow (super defensive)
74
+ inputToSlug.set(input, candidate);
75
+ return candidate;
76
+ }
77
+ // Collision: same slug already used by different input → add suffix
78
+ candidate = `${base}-${counter++}`;
79
+ }
80
+ }
@@ -0,0 +1,2 @@
1
+ export declare function shortHash(input: string, bytes?: number): Promise<string>;
2
+ //# sourceMappingURL=short-hash.d.ts.map
@@ -0,0 +1,24 @@
1
+ export async function shortHash(input, bytes = 16) {
2
+ const data = new TextEncoder().encode(input);
3
+ const digest = await crypto.subtle.digest('SHA-256', data);
4
+ const full = new Uint8Array(digest);
5
+ return toBase62(full.subarray(0, bytes));
6
+ }
7
+ const BASE62_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
8
+ function toBase62(bytes) {
9
+ // TODO: check if this ArrayBuffer guard is still needed — parameter is typed Uint8Array
10
+ const u8 = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
11
+ // Convert bytes -> BigInt (no Buffer required)
12
+ let num = 0n;
13
+ // eslint-disable-next-line compat/compat
14
+ for (const b of u8)
15
+ num = (num << 8n) + BigInt(b);
16
+ if (num === 0n)
17
+ return '0';
18
+ let result = '';
19
+ while (num > 0n) {
20
+ result = BASE62_CHARS[Number(num % 62n)] + result;
21
+ num /= 62n;
22
+ }
23
+ return result;
24
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@expofp/utils",
3
+ "version": "0.0.0-experimental.2d6c681",
4
+ "type": "module",
5
+ "description": "ExpoFP SDK internal: shared utilities",
6
+ "homepage": "https://developer.expofp.com/",
7
+ "license": "MIT",
8
+ "sideEffects": false,
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "main": "./dist/index.js",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ "./package.json": "./package.json",
17
+ ".": {
18
+ "@expofp/source": "./src/index.ts",
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js",
21
+ "default": "./dist/index.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "!**/*.tsbuildinfo",
27
+ "!**/*.map"
28
+ ],
29
+ "dependencies": {
30
+ "debug": "^4.4.3",
31
+ "fetch-retry": "^6.0.0",
32
+ "jszip": "^3.10.1",
33
+ "tslib": "^2.3.0"
34
+ }
35
+ }