@expofp/utils 0.0.0-experimental.cf063fc
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 +17 -0
- package/dist/index.js +16 -0
- package/dist/lib/build-zip-archive.d.ts +5 -0
- package/dist/lib/build-zip-archive.js +40 -0
- package/dist/lib/clone.d.ts +3 -0
- package/dist/lib/clone.js +14 -0
- package/dist/lib/deep-freeze.d.ts +2 -0
- package/dist/lib/deep-freeze.js +11 -0
- package/dist/lib/fetch-with-retry.d.ts +8 -0
- package/dist/lib/fetch-with-retry.js +21 -0
- package/dist/lib/import-fs-promises.d.ts +2 -0
- package/dist/lib/import-fs-promises.js +4 -0
- package/dist/lib/import-js-as-json.d.ts +52 -0
- package/dist/lib/import-js-as-json.js +124 -0
- package/dist/lib/import-json.d.ts +11 -0
- package/dist/lib/import-json.js +91 -0
- package/dist/lib/import-node-module.d.ts +2 -0
- package/dist/lib/import-node-module.js +16 -0
- package/dist/lib/import-runtime-json.d.ts +2 -0
- package/dist/lib/import-runtime-json.js +10 -0
- package/dist/lib/load-script.d.ts +3 -0
- package/dist/lib/load-script.js +108 -0
- package/dist/lib/make-local-path.d.ts +37 -0
- package/dist/lib/make-local-path.js +100 -0
- package/dist/lib/read-file-from-url.d.ts +11 -0
- package/dist/lib/read-file-from-url.js +17 -0
- package/dist/lib/safe-decode.d.ts +8 -0
- package/dist/lib/safe-decode.js +14 -0
- package/dist/lib/safe-local-storage.d.ts +11 -0
- package/dist/lib/safe-local-storage.js +18 -0
- package/dist/lib/safe-slugify.d.ts +7 -0
- package/dist/lib/safe-slugify.js +80 -0
- package/dist/lib/short-hash.d.ts +2 -0
- package/dist/lib/short-hash.js +24 -0
- package/package.json +35 -0
package/dist/index.d.ts
ADDED
|
@@ -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,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,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,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,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,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,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,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,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,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.cf063fc",
|
|
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
|
+
}
|