@expofp/utils 3.0.0-alpha.3
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 +14 -0
- package/dist/index.js +13 -0
- package/dist/lib/buildZipArchive.d.ts +5 -0
- package/dist/lib/buildZipArchive.js +40 -0
- package/dist/lib/clone.d.ts +3 -0
- package/dist/lib/clone.js +14 -0
- package/dist/lib/deepFreeze.d.ts +2 -0
- package/dist/lib/deepFreeze.js +11 -0
- package/dist/lib/importFsPromises.d.ts +2 -0
- package/dist/lib/importFsPromises.js +4 -0
- package/dist/lib/importJsonModule.d.ts +11 -0
- package/dist/lib/importJsonModule.js +78 -0
- package/dist/lib/importNodeModule.d.ts +2 -0
- package/dist/lib/importNodeModule.js +7 -0
- package/dist/lib/importRuntimeJsonFile.d.ts +2 -0
- package/dist/lib/importRuntimeJsonFile.js +10 -0
- package/dist/lib/importRuntimeJsonModule.d.ts +2 -0
- package/dist/lib/importRuntimeJsonModule.js +10 -0
- package/dist/lib/loadScript.d.ts +3 -0
- package/dist/lib/loadScript.js +108 -0
- package/dist/lib/makeLocalPath.d.ts +37 -0
- package/dist/lib/makeLocalPath.js +100 -0
- package/dist/lib/niceFetch.d.ts +8 -0
- package/dist/lib/niceFetch.js +21 -0
- package/dist/lib/readFileFromUrl.d.ts +11 -0
- package/dist/lib/readFileFromUrl.js +17 -0
- package/dist/lib/sha256Base62Truncated.d.ts +2 -0
- package/dist/lib/sha256Base62Truncated.js +23 -0
- package/dist/lib/slugifyFilesystemUnique.d.ts +7 -0
- package/dist/lib/slugifyFilesystemUnique.js +80 -0
- package/package.json +33 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './lib/buildZipArchive.js';
|
|
2
|
+
export * from './lib/clone.js';
|
|
3
|
+
export * from './lib/deepFreeze.js';
|
|
4
|
+
export * from './lib/importFsPromises.js';
|
|
5
|
+
export * from './lib/importJsonModule.js';
|
|
6
|
+
export * from './lib/importNodeModule.js';
|
|
7
|
+
export * from './lib/importRuntimeJsonModule.js';
|
|
8
|
+
export * from './lib/loadScript.js';
|
|
9
|
+
export * from './lib/makeLocalPath.js';
|
|
10
|
+
export * from './lib/niceFetch.js';
|
|
11
|
+
export * from './lib/readFileFromUrl.js';
|
|
12
|
+
export * from './lib/sha256Base62Truncated.js';
|
|
13
|
+
export * from './lib/slugifyFilesystemUnique.js';
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './lib/buildZipArchive.js';
|
|
2
|
+
export * from './lib/clone.js';
|
|
3
|
+
export * from './lib/deepFreeze.js';
|
|
4
|
+
export * from './lib/importFsPromises.js';
|
|
5
|
+
export * from './lib/importJsonModule.js';
|
|
6
|
+
export * from './lib/importNodeModule.js';
|
|
7
|
+
export * from './lib/importRuntimeJsonModule.js';
|
|
8
|
+
export * from './lib/loadScript.js';
|
|
9
|
+
export * from './lib/makeLocalPath.js';
|
|
10
|
+
export * from './lib/niceFetch.js';
|
|
11
|
+
export * from './lib/readFileFromUrl.js';
|
|
12
|
+
export * from './lib/sha256Base62Truncated.js';
|
|
13
|
+
export * from './lib/slugifyFilesystemUnique.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,11 @@
|
|
|
1
|
+
export interface ImportJsonModuleOptions {
|
|
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 importJsonModule<T = unknown>(url: string, options?: ImportJsonModuleOptions): Promise<T>;
|
|
11
|
+
//# sourceMappingURL=importJsonModule.d.ts.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
import { deepFreeze } from './deepFreeze.js';
|
|
3
|
+
import { importFsPromises } from './importFsPromises.js';
|
|
4
|
+
import { niceFetch } from './niceFetch.js';
|
|
5
|
+
const log = debug('efp:utils:importJsonModule');
|
|
6
|
+
let importJsonNotAvailable;
|
|
7
|
+
const jsonFrozen = new WeakSet();
|
|
8
|
+
// ET: this is a workaround for Vite that analyzes dynamic imports and removes 'with' option
|
|
9
|
+
// to be removed when Vite supports it properly
|
|
10
|
+
// Cons: can have CSP issues in some environments
|
|
11
|
+
const importJsonNative = new Function('url', 'return import(url, { with: { type: "json" } });');
|
|
12
|
+
export async function importJsonModule(url, options) {
|
|
13
|
+
const opts = {
|
|
14
|
+
forceFetch: options?.forceFetch || false,
|
|
15
|
+
fetchCache: options?.fetchCache || new Map(),
|
|
16
|
+
importCallback: options?.importCallback,
|
|
17
|
+
signal: options?.signal || null,
|
|
18
|
+
};
|
|
19
|
+
// console.warn('importJson:', resolveContext.forceFetch);
|
|
20
|
+
if (importJsonNotAvailable === undefined && !opts.forceFetch) {
|
|
21
|
+
try {
|
|
22
|
+
await importJsonNative(url);
|
|
23
|
+
importJsonNotAvailable = false;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
log('Dynamic import not available');
|
|
27
|
+
importJsonNotAvailable = true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
let result = undefined;
|
|
31
|
+
if (!importJsonNotAvailable && !opts.forceFetch) {
|
|
32
|
+
log('Dynamic import', url);
|
|
33
|
+
const module = await importJsonNative(url);
|
|
34
|
+
result = module.default;
|
|
35
|
+
}
|
|
36
|
+
else if (url.startsWith('file:') && !opts.forceFetch) {
|
|
37
|
+
log('Read from file system', url);
|
|
38
|
+
result = await readJsonFromFs(new URL(url));
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
log('Fetch', url);
|
|
42
|
+
result = await loadJson(url, opts.fetchCache, opts.signal || null);
|
|
43
|
+
}
|
|
44
|
+
// log('Callback for imported JSON URL:', url, !!opts.importCallback);
|
|
45
|
+
opts.importCallback?.(url);
|
|
46
|
+
// Freeze the imported JSON to make it immutable
|
|
47
|
+
// This is rather a guard against accidental mutations
|
|
48
|
+
if (typeof result === 'object' && result !== null && !jsonFrozen.has(result)) {
|
|
49
|
+
deepFreeze(result);
|
|
50
|
+
jsonFrozen.add(result);
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
// const fetchCache = new Map<string, Promise<any>>();
|
|
55
|
+
async function loadJson(url, fetchCache, signal) {
|
|
56
|
+
const key = '__loadJson__' + url;
|
|
57
|
+
if (fetchCache.has(key)) {
|
|
58
|
+
return fetchCache.get(key);
|
|
59
|
+
}
|
|
60
|
+
const dataPromise = (async function loadJsonInner() {
|
|
61
|
+
// in browser just use fetch, for node - fetch-retry
|
|
62
|
+
const response = await niceFetch(url, { signal });
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
throw new Error(`Failed to fetch JSON from ${url}`);
|
|
65
|
+
}
|
|
66
|
+
return await response.json();
|
|
67
|
+
})();
|
|
68
|
+
fetchCache.set(key, dataPromise);
|
|
69
|
+
return dataPromise;
|
|
70
|
+
}
|
|
71
|
+
async function readJsonFromFs(url) {
|
|
72
|
+
if (url.protocol !== 'file:') {
|
|
73
|
+
throw new Error(`readJsonFromFs only supports file: URLs, got ${url.href}`);
|
|
74
|
+
}
|
|
75
|
+
const fs = await importFsPromises();
|
|
76
|
+
const data = await fs.readFile(url, { encoding: 'utf-8' });
|
|
77
|
+
return JSON.parse(data);
|
|
78
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
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
|
+
// prevents Vite/Rollup from analyzing the import specifier
|
|
6
|
+
return new Function(`return import('${moduleName}')`)();
|
|
7
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
import { importJsonModule } from './importJsonModule.js';
|
|
3
|
+
const log = debug('efp:utils:importRuntimeJsonModule');
|
|
4
|
+
export async function importRuntimeJsonModule(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 importJsonModule(url.href);
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
import { importJsonModule } from './importJsonModule.js';
|
|
3
|
+
const log = debug('efp:utils:importRuntimeJsonModule');
|
|
4
|
+
export async function importRuntimeJsonModule(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 importJsonModule(url.href);
|
|
10
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import debug from 'debug';
|
|
3
|
+
import { niceFetch } from './niceFetch.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 niceFetch(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');
|
|
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');
|
|
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=makeLocalPath.d.ts.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
import { sha256Base62Truncated } from './sha256Base62Truncated.js';
|
|
3
|
+
import { slugifyFilesystemUnique } from './slugifyFilesystemUnique.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 sha256Base62Truncated(url.href) : null;
|
|
56
|
+
// Slugify protocol and domain (e.g., "http-demo-expofp-com")
|
|
57
|
+
const protocol = slugifyFilesystemUnique(url.protocol.replace(':', ''));
|
|
58
|
+
const domain = slugifyFilesystemUnique(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) => slugifyFilesystemUnique(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) => slugifyFilesystemUnique(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 = slugifyFilesystemUnique(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,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 niceFetch: (...args: Parameters<typeof window.fetch>) => ReturnType<typeof window.fetch>;
|
|
8
|
+
//# sourceMappingURL=niceFetch.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 niceFetch = 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 fetchWithRetry = fr.default(globalThis.fetch);
|
|
17
|
+
fn = fetchWithRetry;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return fn(...args);
|
|
21
|
+
};
|
|
@@ -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=readFileFromUrl.d.ts.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { importFsPromises } from './importFsPromises.js';
|
|
2
|
+
import { niceFetch } from './niceFetch.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 niceFetch(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,23 @@
|
|
|
1
|
+
export async function sha256Base62Truncated(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
|
+
for (const b of u8)
|
|
14
|
+
num = (num << 8n) + BigInt(b);
|
|
15
|
+
if (num === 0n)
|
|
16
|
+
return '0';
|
|
17
|
+
let result = '';
|
|
18
|
+
while (num > 0n) {
|
|
19
|
+
result = BASE62_CHARS[Number(num % 62n)] + result;
|
|
20
|
+
num /= 62n;
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
@@ -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 slugifyFilesystemUnique(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
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@expofp/utils",
|
|
3
|
+
"version": "3.0.0-alpha.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "ExpoFP SDK internal: shared utilities",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"module": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
"./package.json": "./package.json",
|
|
15
|
+
".": {
|
|
16
|
+
"@expofp/source": "./src/index.ts",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"!**/*.tsbuildinfo",
|
|
25
|
+
"!**/*.map"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"debug": "^4.4.3",
|
|
29
|
+
"fetch-retry": "^6.0.0",
|
|
30
|
+
"jszip": "^3.10.1",
|
|
31
|
+
"tslib": "^2.3.0"
|
|
32
|
+
}
|
|
33
|
+
}
|