@expofp/utils 0.0.0-experimental.d269d30

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,14 @@
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-json.js';
7
+ export * from './lib/import-node-module.js';
8
+ export * from './lib/import-runtime-json.js';
9
+ export * from './lib/load-script.js';
10
+ export * from './lib/make-local-path.js';
11
+ export * from './lib/read-file-from-url.js';
12
+ export * from './lib/safe-slugify.js';
13
+ export * from './lib/short-hash.js';
14
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
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-json.js';
7
+ export * from './lib/import-node-module.js';
8
+ export * from './lib/import-runtime-json.js';
9
+ export * from './lib/load-script.js';
10
+ export * from './lib/make-local-path.js';
11
+ export * from './lib/read-file-from-url.js';
12
+ export * from './lib/safe-slugify.js';
13
+ 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,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,83 @@
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
+ importJsonNative = new Function('url', 'return import(url, { with: { type: "json" } });');
18
+ }
19
+ catch {
20
+ log('Dynamic import not available');
21
+ importJsonNative = null;
22
+ }
23
+ }
24
+ return importJsonNative ?? undefined;
25
+ }
26
+ export async function importJson(url, options) {
27
+ const forceFetch = options?.forceFetch ?? false;
28
+ const fetchCache = options?.fetchCache ?? new Map();
29
+ const signal = options?.signal ?? null;
30
+ let result;
31
+ const native = !forceFetch ? getImportJsonNative() : undefined;
32
+ if (native) {
33
+ try {
34
+ log('Dynamic import', url);
35
+ const module = await native(url);
36
+ result = module.default;
37
+ }
38
+ catch {
39
+ log('Dynamic import not available');
40
+ importJsonNative = null;
41
+ }
42
+ }
43
+ if (result === undefined) {
44
+ if (url.startsWith('file:') && !forceFetch) {
45
+ log('Read from file system', url);
46
+ result = await readJsonFromFs(new URL(url));
47
+ }
48
+ else {
49
+ log('Fetch', url);
50
+ result = await loadJson(url, fetchCache, signal);
51
+ }
52
+ }
53
+ options?.importCallback?.(url);
54
+ // Guard against accidental mutations of the imported JSON
55
+ if (typeof result === 'object' && result !== null && !jsonFrozen.has(result)) {
56
+ deepFreeze(result);
57
+ jsonFrozen.add(result);
58
+ }
59
+ return result;
60
+ }
61
+ async function loadJson(url, fetchCache, signal) {
62
+ const key = '__loadJson__' + url;
63
+ if (fetchCache.has(key)) {
64
+ return fetchCache.get(key);
65
+ }
66
+ const dataPromise = (async () => {
67
+ const response = await fetchWithRetry(url, { signal });
68
+ if (!response.ok) {
69
+ throw new Error(`Failed to fetch JSON from ${url}`);
70
+ }
71
+ return await response.json();
72
+ })();
73
+ fetchCache.set(key, dataPromise);
74
+ return dataPromise;
75
+ }
76
+ async function readJsonFromFs(url) {
77
+ if (url.protocol !== 'file:') {
78
+ throw new Error(`readJsonFromFs only supports file: URLs, got ${url.href}`);
79
+ }
80
+ const fs = await importFsPromises();
81
+ const data = await fs.readFile(url, { encoding: 'utf-8' });
82
+ return JSON.parse(data);
83
+ }
@@ -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,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,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');
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=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,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,23 @@
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
+ 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
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@expofp/utils",
3
+ "version": "0.0.0-experimental.d269d30",
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
+ }