@editframe/assets 0.38.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/idempotentTask.cjs +53 -56
- package/dist/idempotentTask.cjs.map +1 -1
- package/dist/idempotentTask.js +53 -56
- package/dist/idempotentTask.js.map +1 -1
- package/package.json +1 -1
package/dist/idempotentTask.cjs
CHANGED
|
@@ -68,57 +68,56 @@ const idempotentTask = ({ label, filename, runner }) => {
|
|
|
68
68
|
delete downloadTasks[downloadKey];
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
const cacheDirs = await (0, node_fs_promises.readdir)(cacheDirRoot, { withFileTypes: true });
|
|
77
|
-
log(`Scanning ${cacheDirs.length} cache directories for ${expectedFilename}`);
|
|
78
|
-
for (const dir of cacheDirs) if (dir.isDirectory()) {
|
|
79
|
-
const candidatePath = node_path.default.join(cacheDirRoot, dir.name, expectedFilename);
|
|
80
|
-
if ((0, node_fs.existsSync)(candidatePath) && await isValidCacheFile(candidatePath)) {
|
|
81
|
-
cachePath = candidatePath;
|
|
82
|
-
md5 = dir.name;
|
|
83
|
-
log(`Found existing cache in ${Date.now() - scanStartTime}ms: ${candidatePath} (skipped MD5)`);
|
|
84
|
-
break;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (!cachePath) log(`Cache scan completed in ${Date.now() - scanStartTime}ms, no cache found - will compute MD5`);
|
|
88
|
-
} catch (error) {
|
|
89
|
-
log(`Cache scan failed after ${Date.now() - scanStartTime}ms, will compute MD5: ${error}`);
|
|
90
|
-
}
|
|
91
|
-
if (!md5) {
|
|
92
|
-
const md5StartTime = Date.now();
|
|
93
|
-
log(`Computing MD5 for ${absolutePath}...`);
|
|
94
|
-
md5 = await require_md5.md5FilePath(absolutePath);
|
|
95
|
-
log(`MD5 computed in ${Date.now() - md5StartTime}ms: ${md5}`);
|
|
96
|
-
}
|
|
97
|
-
const cacheDir = node_path.default.join(cacheDirRoot, md5);
|
|
98
|
-
log(`Cache dir: ${cacheDir}`);
|
|
99
|
-
await (0, node_fs_promises.mkdir)(cacheDir, { recursive: true });
|
|
100
|
-
if (!cachePath) cachePath = node_path.default.join(cacheDir, expectedFilename);
|
|
101
|
-
const key = cachePath;
|
|
102
|
-
if ((0, node_fs.existsSync)(cachePath) && await isValidCacheFile(cachePath)) {
|
|
103
|
-
log(`Returning cached ef:${label} task for ${key}`);
|
|
104
|
-
return {
|
|
105
|
-
cachePath,
|
|
106
|
-
md5Sum: md5
|
|
107
|
-
};
|
|
71
|
+
const inputKey = JSON.stringify([absolutePath, ...args]);
|
|
72
|
+
if (tasks[inputKey]) {
|
|
73
|
+
log(`Returning existing ef:${label} task for ${absolutePath}`);
|
|
74
|
+
return await tasks[inputKey];
|
|
108
75
|
}
|
|
109
|
-
const maybeTask = tasks[key];
|
|
110
|
-
if (maybeTask) {
|
|
111
|
-
log(`Returning existing ef:${label} task for ${key}`);
|
|
112
|
-
return await maybeTask;
|
|
113
|
-
}
|
|
114
|
-
log(`Creating new ef:${label} task for ${key}`);
|
|
115
76
|
const fullTask = (async () => {
|
|
116
77
|
try {
|
|
117
|
-
|
|
78
|
+
const expectedFilename = filename(absolutePath, ...args);
|
|
79
|
+
let cachePath = null;
|
|
80
|
+
let md5 = null;
|
|
81
|
+
const scanStartTime = Date.now();
|
|
82
|
+
try {
|
|
83
|
+
const cacheDirs = await (0, node_fs_promises.readdir)(cacheDirRoot, { withFileTypes: true });
|
|
84
|
+
log(`Scanning ${cacheDirs.length} cache directories for ${expectedFilename}`);
|
|
85
|
+
for (const dir of cacheDirs) if (dir.isDirectory()) {
|
|
86
|
+
const candidatePath = node_path.default.join(cacheDirRoot, dir.name, expectedFilename);
|
|
87
|
+
if ((0, node_fs.existsSync)(candidatePath) && await isValidCacheFile(candidatePath)) {
|
|
88
|
+
cachePath = candidatePath;
|
|
89
|
+
md5 = dir.name;
|
|
90
|
+
log(`Found existing cache in ${Date.now() - scanStartTime}ms: ${candidatePath} (skipped MD5)`);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!cachePath) log(`Cache scan completed in ${Date.now() - scanStartTime}ms, no cache found - will compute MD5`);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
log(`Cache scan failed after ${Date.now() - scanStartTime}ms, will compute MD5: ${error}`);
|
|
97
|
+
}
|
|
98
|
+
const resolvedMd5 = md5 ?? await (async () => {
|
|
99
|
+
const md5StartTime = Date.now();
|
|
100
|
+
log(`Computing MD5 for ${absolutePath}...`);
|
|
101
|
+
const computed = await require_md5.md5FilePath(absolutePath);
|
|
102
|
+
log(`MD5 computed in ${Date.now() - md5StartTime}ms: ${computed}`);
|
|
103
|
+
return computed;
|
|
104
|
+
})();
|
|
105
|
+
const cacheDir = node_path.default.join(cacheDirRoot, resolvedMd5);
|
|
106
|
+
log(`Cache dir: ${cacheDir}`);
|
|
107
|
+
await (0, node_fs_promises.mkdir)(cacheDir, { recursive: true });
|
|
108
|
+
const resolvedCachePath = cachePath ?? node_path.default.join(cacheDir, expectedFilename);
|
|
109
|
+
if ((0, node_fs.existsSync)(resolvedCachePath) && await isValidCacheFile(resolvedCachePath)) {
|
|
110
|
+
log(`Returning cached ef:${label} task for ${resolvedCachePath}`);
|
|
111
|
+
return {
|
|
112
|
+
cachePath: resolvedCachePath,
|
|
113
|
+
md5Sum: resolvedMd5
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
log(`Running ef:${label} runner for ${resolvedCachePath}`);
|
|
118
117
|
const result = await runner(absolutePath, ...args);
|
|
119
118
|
if (result instanceof node_stream.Readable) {
|
|
120
|
-
log(`Piping task for ${
|
|
121
|
-
const tempPath = `${
|
|
119
|
+
log(`Piping task for ${resolvedCachePath} to cache`);
|
|
120
|
+
const tempPath = `${resolvedCachePath}.tmp`;
|
|
122
121
|
const writeStream = (0, node_fs.createWriteStream)(tempPath);
|
|
123
122
|
result.pipe(writeStream);
|
|
124
123
|
await new Promise((resolve, reject) => {
|
|
@@ -127,22 +126,20 @@ const idempotentTask = ({ label, filename, runner }) => {
|
|
|
127
126
|
writeStream.on("finish", () => resolve());
|
|
128
127
|
});
|
|
129
128
|
const { rename } = await import("node:fs/promises");
|
|
130
|
-
await rename(tempPath,
|
|
129
|
+
await rename(tempPath, resolvedCachePath);
|
|
131
130
|
} else {
|
|
132
|
-
log(`Writing to ${
|
|
133
|
-
await (0, node_fs_promises.writeFile)(
|
|
131
|
+
log(`Writing to ${resolvedCachePath}`);
|
|
132
|
+
await (0, node_fs_promises.writeFile)(resolvedCachePath, result);
|
|
134
133
|
}
|
|
135
|
-
delete tasks[key];
|
|
136
134
|
return {
|
|
137
|
-
md5Sum:
|
|
138
|
-
cachePath
|
|
135
|
+
md5Sum: resolvedMd5,
|
|
136
|
+
cachePath: resolvedCachePath
|
|
139
137
|
};
|
|
140
|
-
}
|
|
141
|
-
delete tasks[
|
|
142
|
-
throw error;
|
|
138
|
+
} finally {
|
|
139
|
+
delete tasks[inputKey];
|
|
143
140
|
}
|
|
144
141
|
})();
|
|
145
|
-
tasks[
|
|
142
|
+
tasks[inputKey] = fullTask;
|
|
146
143
|
return await fullTask;
|
|
147
144
|
};
|
|
148
145
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"idempotentTask.cjs","names":["tasks: Record<string, Promise<TaskResult>>","downloadTasks: Record<string, Promise<string>>","path","Readable","cachePath: string | null","md5: string | null","md5FilePath"],"sources":["../src/idempotentTask.ts"],"sourcesContent":["import { createWriteStream, existsSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { md5FilePath } from \"./md5.js\";\nimport debug from \"debug\";\nimport { mkdir, writeFile, stat, readdir } from \"node:fs/promises\";\nimport { Readable } from \"node:stream\";\n\ninterface TaskOptions<T extends unknown[]> {\n label: string;\n filename: (absolutePath: string, ...args: T) => string;\n runner: (absolutePath: string, ...args: T) => Promise<string | Readable>;\n}\n\nexport interface TaskResult {\n md5Sum: string;\n cachePath: string;\n}\n\nexport const idempotentTask = <T extends unknown[]>({\n label,\n filename,\n runner,\n}: TaskOptions<T>) => {\n const tasks: Record<string, Promise<TaskResult>> = {};\n const downloadTasks: Record<string, Promise<string>> = {};\n\n // Helper function to validate cache file completeness\n const isValidCacheFile = async (\n filePath: string,\n allowEmpty = false,\n ): Promise<boolean> => {\n try {\n const stats = await stat(filePath);\n // File must exist and either have content or be explicitly allowed to be empty\n return allowEmpty || stats.size > 0;\n } catch {\n return false;\n }\n };\n\n return async (\n rootDir: string,\n absolutePath: string,\n ...args: T\n ): Promise<TaskResult> => {\n const log = debug(`ef:${label}`);\n const cacheDirRoot = path.join(rootDir, \".cache\");\n await mkdir(cacheDirRoot, { recursive: true });\n\n log(`Running ef:${label} task for ${absolutePath} in ${rootDir}`);\n\n // Handle HTTP downloads with proper race condition protection\n if (absolutePath.includes(\"http\")) {\n const safePath = absolutePath.replace(/[^a-zA-Z0-9]/g, \"_\");\n const downloadCachePath = path.join(\n rootDir,\n \".cache\",\n `${safePath}.file`,\n );\n\n // Check if already downloaded and valid (allow empty downloads)\n if (\n existsSync(downloadCachePath) &&\n (await isValidCacheFile(downloadCachePath, true))\n ) {\n log(`Already cached ${absolutePath}`);\n absolutePath = downloadCachePath;\n } else {\n // Use download task deduplication to prevent concurrent downloads\n const downloadKey = absolutePath;\n if (!downloadTasks[downloadKey]) {\n log(`Starting download for ${absolutePath}`);\n downloadTasks[downloadKey] = (async () => {\n try {\n const response = await fetch(absolutePath);\n if (!response.ok) {\n throw new Error(\n `Failed to fetch file from URL ${absolutePath}: ${response.status} ${response.statusText}`,\n );\n }\n\n const stream = response.body;\n if (!stream) {\n throw new Error(`No response body for URL ${absolutePath}`);\n }\n\n // Use temporary file to prevent reading incomplete downloads\n const tempPath = `${downloadCachePath}.tmp`;\n const writeStream = createWriteStream(tempPath);\n\n // @ts-ignore node web stream support in typescript is incorrect about this.\n const readable = Readable.fromWeb(stream);\n readable.pipe(writeStream);\n\n await new Promise<void>((resolve, reject) => {\n readable.on(\"error\", reject);\n writeStream.on(\"error\", reject);\n writeStream.on(\"finish\", () => resolve());\n });\n\n // Atomically move completed file to final location\n const { rename } = await import(\"node:fs/promises\");\n await rename(tempPath, downloadCachePath);\n\n log(`Download completed for ${absolutePath}`);\n return downloadCachePath;\n } catch (error) {\n log(`Download failed for ${absolutePath}: ${error}`);\n // Clean up task reference on failure\n delete downloadTasks[downloadKey];\n throw error;\n }\n })();\n }\n\n absolutePath = await downloadTasks[downloadKey];\n // Clean up completed task\n delete downloadTasks[downloadKey];\n }\n }\n\n // First, try to find existing cache by scanning cache directories\n // This avoids expensive MD5 computation when cache already exists\n const expectedFilename = filename(absolutePath, ...args);\n let cachePath: string | null = null;\n let md5: string | null = null;\n\n // Scan cache directories to find existing cache file\n const scanStartTime = Date.now();\n try {\n const cacheDirs = await readdir(cacheDirRoot, { withFileTypes: true });\n log(\n `Scanning ${cacheDirs.length} cache directories for ${expectedFilename}`,\n );\n for (const dir of cacheDirs) {\n if (dir.isDirectory()) {\n const candidatePath = path.join(\n cacheDirRoot,\n dir.name,\n expectedFilename,\n );\n if (\n existsSync(candidatePath) &&\n (await isValidCacheFile(candidatePath))\n ) {\n cachePath = candidatePath;\n md5 = dir.name; // Directory name is the MD5\n const scanElapsed = Date.now() - scanStartTime;\n log(\n `Found existing cache in ${scanElapsed}ms: ${candidatePath} (skipped MD5)`,\n );\n break;\n }\n }\n }\n if (!cachePath) {\n const scanElapsed = Date.now() - scanStartTime;\n log(\n `Cache scan completed in ${scanElapsed}ms, no cache found - will compute MD5`,\n );\n }\n } catch (error) {\n // If cache directory doesn't exist or can't be read, continue to MD5 computation\n const scanElapsed = Date.now() - scanStartTime;\n log(\n `Cache scan failed after ${scanElapsed}ms, will compute MD5: ${error}`,\n );\n }\n\n // Only compute MD5 if we didn't find an existing cache\n if (!md5) {\n const md5StartTime = Date.now();\n log(`Computing MD5 for ${absolutePath}...`);\n md5 = await md5FilePath(absolutePath);\n const md5Elapsed = Date.now() - md5StartTime;\n log(`MD5 computed in ${md5Elapsed}ms: ${md5}`);\n }\n\n const cacheDir = path.join(cacheDirRoot, md5);\n log(`Cache dir: ${cacheDir}`);\n await mkdir(cacheDir, { recursive: true });\n\n if (!cachePath) {\n cachePath = path.join(cacheDir, expectedFilename);\n }\n const key = cachePath;\n\n // Check if cache exists and is valid (not zero-byte)\n if (existsSync(cachePath) && (await isValidCacheFile(cachePath))) {\n log(`Returning cached ef:${label} task for ${key}`);\n return { cachePath, md5Sum: md5 };\n }\n\n const maybeTask = tasks[key];\n if (maybeTask) {\n log(`Returning existing ef:${label} task for ${key}`);\n return await maybeTask;\n }\n\n log(`Creating new ef:${label} task for ${key}`);\n const fullTask = (async (): Promise<TaskResult> => {\n try {\n log(`Awaiting task for ${key}`);\n const result = await runner(absolutePath, ...args);\n\n if (result instanceof Readable) {\n log(`Piping task for ${key} to cache`);\n // Use temporary file to prevent reading incomplete results\n const tempPath = `${cachePath}.tmp`;\n const writeStream = createWriteStream(tempPath);\n result.pipe(writeStream);\n\n await new Promise<void>((resolve, reject) => {\n result.on(\"error\", reject);\n writeStream.on(\"error\", reject);\n writeStream.on(\"finish\", () => resolve());\n });\n\n // Atomically move completed file to final location\n const { rename } = await import(\"node:fs/promises\");\n await rename(tempPath, cachePath);\n } else {\n log(`Writing to ${cachePath}`);\n await writeFile(cachePath, result);\n }\n\n // Clean up task reference after successful completion\n delete tasks[key];\n\n return {\n md5Sum: md5,\n cachePath,\n };\n } catch (error) {\n // Clean up task reference on failure\n delete tasks[key];\n throw error;\n }\n })();\n\n tasks[key] = fullTask;\n return await fullTask;\n };\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"idempotentTask.cjs","names":["tasks: Record<string, Promise<TaskResult>>","downloadTasks: Record<string, Promise<string>>","path","Readable","cachePath: string | null","md5: string | null","md5FilePath"],"sources":["../src/idempotentTask.ts"],"sourcesContent":["import { createWriteStream, existsSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { md5FilePath } from \"./md5.js\";\nimport debug from \"debug\";\nimport { mkdir, writeFile, stat, readdir } from \"node:fs/promises\";\nimport { Readable } from \"node:stream\";\n\ninterface TaskOptions<T extends unknown[]> {\n label: string;\n filename: (absolutePath: string, ...args: T) => string;\n runner: (absolutePath: string, ...args: T) => Promise<string | Readable>;\n}\n\nexport interface TaskResult {\n md5Sum: string;\n cachePath: string;\n}\n\nexport const idempotentTask = <T extends unknown[]>({\n label,\n filename,\n runner,\n}: TaskOptions<T>) => {\n const tasks: Record<string, Promise<TaskResult>> = {};\n const downloadTasks: Record<string, Promise<string>> = {};\n\n // Helper function to validate cache file completeness\n const isValidCacheFile = async (\n filePath: string,\n allowEmpty = false,\n ): Promise<boolean> => {\n try {\n const stats = await stat(filePath);\n // File must exist and either have content or be explicitly allowed to be empty\n return allowEmpty || stats.size > 0;\n } catch {\n return false;\n }\n };\n\n return async (\n rootDir: string,\n absolutePath: string,\n ...args: T\n ): Promise<TaskResult> => {\n const log = debug(`ef:${label}`);\n const cacheDirRoot = path.join(rootDir, \".cache\");\n await mkdir(cacheDirRoot, { recursive: true });\n\n log(`Running ef:${label} task for ${absolutePath} in ${rootDir}`);\n\n // Handle HTTP downloads with proper race condition protection\n if (absolutePath.includes(\"http\")) {\n const safePath = absolutePath.replace(/[^a-zA-Z0-9]/g, \"_\");\n const downloadCachePath = path.join(\n rootDir,\n \".cache\",\n `${safePath}.file`,\n );\n\n // Check if already downloaded and valid (allow empty downloads)\n if (\n existsSync(downloadCachePath) &&\n (await isValidCacheFile(downloadCachePath, true))\n ) {\n log(`Already cached ${absolutePath}`);\n absolutePath = downloadCachePath;\n } else {\n // Use download task deduplication to prevent concurrent downloads\n const downloadKey = absolutePath;\n if (!downloadTasks[downloadKey]) {\n log(`Starting download for ${absolutePath}`);\n downloadTasks[downloadKey] = (async () => {\n try {\n const response = await fetch(absolutePath);\n if (!response.ok) {\n throw new Error(\n `Failed to fetch file from URL ${absolutePath}: ${response.status} ${response.statusText}`,\n );\n }\n\n const stream = response.body;\n if (!stream) {\n throw new Error(`No response body for URL ${absolutePath}`);\n }\n\n // Use temporary file to prevent reading incomplete downloads\n const tempPath = `${downloadCachePath}.tmp`;\n const writeStream = createWriteStream(tempPath);\n\n // @ts-ignore node web stream support in typescript is incorrect about this.\n const readable = Readable.fromWeb(stream);\n readable.pipe(writeStream);\n\n await new Promise<void>((resolve, reject) => {\n readable.on(\"error\", reject);\n writeStream.on(\"error\", reject);\n writeStream.on(\"finish\", () => resolve());\n });\n\n // Atomically move completed file to final location\n const { rename } = await import(\"node:fs/promises\");\n await rename(tempPath, downloadCachePath);\n\n log(`Download completed for ${absolutePath}`);\n return downloadCachePath;\n } catch (error) {\n log(`Download failed for ${absolutePath}: ${error}`);\n // Clean up task reference on failure\n delete downloadTasks[downloadKey];\n throw error;\n }\n })();\n }\n\n absolutePath = await downloadTasks[downloadKey];\n // Clean up completed task\n delete downloadTasks[downloadKey];\n }\n }\n\n // Deduplicate concurrent callers by input parameters before any async work.\n // Using a synchronous key prevents the TOCTOU race where two concurrent\n // callers both pass the tasks[] check before either registers a task.\n const inputKey = JSON.stringify([absolutePath, ...args]);\n if (tasks[inputKey]) {\n log(`Returning existing ef:${label} task for ${absolutePath}`);\n return await tasks[inputKey];\n }\n\n const fullTask = (async (): Promise<TaskResult> => {\n try {\n // Try to find existing cache by scanning cache directories.\n // This avoids expensive MD5 computation when cache already exists.\n const expectedFilename = filename(absolutePath, ...args);\n let cachePath: string | null = null;\n let md5: string | null = null;\n\n const scanStartTime = Date.now();\n try {\n const cacheDirs = await readdir(cacheDirRoot, {\n withFileTypes: true,\n });\n log(\n `Scanning ${cacheDirs.length} cache directories for ${expectedFilename}`,\n );\n for (const dir of cacheDirs) {\n if (dir.isDirectory()) {\n const candidatePath = path.join(\n cacheDirRoot,\n dir.name,\n expectedFilename,\n );\n if (\n existsSync(candidatePath) &&\n (await isValidCacheFile(candidatePath))\n ) {\n cachePath = candidatePath;\n md5 = dir.name; // Directory name is the MD5\n const scanElapsed = Date.now() - scanStartTime;\n log(\n `Found existing cache in ${scanElapsed}ms: ${candidatePath} (skipped MD5)`,\n );\n break;\n }\n }\n }\n if (!cachePath) {\n const scanElapsed = Date.now() - scanStartTime;\n log(\n `Cache scan completed in ${scanElapsed}ms, no cache found - will compute MD5`,\n );\n }\n } catch (error) {\n const scanElapsed = Date.now() - scanStartTime;\n log(\n `Cache scan failed after ${scanElapsed}ms, will compute MD5: ${error}`,\n );\n }\n\n const resolvedMd5 =\n md5 ??\n (await (async () => {\n const md5StartTime = Date.now();\n log(`Computing MD5 for ${absolutePath}...`);\n const computed = await md5FilePath(absolutePath);\n const md5Elapsed = Date.now() - md5StartTime;\n log(`MD5 computed in ${md5Elapsed}ms: ${computed}`);\n return computed;\n })());\n\n const cacheDir = path.join(cacheDirRoot, resolvedMd5);\n log(`Cache dir: ${cacheDir}`);\n await mkdir(cacheDir, { recursive: true });\n\n const resolvedCachePath =\n cachePath ?? path.join(cacheDir, expectedFilename);\n\n // Check if cache exists and is valid (not zero-byte)\n if (\n existsSync(resolvedCachePath) &&\n (await isValidCacheFile(resolvedCachePath))\n ) {\n log(`Returning cached ef:${label} task for ${resolvedCachePath}`);\n return { cachePath: resolvedCachePath, md5Sum: resolvedMd5 };\n }\n\n log(`Running ef:${label} runner for ${resolvedCachePath}`);\n const result = await runner(absolutePath, ...args);\n\n if (result instanceof Readable) {\n log(`Piping task for ${resolvedCachePath} to cache`);\n const tempPath = `${resolvedCachePath}.tmp`;\n const writeStream = createWriteStream(tempPath);\n result.pipe(writeStream);\n\n await new Promise<void>((resolve, reject) => {\n result.on(\"error\", reject);\n writeStream.on(\"error\", reject);\n writeStream.on(\"finish\", () => resolve());\n });\n\n const { rename } = await import(\"node:fs/promises\");\n await rename(tempPath, resolvedCachePath);\n } else {\n log(`Writing to ${resolvedCachePath}`);\n await writeFile(resolvedCachePath, result);\n }\n\n return {\n md5Sum: resolvedMd5,\n cachePath: resolvedCachePath,\n };\n } finally {\n delete tasks[inputKey];\n }\n })();\n\n tasks[inputKey] = fullTask;\n return await fullTask;\n };\n};\n"],"mappings":";;;;;;;;;;;;;;AAkBA,MAAa,kBAAuC,EAClD,OACA,UACA,aACoB;CACpB,MAAMA,QAA6C,EAAE;CACrD,MAAMC,gBAAiD,EAAE;CAGzD,MAAM,mBAAmB,OACvB,UACA,aAAa,UACQ;AACrB,MAAI;GACF,MAAM,QAAQ,iCAAW,SAAS;AAElC,UAAO,cAAc,MAAM,OAAO;UAC5B;AACN,UAAO;;;AAIX,QAAO,OACL,SACA,cACA,GAAG,SACqB;EACxB,MAAM,yBAAY,MAAM,QAAQ;EAChC,MAAM,eAAeC,kBAAK,KAAK,SAAS,SAAS;AACjD,oCAAY,cAAc,EAAE,WAAW,MAAM,CAAC;AAE9C,MAAI,cAAc,MAAM,YAAY,aAAa,MAAM,UAAU;AAGjE,MAAI,aAAa,SAAS,OAAO,EAAE;GACjC,MAAM,WAAW,aAAa,QAAQ,iBAAiB,IAAI;GAC3D,MAAM,oBAAoBA,kBAAK,KAC7B,SACA,UACA,GAAG,SAAS,OACb;AAGD,+BACa,kBAAkB,IAC5B,MAAM,iBAAiB,mBAAmB,KAAK,EAChD;AACA,QAAI,kBAAkB,eAAe;AACrC,mBAAe;UACV;IAEL,MAAM,cAAc;AACpB,QAAI,CAAC,cAAc,cAAc;AAC/B,SAAI,yBAAyB,eAAe;AAC5C,mBAAc,gBAAgB,YAAY;AACxC,UAAI;OACF,MAAM,WAAW,MAAM,MAAM,aAAa;AAC1C,WAAI,CAAC,SAAS,GACZ,OAAM,IAAI,MACR,iCAAiC,aAAa,IAAI,SAAS,OAAO,GAAG,SAAS,aAC/E;OAGH,MAAM,SAAS,SAAS;AACxB,WAAI,CAAC,OACH,OAAM,IAAI,MAAM,4BAA4B,eAAe;OAI7D,MAAM,WAAW,GAAG,kBAAkB;OACtC,MAAM,6CAAgC,SAAS;OAG/C,MAAM,WAAWC,qBAAS,QAAQ,OAAO;AACzC,gBAAS,KAAK,YAAY;AAE1B,aAAM,IAAI,SAAe,SAAS,WAAW;AAC3C,iBAAS,GAAG,SAAS,OAAO;AAC5B,oBAAY,GAAG,SAAS,OAAO;AAC/B,oBAAY,GAAG,gBAAgB,SAAS,CAAC;SACzC;OAGF,MAAM,EAAE,WAAW,MAAM,OAAO;AAChC,aAAM,OAAO,UAAU,kBAAkB;AAEzC,WAAI,0BAA0B,eAAe;AAC7C,cAAO;eACA,OAAO;AACd,WAAI,uBAAuB,aAAa,IAAI,QAAQ;AAEpD,cAAO,cAAc;AACrB,aAAM;;SAEN;;AAGN,mBAAe,MAAM,cAAc;AAEnC,WAAO,cAAc;;;EAOzB,MAAM,WAAW,KAAK,UAAU,CAAC,cAAc,GAAG,KAAK,CAAC;AACxD,MAAI,MAAM,WAAW;AACnB,OAAI,yBAAyB,MAAM,YAAY,eAAe;AAC9D,UAAO,MAAM,MAAM;;EAGrB,MAAM,YAAY,YAAiC;AACjD,OAAI;IAGF,MAAM,mBAAmB,SAAS,cAAc,GAAG,KAAK;IACxD,IAAIC,YAA2B;IAC/B,IAAIC,MAAqB;IAEzB,MAAM,gBAAgB,KAAK,KAAK;AAChC,QAAI;KACF,MAAM,YAAY,oCAAc,cAAc,EAC5C,eAAe,MAChB,CAAC;AACF,SACE,YAAY,UAAU,OAAO,yBAAyB,mBACvD;AACD,UAAK,MAAM,OAAO,UAChB,KAAI,IAAI,aAAa,EAAE;MACrB,MAAM,gBAAgBH,kBAAK,KACzB,cACA,IAAI,MACJ,iBACD;AACD,kCACa,cAAc,IACxB,MAAM,iBAAiB,cAAc,EACtC;AACA,mBAAY;AACZ,aAAM,IAAI;AAEV,WACE,2BAFkB,KAAK,KAAK,GAAG,cAEQ,MAAM,cAAc,gBAC5D;AACD;;;AAIN,SAAI,CAAC,UAEH,KACE,2BAFkB,KAAK,KAAK,GAAG,cAEQ,uCACxC;aAEI,OAAO;AAEd,SACE,2BAFkB,KAAK,KAAK,GAAG,cAEQ,wBAAwB,QAChE;;IAGH,MAAM,cACJ,OACC,OAAO,YAAY;KAClB,MAAM,eAAe,KAAK,KAAK;AAC/B,SAAI,qBAAqB,aAAa,KAAK;KAC3C,MAAM,WAAW,MAAMI,wBAAY,aAAa;AAEhD,SAAI,mBADe,KAAK,KAAK,GAAG,aACE,MAAM,WAAW;AACnD,YAAO;QACL;IAEN,MAAM,WAAWJ,kBAAK,KAAK,cAAc,YAAY;AACrD,QAAI,cAAc,WAAW;AAC7B,sCAAY,UAAU,EAAE,WAAW,MAAM,CAAC;IAE1C,MAAM,oBACJ,aAAaA,kBAAK,KAAK,UAAU,iBAAiB;AAGpD,gCACa,kBAAkB,IAC5B,MAAM,iBAAiB,kBAAkB,EAC1C;AACA,SAAI,uBAAuB,MAAM,YAAY,oBAAoB;AACjE,YAAO;MAAE,WAAW;MAAmB,QAAQ;MAAa;;AAG9D,QAAI,cAAc,MAAM,cAAc,oBAAoB;IAC1D,MAAM,SAAS,MAAM,OAAO,cAAc,GAAG,KAAK;AAElD,QAAI,kBAAkBC,sBAAU;AAC9B,SAAI,mBAAmB,kBAAkB,WAAW;KACpD,MAAM,WAAW,GAAG,kBAAkB;KACtC,MAAM,6CAAgC,SAAS;AAC/C,YAAO,KAAK,YAAY;AAExB,WAAM,IAAI,SAAe,SAAS,WAAW;AAC3C,aAAO,GAAG,SAAS,OAAO;AAC1B,kBAAY,GAAG,SAAS,OAAO;AAC/B,kBAAY,GAAG,gBAAgB,SAAS,CAAC;OACzC;KAEF,MAAM,EAAE,WAAW,MAAM,OAAO;AAChC,WAAM,OAAO,UAAU,kBAAkB;WACpC;AACL,SAAI,cAAc,oBAAoB;AACtC,2CAAgB,mBAAmB,OAAO;;AAG5C,WAAO;KACL,QAAQ;KACR,WAAW;KACZ;aACO;AACR,WAAO,MAAM;;MAEb;AAEJ,QAAM,YAAY;AAClB,SAAO,MAAM"}
|
package/dist/idempotentTask.js
CHANGED
|
@@ -62,57 +62,56 @@ const idempotentTask = ({ label, filename, runner }) => {
|
|
|
62
62
|
delete downloadTasks[downloadKey];
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
try {
|
|
70
|
-
const cacheDirs = await readdir(cacheDirRoot, { withFileTypes: true });
|
|
71
|
-
log(`Scanning ${cacheDirs.length} cache directories for ${expectedFilename}`);
|
|
72
|
-
for (const dir of cacheDirs) if (dir.isDirectory()) {
|
|
73
|
-
const candidatePath = path.join(cacheDirRoot, dir.name, expectedFilename);
|
|
74
|
-
if (existsSync(candidatePath) && await isValidCacheFile(candidatePath)) {
|
|
75
|
-
cachePath = candidatePath;
|
|
76
|
-
md5 = dir.name;
|
|
77
|
-
log(`Found existing cache in ${Date.now() - scanStartTime}ms: ${candidatePath} (skipped MD5)`);
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
if (!cachePath) log(`Cache scan completed in ${Date.now() - scanStartTime}ms, no cache found - will compute MD5`);
|
|
82
|
-
} catch (error) {
|
|
83
|
-
log(`Cache scan failed after ${Date.now() - scanStartTime}ms, will compute MD5: ${error}`);
|
|
84
|
-
}
|
|
85
|
-
if (!md5) {
|
|
86
|
-
const md5StartTime = Date.now();
|
|
87
|
-
log(`Computing MD5 for ${absolutePath}...`);
|
|
88
|
-
md5 = await md5FilePath(absolutePath);
|
|
89
|
-
log(`MD5 computed in ${Date.now() - md5StartTime}ms: ${md5}`);
|
|
90
|
-
}
|
|
91
|
-
const cacheDir = path.join(cacheDirRoot, md5);
|
|
92
|
-
log(`Cache dir: ${cacheDir}`);
|
|
93
|
-
await mkdir(cacheDir, { recursive: true });
|
|
94
|
-
if (!cachePath) cachePath = path.join(cacheDir, expectedFilename);
|
|
95
|
-
const key = cachePath;
|
|
96
|
-
if (existsSync(cachePath) && await isValidCacheFile(cachePath)) {
|
|
97
|
-
log(`Returning cached ef:${label} task for ${key}`);
|
|
98
|
-
return {
|
|
99
|
-
cachePath,
|
|
100
|
-
md5Sum: md5
|
|
101
|
-
};
|
|
65
|
+
const inputKey = JSON.stringify([absolutePath, ...args]);
|
|
66
|
+
if (tasks[inputKey]) {
|
|
67
|
+
log(`Returning existing ef:${label} task for ${absolutePath}`);
|
|
68
|
+
return await tasks[inputKey];
|
|
102
69
|
}
|
|
103
|
-
const maybeTask = tasks[key];
|
|
104
|
-
if (maybeTask) {
|
|
105
|
-
log(`Returning existing ef:${label} task for ${key}`);
|
|
106
|
-
return await maybeTask;
|
|
107
|
-
}
|
|
108
|
-
log(`Creating new ef:${label} task for ${key}`);
|
|
109
70
|
const fullTask = (async () => {
|
|
110
71
|
try {
|
|
111
|
-
|
|
72
|
+
const expectedFilename = filename(absolutePath, ...args);
|
|
73
|
+
let cachePath = null;
|
|
74
|
+
let md5 = null;
|
|
75
|
+
const scanStartTime = Date.now();
|
|
76
|
+
try {
|
|
77
|
+
const cacheDirs = await readdir(cacheDirRoot, { withFileTypes: true });
|
|
78
|
+
log(`Scanning ${cacheDirs.length} cache directories for ${expectedFilename}`);
|
|
79
|
+
for (const dir of cacheDirs) if (dir.isDirectory()) {
|
|
80
|
+
const candidatePath = path.join(cacheDirRoot, dir.name, expectedFilename);
|
|
81
|
+
if (existsSync(candidatePath) && await isValidCacheFile(candidatePath)) {
|
|
82
|
+
cachePath = candidatePath;
|
|
83
|
+
md5 = dir.name;
|
|
84
|
+
log(`Found existing cache in ${Date.now() - scanStartTime}ms: ${candidatePath} (skipped MD5)`);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!cachePath) log(`Cache scan completed in ${Date.now() - scanStartTime}ms, no cache found - will compute MD5`);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
log(`Cache scan failed after ${Date.now() - scanStartTime}ms, will compute MD5: ${error}`);
|
|
91
|
+
}
|
|
92
|
+
const resolvedMd5 = md5 ?? await (async () => {
|
|
93
|
+
const md5StartTime = Date.now();
|
|
94
|
+
log(`Computing MD5 for ${absolutePath}...`);
|
|
95
|
+
const computed = await md5FilePath(absolutePath);
|
|
96
|
+
log(`MD5 computed in ${Date.now() - md5StartTime}ms: ${computed}`);
|
|
97
|
+
return computed;
|
|
98
|
+
})();
|
|
99
|
+
const cacheDir = path.join(cacheDirRoot, resolvedMd5);
|
|
100
|
+
log(`Cache dir: ${cacheDir}`);
|
|
101
|
+
await mkdir(cacheDir, { recursive: true });
|
|
102
|
+
const resolvedCachePath = cachePath ?? path.join(cacheDir, expectedFilename);
|
|
103
|
+
if (existsSync(resolvedCachePath) && await isValidCacheFile(resolvedCachePath)) {
|
|
104
|
+
log(`Returning cached ef:${label} task for ${resolvedCachePath}`);
|
|
105
|
+
return {
|
|
106
|
+
cachePath: resolvedCachePath,
|
|
107
|
+
md5Sum: resolvedMd5
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
log(`Running ef:${label} runner for ${resolvedCachePath}`);
|
|
112
111
|
const result = await runner(absolutePath, ...args);
|
|
113
112
|
if (result instanceof Readable) {
|
|
114
|
-
log(`Piping task for ${
|
|
115
|
-
const tempPath = `${
|
|
113
|
+
log(`Piping task for ${resolvedCachePath} to cache`);
|
|
114
|
+
const tempPath = `${resolvedCachePath}.tmp`;
|
|
116
115
|
const writeStream = createWriteStream(tempPath);
|
|
117
116
|
result.pipe(writeStream);
|
|
118
117
|
await new Promise((resolve, reject) => {
|
|
@@ -121,22 +120,20 @@ const idempotentTask = ({ label, filename, runner }) => {
|
|
|
121
120
|
writeStream.on("finish", () => resolve());
|
|
122
121
|
});
|
|
123
122
|
const { rename } = await import("node:fs/promises");
|
|
124
|
-
await rename(tempPath,
|
|
123
|
+
await rename(tempPath, resolvedCachePath);
|
|
125
124
|
} else {
|
|
126
|
-
log(`Writing to ${
|
|
127
|
-
await writeFile(
|
|
125
|
+
log(`Writing to ${resolvedCachePath}`);
|
|
126
|
+
await writeFile(resolvedCachePath, result);
|
|
128
127
|
}
|
|
129
|
-
delete tasks[key];
|
|
130
128
|
return {
|
|
131
|
-
md5Sum:
|
|
132
|
-
cachePath
|
|
129
|
+
md5Sum: resolvedMd5,
|
|
130
|
+
cachePath: resolvedCachePath
|
|
133
131
|
};
|
|
134
|
-
}
|
|
135
|
-
delete tasks[
|
|
136
|
-
throw error;
|
|
132
|
+
} finally {
|
|
133
|
+
delete tasks[inputKey];
|
|
137
134
|
}
|
|
138
135
|
})();
|
|
139
|
-
tasks[
|
|
136
|
+
tasks[inputKey] = fullTask;
|
|
140
137
|
return await fullTask;
|
|
141
138
|
};
|
|
142
139
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"idempotentTask.js","names":["tasks: Record<string, Promise<TaskResult>>","downloadTasks: Record<string, Promise<string>>","cachePath: string | null","md5: string | null"],"sources":["../src/idempotentTask.ts"],"sourcesContent":["import { createWriteStream, existsSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { md5FilePath } from \"./md5.js\";\nimport debug from \"debug\";\nimport { mkdir, writeFile, stat, readdir } from \"node:fs/promises\";\nimport { Readable } from \"node:stream\";\n\ninterface TaskOptions<T extends unknown[]> {\n label: string;\n filename: (absolutePath: string, ...args: T) => string;\n runner: (absolutePath: string, ...args: T) => Promise<string | Readable>;\n}\n\nexport interface TaskResult {\n md5Sum: string;\n cachePath: string;\n}\n\nexport const idempotentTask = <T extends unknown[]>({\n label,\n filename,\n runner,\n}: TaskOptions<T>) => {\n const tasks: Record<string, Promise<TaskResult>> = {};\n const downloadTasks: Record<string, Promise<string>> = {};\n\n // Helper function to validate cache file completeness\n const isValidCacheFile = async (\n filePath: string,\n allowEmpty = false,\n ): Promise<boolean> => {\n try {\n const stats = await stat(filePath);\n // File must exist and either have content or be explicitly allowed to be empty\n return allowEmpty || stats.size > 0;\n } catch {\n return false;\n }\n };\n\n return async (\n rootDir: string,\n absolutePath: string,\n ...args: T\n ): Promise<TaskResult> => {\n const log = debug(`ef:${label}`);\n const cacheDirRoot = path.join(rootDir, \".cache\");\n await mkdir(cacheDirRoot, { recursive: true });\n\n log(`Running ef:${label} task for ${absolutePath} in ${rootDir}`);\n\n // Handle HTTP downloads with proper race condition protection\n if (absolutePath.includes(\"http\")) {\n const safePath = absolutePath.replace(/[^a-zA-Z0-9]/g, \"_\");\n const downloadCachePath = path.join(\n rootDir,\n \".cache\",\n `${safePath}.file`,\n );\n\n // Check if already downloaded and valid (allow empty downloads)\n if (\n existsSync(downloadCachePath) &&\n (await isValidCacheFile(downloadCachePath, true))\n ) {\n log(`Already cached ${absolutePath}`);\n absolutePath = downloadCachePath;\n } else {\n // Use download task deduplication to prevent concurrent downloads\n const downloadKey = absolutePath;\n if (!downloadTasks[downloadKey]) {\n log(`Starting download for ${absolutePath}`);\n downloadTasks[downloadKey] = (async () => {\n try {\n const response = await fetch(absolutePath);\n if (!response.ok) {\n throw new Error(\n `Failed to fetch file from URL ${absolutePath}: ${response.status} ${response.statusText}`,\n );\n }\n\n const stream = response.body;\n if (!stream) {\n throw new Error(`No response body for URL ${absolutePath}`);\n }\n\n // Use temporary file to prevent reading incomplete downloads\n const tempPath = `${downloadCachePath}.tmp`;\n const writeStream = createWriteStream(tempPath);\n\n // @ts-ignore node web stream support in typescript is incorrect about this.\n const readable = Readable.fromWeb(stream);\n readable.pipe(writeStream);\n\n await new Promise<void>((resolve, reject) => {\n readable.on(\"error\", reject);\n writeStream.on(\"error\", reject);\n writeStream.on(\"finish\", () => resolve());\n });\n\n // Atomically move completed file to final location\n const { rename } = await import(\"node:fs/promises\");\n await rename(tempPath, downloadCachePath);\n\n log(`Download completed for ${absolutePath}`);\n return downloadCachePath;\n } catch (error) {\n log(`Download failed for ${absolutePath}: ${error}`);\n // Clean up task reference on failure\n delete downloadTasks[downloadKey];\n throw error;\n }\n })();\n }\n\n absolutePath = await downloadTasks[downloadKey];\n // Clean up completed task\n delete downloadTasks[downloadKey];\n }\n }\n\n // First, try to find existing cache by scanning cache directories\n // This avoids expensive MD5 computation when cache already exists\n const expectedFilename = filename(absolutePath, ...args);\n let cachePath: string | null = null;\n let md5: string | null = null;\n\n // Scan cache directories to find existing cache file\n const scanStartTime = Date.now();\n try {\n const cacheDirs = await readdir(cacheDirRoot, { withFileTypes: true });\n log(\n `Scanning ${cacheDirs.length} cache directories for ${expectedFilename}`,\n );\n for (const dir of cacheDirs) {\n if (dir.isDirectory()) {\n const candidatePath = path.join(\n cacheDirRoot,\n dir.name,\n expectedFilename,\n );\n if (\n existsSync(candidatePath) &&\n (await isValidCacheFile(candidatePath))\n ) {\n cachePath = candidatePath;\n md5 = dir.name; // Directory name is the MD5\n const scanElapsed = Date.now() - scanStartTime;\n log(\n `Found existing cache in ${scanElapsed}ms: ${candidatePath} (skipped MD5)`,\n );\n break;\n }\n }\n }\n if (!cachePath) {\n const scanElapsed = Date.now() - scanStartTime;\n log(\n `Cache scan completed in ${scanElapsed}ms, no cache found - will compute MD5`,\n );\n }\n } catch (error) {\n // If cache directory doesn't exist or can't be read, continue to MD5 computation\n const scanElapsed = Date.now() - scanStartTime;\n log(\n `Cache scan failed after ${scanElapsed}ms, will compute MD5: ${error}`,\n );\n }\n\n // Only compute MD5 if we didn't find an existing cache\n if (!md5) {\n const md5StartTime = Date.now();\n log(`Computing MD5 for ${absolutePath}...`);\n md5 = await md5FilePath(absolutePath);\n const md5Elapsed = Date.now() - md5StartTime;\n log(`MD5 computed in ${md5Elapsed}ms: ${md5}`);\n }\n\n const cacheDir = path.join(cacheDirRoot, md5);\n log(`Cache dir: ${cacheDir}`);\n await mkdir(cacheDir, { recursive: true });\n\n if (!cachePath) {\n cachePath = path.join(cacheDir, expectedFilename);\n }\n const key = cachePath;\n\n // Check if cache exists and is valid (not zero-byte)\n if (existsSync(cachePath) && (await isValidCacheFile(cachePath))) {\n log(`Returning cached ef:${label} task for ${key}`);\n return { cachePath, md5Sum: md5 };\n }\n\n const maybeTask = tasks[key];\n if (maybeTask) {\n log(`Returning existing ef:${label} task for ${key}`);\n return await maybeTask;\n }\n\n log(`Creating new ef:${label} task for ${key}`);\n const fullTask = (async (): Promise<TaskResult> => {\n try {\n log(`Awaiting task for ${key}`);\n const result = await runner(absolutePath, ...args);\n\n if (result instanceof Readable) {\n log(`Piping task for ${key} to cache`);\n // Use temporary file to prevent reading incomplete results\n const tempPath = `${cachePath}.tmp`;\n const writeStream = createWriteStream(tempPath);\n result.pipe(writeStream);\n\n await new Promise<void>((resolve, reject) => {\n result.on(\"error\", reject);\n writeStream.on(\"error\", reject);\n writeStream.on(\"finish\", () => resolve());\n });\n\n // Atomically move completed file to final location\n const { rename } = await import(\"node:fs/promises\");\n await rename(tempPath, cachePath);\n } else {\n log(`Writing to ${cachePath}`);\n await writeFile(cachePath, result);\n }\n\n // Clean up task reference after successful completion\n delete tasks[key];\n\n return {\n md5Sum: md5,\n cachePath,\n };\n } catch (error) {\n // Clean up task reference on failure\n delete tasks[key];\n throw error;\n }\n })();\n\n tasks[key] = fullTask;\n return await fullTask;\n };\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"idempotentTask.js","names":["tasks: Record<string, Promise<TaskResult>>","downloadTasks: Record<string, Promise<string>>","cachePath: string | null","md5: string | null"],"sources":["../src/idempotentTask.ts"],"sourcesContent":["import { createWriteStream, existsSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { md5FilePath } from \"./md5.js\";\nimport debug from \"debug\";\nimport { mkdir, writeFile, stat, readdir } from \"node:fs/promises\";\nimport { Readable } from \"node:stream\";\n\ninterface TaskOptions<T extends unknown[]> {\n label: string;\n filename: (absolutePath: string, ...args: T) => string;\n runner: (absolutePath: string, ...args: T) => Promise<string | Readable>;\n}\n\nexport interface TaskResult {\n md5Sum: string;\n cachePath: string;\n}\n\nexport const idempotentTask = <T extends unknown[]>({\n label,\n filename,\n runner,\n}: TaskOptions<T>) => {\n const tasks: Record<string, Promise<TaskResult>> = {};\n const downloadTasks: Record<string, Promise<string>> = {};\n\n // Helper function to validate cache file completeness\n const isValidCacheFile = async (\n filePath: string,\n allowEmpty = false,\n ): Promise<boolean> => {\n try {\n const stats = await stat(filePath);\n // File must exist and either have content or be explicitly allowed to be empty\n return allowEmpty || stats.size > 0;\n } catch {\n return false;\n }\n };\n\n return async (\n rootDir: string,\n absolutePath: string,\n ...args: T\n ): Promise<TaskResult> => {\n const log = debug(`ef:${label}`);\n const cacheDirRoot = path.join(rootDir, \".cache\");\n await mkdir(cacheDirRoot, { recursive: true });\n\n log(`Running ef:${label} task for ${absolutePath} in ${rootDir}`);\n\n // Handle HTTP downloads with proper race condition protection\n if (absolutePath.includes(\"http\")) {\n const safePath = absolutePath.replace(/[^a-zA-Z0-9]/g, \"_\");\n const downloadCachePath = path.join(\n rootDir,\n \".cache\",\n `${safePath}.file`,\n );\n\n // Check if already downloaded and valid (allow empty downloads)\n if (\n existsSync(downloadCachePath) &&\n (await isValidCacheFile(downloadCachePath, true))\n ) {\n log(`Already cached ${absolutePath}`);\n absolutePath = downloadCachePath;\n } else {\n // Use download task deduplication to prevent concurrent downloads\n const downloadKey = absolutePath;\n if (!downloadTasks[downloadKey]) {\n log(`Starting download for ${absolutePath}`);\n downloadTasks[downloadKey] = (async () => {\n try {\n const response = await fetch(absolutePath);\n if (!response.ok) {\n throw new Error(\n `Failed to fetch file from URL ${absolutePath}: ${response.status} ${response.statusText}`,\n );\n }\n\n const stream = response.body;\n if (!stream) {\n throw new Error(`No response body for URL ${absolutePath}`);\n }\n\n // Use temporary file to prevent reading incomplete downloads\n const tempPath = `${downloadCachePath}.tmp`;\n const writeStream = createWriteStream(tempPath);\n\n // @ts-ignore node web stream support in typescript is incorrect about this.\n const readable = Readable.fromWeb(stream);\n readable.pipe(writeStream);\n\n await new Promise<void>((resolve, reject) => {\n readable.on(\"error\", reject);\n writeStream.on(\"error\", reject);\n writeStream.on(\"finish\", () => resolve());\n });\n\n // Atomically move completed file to final location\n const { rename } = await import(\"node:fs/promises\");\n await rename(tempPath, downloadCachePath);\n\n log(`Download completed for ${absolutePath}`);\n return downloadCachePath;\n } catch (error) {\n log(`Download failed for ${absolutePath}: ${error}`);\n // Clean up task reference on failure\n delete downloadTasks[downloadKey];\n throw error;\n }\n })();\n }\n\n absolutePath = await downloadTasks[downloadKey];\n // Clean up completed task\n delete downloadTasks[downloadKey];\n }\n }\n\n // Deduplicate concurrent callers by input parameters before any async work.\n // Using a synchronous key prevents the TOCTOU race where two concurrent\n // callers both pass the tasks[] check before either registers a task.\n const inputKey = JSON.stringify([absolutePath, ...args]);\n if (tasks[inputKey]) {\n log(`Returning existing ef:${label} task for ${absolutePath}`);\n return await tasks[inputKey];\n }\n\n const fullTask = (async (): Promise<TaskResult> => {\n try {\n // Try to find existing cache by scanning cache directories.\n // This avoids expensive MD5 computation when cache already exists.\n const expectedFilename = filename(absolutePath, ...args);\n let cachePath: string | null = null;\n let md5: string | null = null;\n\n const scanStartTime = Date.now();\n try {\n const cacheDirs = await readdir(cacheDirRoot, {\n withFileTypes: true,\n });\n log(\n `Scanning ${cacheDirs.length} cache directories for ${expectedFilename}`,\n );\n for (const dir of cacheDirs) {\n if (dir.isDirectory()) {\n const candidatePath = path.join(\n cacheDirRoot,\n dir.name,\n expectedFilename,\n );\n if (\n existsSync(candidatePath) &&\n (await isValidCacheFile(candidatePath))\n ) {\n cachePath = candidatePath;\n md5 = dir.name; // Directory name is the MD5\n const scanElapsed = Date.now() - scanStartTime;\n log(\n `Found existing cache in ${scanElapsed}ms: ${candidatePath} (skipped MD5)`,\n );\n break;\n }\n }\n }\n if (!cachePath) {\n const scanElapsed = Date.now() - scanStartTime;\n log(\n `Cache scan completed in ${scanElapsed}ms, no cache found - will compute MD5`,\n );\n }\n } catch (error) {\n const scanElapsed = Date.now() - scanStartTime;\n log(\n `Cache scan failed after ${scanElapsed}ms, will compute MD5: ${error}`,\n );\n }\n\n const resolvedMd5 =\n md5 ??\n (await (async () => {\n const md5StartTime = Date.now();\n log(`Computing MD5 for ${absolutePath}...`);\n const computed = await md5FilePath(absolutePath);\n const md5Elapsed = Date.now() - md5StartTime;\n log(`MD5 computed in ${md5Elapsed}ms: ${computed}`);\n return computed;\n })());\n\n const cacheDir = path.join(cacheDirRoot, resolvedMd5);\n log(`Cache dir: ${cacheDir}`);\n await mkdir(cacheDir, { recursive: true });\n\n const resolvedCachePath =\n cachePath ?? path.join(cacheDir, expectedFilename);\n\n // Check if cache exists and is valid (not zero-byte)\n if (\n existsSync(resolvedCachePath) &&\n (await isValidCacheFile(resolvedCachePath))\n ) {\n log(`Returning cached ef:${label} task for ${resolvedCachePath}`);\n return { cachePath: resolvedCachePath, md5Sum: resolvedMd5 };\n }\n\n log(`Running ef:${label} runner for ${resolvedCachePath}`);\n const result = await runner(absolutePath, ...args);\n\n if (result instanceof Readable) {\n log(`Piping task for ${resolvedCachePath} to cache`);\n const tempPath = `${resolvedCachePath}.tmp`;\n const writeStream = createWriteStream(tempPath);\n result.pipe(writeStream);\n\n await new Promise<void>((resolve, reject) => {\n result.on(\"error\", reject);\n writeStream.on(\"error\", reject);\n writeStream.on(\"finish\", () => resolve());\n });\n\n const { rename } = await import(\"node:fs/promises\");\n await rename(tempPath, resolvedCachePath);\n } else {\n log(`Writing to ${resolvedCachePath}`);\n await writeFile(resolvedCachePath, result);\n }\n\n return {\n md5Sum: resolvedMd5,\n cachePath: resolvedCachePath,\n };\n } finally {\n delete tasks[inputKey];\n }\n })();\n\n tasks[inputKey] = fullTask;\n return await fullTask;\n };\n};\n"],"mappings":"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"}
|