@warlock.js/core 4.1.5 → 4.1.6
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/esm/config/config-getter.mjs +5 -5
- package/esm/config/config-getter.mjs.map +1 -1
- package/esm/config/config-loader.mjs +2 -2
- package/esm/config/config-loader.mjs.map +1 -1
- package/esm/connectors/cache-connector.mjs +2 -2
- package/esm/connectors/cache-connector.mjs.map +1 -1
- package/esm/connectors/database-connector.mjs +2 -2
- package/esm/connectors/database-connector.mjs.map +1 -1
- package/esm/connectors/herald-connector.mjs +2 -2
- package/esm/connectors/herald-connector.mjs.map +1 -1
- package/esm/connectors/http-connector.mjs +5 -5
- package/esm/connectors/http-connector.mjs.map +1 -1
- package/esm/connectors/logger-connector.mjs +2 -2
- package/esm/connectors/logger-connector.mjs.map +1 -1
- package/esm/connectors/mail-connector.mjs +2 -2
- package/esm/connectors/mail-connector.mjs.map +1 -1
- package/esm/connectors/socket-connector.mjs +3 -3
- package/esm/connectors/socket-connector.mjs.map +1 -1
- package/esm/dev-server/health-checker/workers/eslint-health.worker.d.mts +1 -0
- package/esm/dev-server/health-checker/workers/ts-health.worker.d.mts +1 -0
- package/esm/dev-server/loader/hook-thread.d.mts +48 -0
- package/esm/dev-server/loader/hook-thread.d.mts.map +1 -0
- package/esm/dev-server/loader/hook-thread.mjs +47 -0
- package/esm/dev-server/loader/hook-thread.mjs.map +1 -0
- package/esm/dev-server/loader/load-hook.d.mts +58 -0
- package/esm/dev-server/loader/load-hook.d.mts.map +1 -0
- package/esm/dev-server/loader/load-hook.mjs +86 -0
- package/esm/dev-server/loader/load-hook.mjs.map +1 -0
- package/esm/dev-server/loader/own-resolver.mjs +69 -0
- package/esm/dev-server/loader/own-resolver.mjs.map +1 -0
- package/esm/dev-server/loader/resolve-capture.mjs +43 -0
- package/esm/dev-server/loader/resolve-capture.mjs.map +1 -0
- package/esm/dev-server/loader/resolve-hook.d.mts +41 -0
- package/esm/dev-server/loader/resolve-hook.d.mts.map +1 -0
- package/esm/dev-server/loader/resolve-hook.mjs +87 -0
- package/esm/dev-server/loader/resolve-hook.mjs.map +1 -0
- package/esm/dev-server/loader/source-slug.mjs +22 -0
- package/esm/dev-server/loader/source-slug.mjs.map +1 -0
- package/esm/dev-server/loader/transpile-cache.mjs +163 -1
- package/esm/dev-server/loader/transpile-cache.mjs.map +1 -1
- package/esm/dev-server/loader/version-registry.mjs +45 -0
- package/esm/dev-server/loader/version-registry.mjs.map +1 -0
- package/esm/http/config.mjs +2 -2
- package/esm/http/config.mjs.map +1 -1
- package/esm/http/createHttpApplication.mjs +2 -2
- package/esm/http/createHttpApplication.mjs.map +1 -1
- package/esm/http/middleware/idempotency.middleware.mjs +5 -5
- package/esm/http/middleware/idempotency.middleware.mjs.map +1 -1
- package/esm/http/middleware/inject-request-context.mjs +2 -2
- package/esm/http/middleware/inject-request-context.mjs.map +1 -1
- package/esm/http/middleware/maintenance.middleware.mjs +4 -4
- package/esm/http/middleware/maintenance.middleware.mjs.map +1 -1
- package/esm/http/plugins.mjs +9 -9
- package/esm/http/plugins.mjs.map +1 -1
- package/esm/http/response.mjs +5 -5
- package/esm/http/response.mjs.map +1 -1
- package/esm/http/server.mjs +2 -2
- package/esm/http/server.mjs.map +1 -1
- package/esm/utils/paths.mjs +2 -2
- package/esm/utils/paths.mjs.map +1 -1
- package/esm/validation/validateAll.mjs +2 -2
- package/esm/validation/validateAll.mjs.map +1 -1
- package/package.json +27 -9
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { TranspileCache, cacheKey } from "./transpile-cache.mjs";
|
|
2
|
+
import { sourceSlug } from "./source-slug.mjs";
|
|
3
|
+
import { transformSync } from "esbuild";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
//#region ../../@warlock.js/core/src/dev-server/loader/load-hook.ts
|
|
8
|
+
const VERSION_QUERY = /\?v=\d+$/;
|
|
9
|
+
const TS_FILE = /\.(ts|tsx|mts|cts)$/;
|
|
10
|
+
let transpile;
|
|
11
|
+
let cache;
|
|
12
|
+
/**
|
|
13
|
+
* Called once from the hook worker's `initialize()`. Wires the persisted
|
|
14
|
+
* transpile cache and runs one opportunistic GC pass at boot — cheap, off
|
|
15
|
+
* the hot path, single directory scan.
|
|
16
|
+
*/
|
|
17
|
+
function configureTranspile(init) {
|
|
18
|
+
transpile = init;
|
|
19
|
+
cache = new TranspileCache(init.cacheDir);
|
|
20
|
+
cache.gc({
|
|
21
|
+
maxBytes: 256 * 1024 * 1024,
|
|
22
|
+
maxAgeMs: 336 * 60 * 60 * 1e3
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* ESM loader `load()` hook.
|
|
27
|
+
*
|
|
28
|
+
* Owns the transpile for **every** TypeScript file — project `src/`,
|
|
29
|
+
* `@warlock.js/*` framework source, and `warlock.config.ts` alike. There
|
|
30
|
+
* is no tsx in the TypeScript path any more: we read the source, key it by
|
|
31
|
+
* a content hash + options fingerprint, serve the cached JS on a hit, and
|
|
32
|
+
* otherwise transform it with esbuild directly and persist. Non-TypeScript
|
|
33
|
+
* URLs (npm `.js`/`.cjs`/`.mjs`, `node:` builtins, JSON) are not ours —
|
|
34
|
+
* they fall through to the next loader (Node's default).
|
|
35
|
+
*
|
|
36
|
+
* The `?v=<N>` query drives Node's module identity (HMR) for `src/` files
|
|
37
|
+
* and is **never** part of the cache key — the cache is keyed purely by
|
|
38
|
+
* source content, so the two mechanisms are fully orthogonal. Framework
|
|
39
|
+
* and config `.ts` carry no `?v` (no HMR) but are transpiled and cached
|
|
40
|
+
* exactly the same way.
|
|
41
|
+
*
|
|
42
|
+
* The source map esbuild emits is inline base64 with `sourcefile` set to
|
|
43
|
+
* the clean absolute `.ts` path, so V8 (run with source maps enabled)
|
|
44
|
+
* reports stack frames at the original TypeScript location.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* // load("file:///src/user.model.ts?v=3") → esbuild transpile + cache
|
|
48
|
+
* // load("file:///@warlock.js/core/src/x.ts") → esbuild transpile + cache
|
|
49
|
+
* // load("file:///node_modules/ms/index.js") → nextLoad (Node default)
|
|
50
|
+
*/
|
|
51
|
+
async function load(url, context, nextLoad) {
|
|
52
|
+
const cleanUrl = url.replace(VERSION_QUERY, "");
|
|
53
|
+
if (!TS_FILE.test(cleanUrl)) return nextLoad(url, context);
|
|
54
|
+
const absolutePath = fileURLToPath(cleanUrl);
|
|
55
|
+
const source = readFileSync(absolutePath, "utf8");
|
|
56
|
+
const key = cacheKey(source, transpile.fingerprint);
|
|
57
|
+
const label = transpile.debugNames ? sourceSlug(absolutePath) : void 0;
|
|
58
|
+
const hit = cache.get(key, label);
|
|
59
|
+
if (hit) return {
|
|
60
|
+
format: "module",
|
|
61
|
+
source: hit.code,
|
|
62
|
+
shortCircuit: true
|
|
63
|
+
};
|
|
64
|
+
const result = transformSync(source, {
|
|
65
|
+
loader: absolutePath.endsWith(".tsx") ? "tsx" : "ts",
|
|
66
|
+
format: "esm",
|
|
67
|
+
target: transpile.target,
|
|
68
|
+
sourcefile: absolutePath,
|
|
69
|
+
sourcemap: "inline",
|
|
70
|
+
tsconfigRaw: transpile.tsconfigRaw
|
|
71
|
+
});
|
|
72
|
+
const code = label ? `${result.code}\n// @source ${absolutePath}\n` : result.code;
|
|
73
|
+
cache.put(key, {
|
|
74
|
+
code,
|
|
75
|
+
map: ""
|
|
76
|
+
}, label);
|
|
77
|
+
return {
|
|
78
|
+
format: "module",
|
|
79
|
+
source: code,
|
|
80
|
+
shortCircuit: true
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
//#endregion
|
|
85
|
+
export { configureTranspile, load };
|
|
86
|
+
//# sourceMappingURL=load-hook.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"load-hook.mjs","names":[],"sources":["../../../../../../../@warlock.js/core/src/dev-server/loader/load-hook.ts"],"sourcesContent":["import { transformSync } from \"esbuild\";\nimport { readFileSync } from \"node:fs\";\nimport { fileURLToPath } from \"node:url\";\nimport { sourceSlug } from \"./source-slug.js\";\nimport { cacheKey, TranspileCache } from \"./transpile-cache.js\";\n\ntype LoadContext = {\n format?: string;\n importAttributes?: Record<string, string>;\n};\n\ntype LoadResult = {\n format: string;\n source: string | Buffer;\n shortCircuit?: boolean;\n};\n\ntype NextLoad = (url: string, context?: LoadContext) => Promise<LoadResult>;\n\n/**\n * Everything the transpile path needs, computed once on the main thread and\n * shipped into the hook worker via `initialize()`. Always present — esbuild\n * transpile is unconditional now (tsx is no longer in the TS path).\n */\nexport type TranspileInit = {\n /** Absolute `.warlock/transpile` directory. */\n cacheDir: string;\n /** Source-content key salt: esbuild version + epoch + tsconfig blob. */\n fingerprint: string;\n /** esbuild `target` (e.g. `\"node22\"` — must match the prod builder). */\n target: string;\n /** JSON string of `{ compilerOptions }` handed to esbuild as tsconfigRaw. */\n tsconfigRaw: string;\n /**\n * Debug-only: name cache files `<slug>.<hash>.js` and append a trailing\n * `// @source <path>` marker so entries are eyeball-identifiable. Off by\n * default (opaque `<hash>.js`). Cosmetic — never affects keys or lookup.\n */\n debugNames: boolean;\n};\n\nconst VERSION_QUERY = /\\?v=\\d+$/;\nconst TS_FILE = /\\.(ts|tsx|mts|cts)$/;\n\nlet transpile: TranspileInit;\nlet cache: TranspileCache;\n\n/**\n * Called once from the hook worker's `initialize()`. Wires the persisted\n * transpile cache and runs one opportunistic GC pass at boot — cheap, off\n * the hot path, single directory scan.\n */\nexport function configureTranspile(init: TranspileInit): void {\n transpile = init;\n cache = new TranspileCache(init.cacheDir);\n\n // Boot-time eviction so the cache can't grow unbounded across weeks of\n // dev. Bounds are generous; correctness never depends on GC (content\n // hashing already guarantees that) — this is purely disk hygiene.\n cache.gc({\n maxBytes: 256 * 1024 * 1024,\n maxAgeMs: 14 * 24 * 60 * 60 * 1000,\n });\n}\n\n/**\n * ESM loader `load()` hook.\n *\n * Owns the transpile for **every** TypeScript file — project `src/`,\n * `@warlock.js/*` framework source, and `warlock.config.ts` alike. There\n * is no tsx in the TypeScript path any more: we read the source, key it by\n * a content hash + options fingerprint, serve the cached JS on a hit, and\n * otherwise transform it with esbuild directly and persist. Non-TypeScript\n * URLs (npm `.js`/`.cjs`/`.mjs`, `node:` builtins, JSON) are not ours —\n * they fall through to the next loader (Node's default).\n *\n * The `?v=<N>` query drives Node's module identity (HMR) for `src/` files\n * and is **never** part of the cache key — the cache is keyed purely by\n * source content, so the two mechanisms are fully orthogonal. Framework\n * and config `.ts` carry no `?v` (no HMR) but are transpiled and cached\n * exactly the same way.\n *\n * The source map esbuild emits is inline base64 with `sourcefile` set to\n * the clean absolute `.ts` path, so V8 (run with source maps enabled)\n * reports stack frames at the original TypeScript location.\n *\n * @example\n * // load(\"file:///src/user.model.ts?v=3\") → esbuild transpile + cache\n * // load(\"file:///@warlock.js/core/src/x.ts\") → esbuild transpile + cache\n * // load(\"file:///node_modules/ms/index.js\") → nextLoad (Node default)\n */\nexport async function load(\n url: string,\n context: LoadContext,\n nextLoad: NextLoad,\n): Promise<LoadResult> {\n const cleanUrl = url.replace(VERSION_QUERY, \"\");\n\n // Not TypeScript → not ours. npm packages, node: builtins, JSON, etc.\n if (!TS_FILE.test(cleanUrl)) {\n return nextLoad(url, context);\n }\n\n const absolutePath = fileURLToPath(cleanUrl);\n const source = readFileSync(absolutePath, \"utf8\");\n const key = cacheKey(source, transpile.fingerprint);\n\n // Cosmetic filename label; undefined keeps opaque <hash>.js names.\n const label = transpile.debugNames ? sourceSlug(absolutePath) : undefined;\n\n const hit = cache.get(key, label);\n if (hit) {\n return { format: \"module\", source: hit.code, shortCircuit: true };\n }\n\n const loader = absolutePath.endsWith(\".tsx\") ? \"tsx\" : \"ts\";\n\n const result = transformSync(source, {\n loader,\n format: \"esm\",\n target: transpile.target,\n sourcefile: absolutePath,\n // Inline base64 map, self-contained (sourcesContent on by default), so\n // it survives the ?v=N URL with no extra resolution step.\n sourcemap: \"inline\",\n tsconfigRaw: transpile.tsconfigRaw,\n });\n\n // result.code already carries the inline //# sourceMappingURL. The\n // marker goes AFTER that line: a trailing comment can't shift earlier\n // generated lines, so the inline map (and therefore stack-frame line\n // numbers) stays exact. A leading marker would desync it by one line.\n const code = label\n ? `${result.code}\\n// @source ${absolutePath}\\n`\n : result.code;\n\n cache.put(key, { code, map: \"\" }, label);\n\n return { format: \"module\", source: code, shortCircuit: true };\n}\n"],"mappings":";;;;;;;AAyCA,MAAM,gBAAgB;AACtB,MAAM,UAAU;AAEhB,IAAI;AACJ,IAAI;;;;;;AAOJ,SAAgB,mBAAmB,MAA2B;CAC5D,YAAY;CACZ,QAAQ,IAAI,eAAe,KAAK,QAAQ;CAKxC,MAAM,GAAG;EACP,UAAU,MAAM,OAAO;EACvB,UAAU,MAAU,KAAK,KAAK;CAChC,CAAC;AACH;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,eAAsB,KACpB,KACA,SACA,UACqB;CACrB,MAAM,WAAW,IAAI,QAAQ,eAAe,EAAE;CAG9C,IAAI,CAAC,QAAQ,KAAK,QAAQ,GACxB,OAAO,SAAS,KAAK,OAAO;CAG9B,MAAM,eAAe,cAAc,QAAQ;CAC3C,MAAM,SAAS,aAAa,cAAc,MAAM;CAChD,MAAM,MAAM,SAAS,QAAQ,UAAU,WAAW;CAGlD,MAAM,QAAQ,UAAU,aAAa,WAAW,YAAY,IAAI;CAEhE,MAAM,MAAM,MAAM,IAAI,KAAK,KAAK;CAChC,IAAI,KACF,OAAO;EAAE,QAAQ;EAAU,QAAQ,IAAI;EAAM,cAAc;CAAK;CAKlE,MAAM,SAAS,cAAc,QAAQ;EACnC,QAHa,aAAa,SAAS,MAAM,IAAI,QAAQ;EAIrD,QAAQ;EACR,QAAQ,UAAU;EAClB,YAAY;EAGZ,WAAW;EACX,aAAa,UAAU;CACzB,CAAC;CAMD,MAAM,OAAO,QACT,GAAG,OAAO,KAAK,eAAe,aAAa,MAC3C,OAAO;CAEX,MAAM,IAAI,KAAK;EAAE;EAAM,KAAK;CAAG,GAAG,KAAK;CAEvC,OAAO;EAAE,QAAQ;EAAU,QAAQ;EAAM,cAAc;CAAK;AAC9D"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { URL, fileURLToPath, pathToFileURL } from "node:url";
|
|
2
|
+
|
|
3
|
+
//#region ../../@warlock.js/core/src/dev-server/loader/own-resolver.ts
|
|
4
|
+
/**
|
|
5
|
+
* Extension/`index` probing for one candidate base path, mirroring tsx's
|
|
6
|
+
* TypeScript-aware order:
|
|
7
|
+
*
|
|
8
|
+
* 1. exact (if it already has a real extension that exists)
|
|
9
|
+
* 2. `.js`/`.jsx`/`.mjs`/`.cjs` specifier → the TS source it compiles from
|
|
10
|
+
* (`.ts`/`.tsx`) — the standard "import the .js, ship the .ts" ESM style
|
|
11
|
+
* 3. append `.ts`, `.tsx`, `.js`, `.jsx`, `.json`
|
|
12
|
+
* 4. directory index: `<dir>/index.{ts,tsx,js,jsx}`
|
|
13
|
+
*
|
|
14
|
+
* Returns the resolved absolute path, or `null` if nothing exists.
|
|
15
|
+
*/
|
|
16
|
+
function probeFile(basePath, fileExists) {
|
|
17
|
+
const jsRewrite = basePath.match(/\.(js|jsx|mjs|cjs)$/);
|
|
18
|
+
if (jsRewrite) {
|
|
19
|
+
const stem = basePath.slice(0, -jsRewrite[0].length);
|
|
20
|
+
for (const ext of [".ts", ".tsx"]) if (fileExists(stem + ext)) return stem + ext;
|
|
21
|
+
}
|
|
22
|
+
if (/\.[a-z]+$/i.test(basePath) && fileExists(basePath)) return basePath;
|
|
23
|
+
for (const ext of [
|
|
24
|
+
".ts",
|
|
25
|
+
".tsx",
|
|
26
|
+
".js",
|
|
27
|
+
".jsx",
|
|
28
|
+
".json"
|
|
29
|
+
]) if (fileExists(basePath + ext)) return basePath + ext;
|
|
30
|
+
for (const ext of [
|
|
31
|
+
".ts",
|
|
32
|
+
".tsx",
|
|
33
|
+
".js",
|
|
34
|
+
".jsx"
|
|
35
|
+
]) {
|
|
36
|
+
const candidate = `${basePath}/index${ext}`;
|
|
37
|
+
if (fileExists(candidate)) return candidate;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function isRelative(specifier) {
|
|
42
|
+
return specifier.startsWith("./") || specifier.startsWith("../");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve `specifier` to a `file://` URL we own, or `null` to fall through
|
|
46
|
+
* to the next resolver.
|
|
47
|
+
*
|
|
48
|
+
* @param specifier - The import specifier.
|
|
49
|
+
* @param parentURL - Importing module's URL (already `?v=N`-stripped).
|
|
50
|
+
* @param pathsMatcher - `get-tsconfig` paths matcher (alias → candidates).
|
|
51
|
+
* @param fileExists - Existence probe (injected for testing).
|
|
52
|
+
*/
|
|
53
|
+
function ownResolve(specifier, parentURL, pathsMatcher, fileExists) {
|
|
54
|
+
if (specifier.startsWith("node:") || specifier.startsWith("file:") || specifier.startsWith("data:")) return null;
|
|
55
|
+
if (isRelative(specifier) || specifier.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(specifier)) {
|
|
56
|
+
if (!parentURL) return null;
|
|
57
|
+
const hit = probeFile(fileURLToPath(new URL(specifier, parentURL)).replace(/\\/g, "/"), fileExists);
|
|
58
|
+
return hit ? pathToFileURL(hit).href : null;
|
|
59
|
+
}
|
|
60
|
+
if (pathsMatcher) for (const candidate of pathsMatcher(specifier)) {
|
|
61
|
+
const hit = probeFile(candidate.replace(/\\/g, "/"), fileExists);
|
|
62
|
+
if (hit) return pathToFileURL(hit).href;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
//#endregion
|
|
68
|
+
export { ownResolve };
|
|
69
|
+
//# sourceMappingURL=own-resolver.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"own-resolver.mjs","names":[],"sources":["../../../../../../../@warlock.js/core/src/dev-server/loader/own-resolver.ts"],"sourcesContent":["import { fileURLToPath, pathToFileURL, URL } from \"node:url\";\n\n/**\n * The framework's own ESM `resolve()` core — the Phase B replacement for\n * delegating resolution to tsx.\n *\n * Scope by design: it owns exactly what Node's default resolver cannot do\n * (TypeScript extension/`index` probing and tsconfig `paths` aliases). For\n * anything else — bare npm packages, `node:` builtins, `file:`/`data:`\n * specifiers — it returns `null`, meaning \"not mine, fall through to the\n * next resolver\" (Node default, which already honours `node_modules` and\n * package `exports`). That keeps the risky surface minimal: we only have\n * to reproduce tsx's TS-path behaviour, not re-implement npm resolution.\n *\n * Pure and dependency-injected (`pathsMatcher`, `fileExists`) so the whole\n * thing is unit-testable and verifiable against the captured tsx-4.21\n * golden fixture without booting.\n */\n\nexport type PathsMatcher = (specifier: string) => string[];\nexport type FileExists = (absolutePath: string) => boolean;\n\n/**\n * Extension/`index` probing for one candidate base path, mirroring tsx's\n * TypeScript-aware order:\n *\n * 1. exact (if it already has a real extension that exists)\n * 2. `.js`/`.jsx`/`.mjs`/`.cjs` specifier → the TS source it compiles from\n * (`.ts`/`.tsx`) — the standard \"import the .js, ship the .ts\" ESM style\n * 3. append `.ts`, `.tsx`, `.js`, `.jsx`, `.json`\n * 4. directory index: `<dir>/index.{ts,tsx,js,jsx}`\n *\n * Returns the resolved absolute path, or `null` if nothing exists.\n */\nexport function probeFile(basePath: string, fileExists: FileExists): string | null {\n // 2. `.js` → `.ts`/`.tsx` rewrite (checked before the literal .js so a\n // co-located source wins, matching tsx).\n const jsRewrite = basePath.match(/\\.(js|jsx|mjs|cjs)$/);\n if (jsRewrite) {\n const stem = basePath.slice(0, -jsRewrite[0].length);\n for (const ext of [\".ts\", \".tsx\"]) {\n if (fileExists(stem + ext)) return stem + ext;\n }\n }\n\n // 1. exact (real extension already present and on disk)\n if (/\\.[a-z]+$/i.test(basePath) && fileExists(basePath)) {\n return basePath;\n }\n\n // 3. append source/asset extensions\n for (const ext of [\".ts\", \".tsx\", \".js\", \".jsx\", \".json\"]) {\n if (fileExists(basePath + ext)) return basePath + ext;\n }\n\n // 4. directory index\n for (const ext of [\".ts\", \".tsx\", \".js\", \".jsx\"]) {\n const candidate = `${basePath}/index${ext}`;\n if (fileExists(candidate)) return candidate;\n }\n\n return null;\n}\n\nfunction isRelative(specifier: string): boolean {\n return specifier.startsWith(\"./\") || specifier.startsWith(\"../\");\n}\n\n/**\n * Resolve `specifier` to a `file://` URL we own, or `null` to fall through\n * to the next resolver.\n *\n * @param specifier - The import specifier.\n * @param parentURL - Importing module's URL (already `?v=N`-stripped).\n * @param pathsMatcher - `get-tsconfig` paths matcher (alias → candidates).\n * @param fileExists - Existence probe (injected for testing).\n */\nexport function ownResolve(\n specifier: string,\n parentURL: string | undefined,\n pathsMatcher: PathsMatcher | null,\n fileExists: FileExists,\n): string | null {\n // Not ours: builtins and already-formed URLs (config-loader imports by\n // file: URL). Node default handles these.\n if (\n specifier.startsWith(\"node:\") ||\n specifier.startsWith(\"file:\") ||\n specifier.startsWith(\"data:\")\n ) {\n return null;\n }\n\n // Relative / absolute path imports → probe against the parent's dir.\n if (isRelative(specifier) || specifier.startsWith(\"/\") || /^[a-zA-Z]:[\\\\/]/.test(specifier)) {\n if (!parentURL) return null;\n const base = fileURLToPath(new URL(specifier, parentURL));\n const hit = probeFile(base.replace(/\\\\/g, \"/\"), fileExists);\n return hit ? pathToFileURL(hit).href : null;\n }\n\n // Bare / aliased: try tsconfig `paths`. Each candidate is probed; the\n // first that exists on disk wins. If none do (e.g. a real npm package\n // whose name happened to match a baseUrl guess), return null so Node\n // default resolves it from node_modules with full `exports` support.\n if (pathsMatcher) {\n for (const candidate of pathsMatcher(specifier)) {\n const hit = probeFile(candidate.replace(/\\\\/g, \"/\"), fileExists);\n if (hit) return pathToFileURL(hit).href;\n }\n }\n\n return null;\n}\n"],"mappings":";;;;;;;;;;;;;;;AAkCA,SAAgB,UAAU,UAAkB,YAAuC;CAGjF,MAAM,YAAY,SAAS,MAAM,qBAAqB;CACtD,IAAI,WAAW;EACb,MAAM,OAAO,SAAS,MAAM,GAAG,CAAC,UAAU,GAAG,MAAM;EACnD,KAAK,MAAM,OAAO,CAAC,OAAO,MAAM,GAC9B,IAAI,WAAW,OAAO,GAAG,GAAG,OAAO,OAAO;CAE9C;CAGA,IAAI,aAAa,KAAK,QAAQ,KAAK,WAAW,QAAQ,GACpD,OAAO;CAIT,KAAK,MAAM,OAAO;EAAC;EAAO;EAAQ;EAAO;EAAQ;CAAO,GACtD,IAAI,WAAW,WAAW,GAAG,GAAG,OAAO,WAAW;CAIpD,KAAK,MAAM,OAAO;EAAC;EAAO;EAAQ;EAAO;CAAM,GAAG;EAChD,MAAM,YAAY,GAAG,SAAS,QAAQ;EACtC,IAAI,WAAW,SAAS,GAAG,OAAO;CACpC;CAEA,OAAO;AACT;AAEA,SAAS,WAAW,WAA4B;CAC9C,OAAO,UAAU,WAAW,IAAI,KAAK,UAAU,WAAW,KAAK;AACjE;;;;;;;;;;AAWA,SAAgB,WACd,WACA,WACA,cACA,YACe;CAGf,IACE,UAAU,WAAW,OAAO,KAC5B,UAAU,WAAW,OAAO,KAC5B,UAAU,WAAW,OAAO,GAE5B,OAAO;CAIT,IAAI,WAAW,SAAS,KAAK,UAAU,WAAW,GAAG,KAAK,kBAAkB,KAAK,SAAS,GAAG;EAC3F,IAAI,CAAC,WAAW,OAAO;EAEvB,MAAM,MAAM,UADC,cAAc,IAAI,IAAI,WAAW,SAAS,CAC9B,EAAE,QAAQ,OAAO,GAAG,GAAG,UAAU;EAC1D,OAAO,MAAM,cAAc,GAAG,EAAE,OAAO;CACzC;CAMA,IAAI,cACF,KAAK,MAAM,aAAa,aAAa,SAAS,GAAG;EAC/C,MAAM,MAAM,UAAU,UAAU,QAAQ,OAAO,GAAG,GAAG,UAAU;EAC/D,IAAI,KAAK,OAAO,cAAc,GAAG,EAAE;CACrC;CAGF,OAAO;AACT"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
//#region ../../@warlock.js/core/src/dev-server/loader/resolve-capture.ts
|
|
4
|
+
/**
|
|
5
|
+
* Golden-fixture recorder for the Phase B resolver work.
|
|
6
|
+
*
|
|
7
|
+
* When `WARLOCK_RESOLVE_CAPTURE` points at a file path, every resolution
|
|
8
|
+
* the *current* (tsx-backed) hook performs is appended there as one JSON
|
|
9
|
+
* object per line: `{ specifier, parentURL, url, format }`, all with the
|
|
10
|
+
* `?v=N` HMR token stripped so the record is stable across runs.
|
|
11
|
+
*
|
|
12
|
+
* A full `yarn start` boot then yields the real import graph's resolutions
|
|
13
|
+
* — tsconfig paths, workspace `@warlock.js/*`, relative/extensionless,
|
|
14
|
+
* package `exports`, bare npm, `node:` builtins — as the ground truth the
|
|
15
|
+
* replacement resolver must reproduce exactly.
|
|
16
|
+
*
|
|
17
|
+
* Disabled (the default) it is a single env-var read and an immediate
|
|
18
|
+
* return: no measurable cost on the resolve hot path.
|
|
19
|
+
*/
|
|
20
|
+
const VERSION_QUERY = /\?v=\d+$/;
|
|
21
|
+
let resolvedPath;
|
|
22
|
+
function targetFile() {
|
|
23
|
+
if (resolvedPath !== void 0) return resolvedPath;
|
|
24
|
+
resolvedPath = process.env.WARLOCK_RESOLVE_CAPTURE || null;
|
|
25
|
+
return resolvedPath;
|
|
26
|
+
}
|
|
27
|
+
function captureResolution(record) {
|
|
28
|
+
const file = targetFile();
|
|
29
|
+
if (!file) return;
|
|
30
|
+
const line = JSON.stringify({
|
|
31
|
+
specifier: record.specifier,
|
|
32
|
+
parentURL: record.parentURL?.replace(VERSION_QUERY, ""),
|
|
33
|
+
url: record.url.replace(VERSION_QUERY, ""),
|
|
34
|
+
format: record.format
|
|
35
|
+
}) + "\n";
|
|
36
|
+
try {
|
|
37
|
+
appendFileSync(file, line);
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
//#endregion
|
|
42
|
+
export { captureResolution };
|
|
43
|
+
//# sourceMappingURL=resolve-capture.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-capture.mjs","names":[],"sources":["../../../../../../../@warlock.js/core/src/dev-server/loader/resolve-capture.ts"],"sourcesContent":["import { appendFileSync } from \"node:fs\";\n\n/**\n * Golden-fixture recorder for the Phase B resolver work.\n *\n * When `WARLOCK_RESOLVE_CAPTURE` points at a file path, every resolution\n * the *current* (tsx-backed) hook performs is appended there as one JSON\n * object per line: `{ specifier, parentURL, url, format }`, all with the\n * `?v=N` HMR token stripped so the record is stable across runs.\n *\n * A full `yarn start` boot then yields the real import graph's resolutions\n * — tsconfig paths, workspace `@warlock.js/*`, relative/extensionless,\n * package `exports`, bare npm, `node:` builtins — as the ground truth the\n * replacement resolver must reproduce exactly.\n *\n * Disabled (the default) it is a single env-var read and an immediate\n * return: no measurable cost on the resolve hot path.\n */\n\nconst VERSION_QUERY = /\\?v=\\d+$/;\n\nlet resolvedPath: string | null | undefined;\n\nfunction targetFile(): string | null {\n if (resolvedPath !== undefined) return resolvedPath;\n resolvedPath = process.env.WARLOCK_RESOLVE_CAPTURE || null;\n return resolvedPath;\n}\n\nexport type ResolutionRecord = {\n specifier: string;\n parentURL: string | undefined;\n url: string;\n format: string | undefined;\n};\n\nexport function captureResolution(record: ResolutionRecord): void {\n const file = targetFile();\n if (!file) return;\n\n const line =\n JSON.stringify({\n specifier: record.specifier,\n parentURL: record.parentURL?.replace(VERSION_QUERY, \"\"),\n url: record.url.replace(VERSION_QUERY, \"\"),\n format: record.format,\n }) + \"\\n\";\n\n try {\n appendFileSync(file, line);\n } catch {\n // Capture is a diagnostic aid — never let it break resolution.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAmBA,MAAM,gBAAgB;AAEtB,IAAI;AAEJ,SAAS,aAA4B;CACnC,IAAI,iBAAiB,QAAW,OAAO;CACvC,eAAe,QAAQ,IAAI,2BAA2B;CACtD,OAAO;AACT;AASA,SAAgB,kBAAkB,QAAgC;CAChE,MAAM,OAAO,WAAW;CACxB,IAAI,CAAC,MAAM;CAEX,MAAM,OACJ,KAAK,UAAU;EACb,WAAW,OAAO;EAClB,WAAW,OAAO,WAAW,QAAQ,eAAe,EAAE;EACtD,KAAK,OAAO,IAAI,QAAQ,eAAe,EAAE;EACzC,QAAQ,OAAO;CACjB,CAAC,IAAI;CAEP,IAAI;EACF,eAAe,MAAM,IAAI;CAC3B,QAAQ,CAER;AACF"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//#region ../../@warlock.js/core/src/dev-server/loader/resolve-hook.d.ts
|
|
2
|
+
type ResolveContext = {
|
|
3
|
+
parentURL?: string;
|
|
4
|
+
conditions?: string[];
|
|
5
|
+
importAttributes?: Record<string, string>;
|
|
6
|
+
};
|
|
7
|
+
type ResolveResult = {
|
|
8
|
+
url: string;
|
|
9
|
+
format?: string;
|
|
10
|
+
shortCircuit?: boolean;
|
|
11
|
+
};
|
|
12
|
+
type NextResolve = (specifier: string, context?: ResolveContext) => Promise<ResolveResult>;
|
|
13
|
+
/**
|
|
14
|
+
* ESM loader `resolve()` hook.
|
|
15
|
+
*
|
|
16
|
+
* The framework's own resolver (`ownResolve`) handles tsconfig `paths`,
|
|
17
|
+
* aliases, and TS extension/`index` probing. Specifiers it doesn't own
|
|
18
|
+
* (bare npm, `node:`, `file:`) fall through to `nextResolve` (Node
|
|
19
|
+
* default). After resolution, stamps a `?v=<N>` version token onto any
|
|
20
|
+
* `.ts` / `.tsx` file that lives under the configured `src/` root.
|
|
21
|
+
*
|
|
22
|
+
* The version token is the cache-bust mechanism: when a file changes the main
|
|
23
|
+
* thread bumps its counter, the next `import()` produces a new URL, Node sees
|
|
24
|
+
* a cache miss, and loads fresh content — native ESM cycles are preserved
|
|
25
|
+
* because static imports remain static throughout.
|
|
26
|
+
*
|
|
27
|
+
* **parentURL cleaning**: the resolved URL carries `?v=N` as a suffix. If a
|
|
28
|
+
* module loaded this way statically imports a relative sibling, Node uses that
|
|
29
|
+
* `?v=N` URL as the `parentURL` for the next `resolve()` call. Passing it
|
|
30
|
+
* unmodified to `nextResolve()` causes tsx to produce paths like
|
|
31
|
+
* `some-sibling.ts?v=0` (the query leaks into URL joining). We strip `?v=N`
|
|
32
|
+
* from `parentURL` before forwarding to prevent this.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* // First import of user.model.ts → ?v=0
|
|
36
|
+
* // File watcher bumps → next import → ?v=1 → fresh module
|
|
37
|
+
*/
|
|
38
|
+
declare function resolve(specifier: string, context: ResolveContext, nextResolve: NextResolve): Promise<ResolveResult>;
|
|
39
|
+
//#endregion
|
|
40
|
+
export { resolve };
|
|
41
|
+
//# sourceMappingURL=resolve-hook.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-hook.d.mts","names":[],"sources":["../../../../../../../@warlock.js/core/src/dev-server/loader/resolve-hook.ts"],"mappings":";KAmCK,cAAA;EACH,SAAA;EACA,UAAA;EACA,gBAAA,GAAmB,MAAM;AAAA;AAAA,KAGtB,aAAA;EACH,GAAA;EACA,MAAA;EACA,YAAA;AAAA;AAAA,KAGG,WAAA,IAAe,SAAA,UAAmB,OAAA,GAAU,cAAA,KAAmB,OAAA,CAAQ,aAAA;;AAH9D;AAAA;;;;;;;;;;;;;;AAG2E;AA2BzF;;;;;;;;iBAAsB,OAAA,CACpB,SAAA,UACA,OAAA,EAAS,cAAA,EACT,WAAA,EAAa,WAAA,GACZ,OAAA,CAAQ,aAAA"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ownResolve } from "./own-resolver.mjs";
|
|
2
|
+
import { captureResolution } from "./resolve-capture.mjs";
|
|
3
|
+
import { getVersion } from "./version-registry.mjs";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import { createPathsMatcher, getTsconfig } from "get-tsconfig";
|
|
7
|
+
|
|
8
|
+
//#region ../../@warlock.js/core/src/dev-server/loader/resolve-hook.ts
|
|
9
|
+
/**
|
|
10
|
+
* The `file://` URL prefix for the project's `src/` root, set once during
|
|
11
|
+
* `initialize()`. Used to identify which resolved URLs belong to user code.
|
|
12
|
+
*/
|
|
13
|
+
let srcRootUrl = "";
|
|
14
|
+
/**
|
|
15
|
+
* Configure the source root used by the resolve hook.
|
|
16
|
+
* Must be called from `initialize()` before any imports are resolved.
|
|
17
|
+
*
|
|
18
|
+
* @param srcRoot - Absolute filesystem path to the project `src/` directory.
|
|
19
|
+
*/
|
|
20
|
+
function setSrcRoot(srcRoot) {
|
|
21
|
+
srcRootUrl = pathToFileURL(srcRoot).href + "/";
|
|
22
|
+
}
|
|
23
|
+
/** Lazily-built tsconfig `paths` matcher (worker has no startup hook). */
|
|
24
|
+
let pathsMatcher = null;
|
|
25
|
+
let pathsMatcherBuilt = false;
|
|
26
|
+
function getPathsMatcher() {
|
|
27
|
+
if (pathsMatcherBuilt) return pathsMatcher;
|
|
28
|
+
pathsMatcherBuilt = true;
|
|
29
|
+
const tsconfig = getTsconfig(process.cwd());
|
|
30
|
+
pathsMatcher = tsconfig ? createPathsMatcher(tsconfig) : null;
|
|
31
|
+
return pathsMatcher;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* ESM loader `resolve()` hook.
|
|
35
|
+
*
|
|
36
|
+
* The framework's own resolver (`ownResolve`) handles tsconfig `paths`,
|
|
37
|
+
* aliases, and TS extension/`index` probing. Specifiers it doesn't own
|
|
38
|
+
* (bare npm, `node:`, `file:`) fall through to `nextResolve` (Node
|
|
39
|
+
* default). After resolution, stamps a `?v=<N>` version token onto any
|
|
40
|
+
* `.ts` / `.tsx` file that lives under the configured `src/` root.
|
|
41
|
+
*
|
|
42
|
+
* The version token is the cache-bust mechanism: when a file changes the main
|
|
43
|
+
* thread bumps its counter, the next `import()` produces a new URL, Node sees
|
|
44
|
+
* a cache miss, and loads fresh content — native ESM cycles are preserved
|
|
45
|
+
* because static imports remain static throughout.
|
|
46
|
+
*
|
|
47
|
+
* **parentURL cleaning**: the resolved URL carries `?v=N` as a suffix. If a
|
|
48
|
+
* module loaded this way statically imports a relative sibling, Node uses that
|
|
49
|
+
* `?v=N` URL as the `parentURL` for the next `resolve()` call. Passing it
|
|
50
|
+
* unmodified to `nextResolve()` causes tsx to produce paths like
|
|
51
|
+
* `some-sibling.ts?v=0` (the query leaks into URL joining). We strip `?v=N`
|
|
52
|
+
* from `parentURL` before forwarding to prevent this.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* // First import of user.model.ts → ?v=0
|
|
56
|
+
* // File watcher bumps → next import → ?v=1 → fresh module
|
|
57
|
+
*/
|
|
58
|
+
async function resolve(specifier, context, nextResolve) {
|
|
59
|
+
const cleanContext = context.parentURL?.includes("?v=") ? {
|
|
60
|
+
...context,
|
|
61
|
+
parentURL: context.parentURL.replace(/\?v=\d+$/, "")
|
|
62
|
+
} : context;
|
|
63
|
+
const owned = ownResolve(specifier, cleanContext.parentURL, getPathsMatcher(), existsSync);
|
|
64
|
+
const result = owned ? {
|
|
65
|
+
url: owned,
|
|
66
|
+
shortCircuit: true
|
|
67
|
+
} : await nextResolve(specifier, cleanContext);
|
|
68
|
+
const { url } = result;
|
|
69
|
+
captureResolution({
|
|
70
|
+
specifier,
|
|
71
|
+
parentURL: cleanContext.parentURL,
|
|
72
|
+
url,
|
|
73
|
+
format: result.format
|
|
74
|
+
});
|
|
75
|
+
const cleanUrl = url.replace(/\?v=\d+$/, "");
|
|
76
|
+
if (!cleanUrl.startsWith(srcRootUrl) || !cleanUrl.endsWith(".ts") && !cleanUrl.endsWith(".tsx")) return result;
|
|
77
|
+
const version = getVersion(fileURLToPath(cleanUrl));
|
|
78
|
+
return {
|
|
79
|
+
...result,
|
|
80
|
+
url: `${cleanUrl}?v=${version}`,
|
|
81
|
+
shortCircuit: true
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
//#endregion
|
|
86
|
+
export { resolve, setSrcRoot };
|
|
87
|
+
//# sourceMappingURL=resolve-hook.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-hook.mjs","names":[],"sources":["../../../../../../../@warlock.js/core/src/dev-server/loader/resolve-hook.ts"],"sourcesContent":["import { createPathsMatcher, getTsconfig } from \"get-tsconfig\";\nimport { existsSync } from \"node:fs\";\nimport { fileURLToPath, pathToFileURL } from \"node:url\";\nimport { ownResolve, type PathsMatcher } from \"./own-resolver.js\";\nimport { captureResolution } from \"./resolve-capture.js\";\nimport { getVersion } from \"./version-registry.js\";\n\n/**\n * The `file://` URL prefix for the project's `src/` root, set once during\n * `initialize()`. Used to identify which resolved URLs belong to user code.\n */\nlet srcRootUrl = \"\";\n\n/**\n * Configure the source root used by the resolve hook.\n * Must be called from `initialize()` before any imports are resolved.\n *\n * @param srcRoot - Absolute filesystem path to the project `src/` directory.\n */\nexport function setSrcRoot(srcRoot: string): void {\n srcRootUrl = pathToFileURL(srcRoot).href + \"/\";\n}\n\n/** Lazily-built tsconfig `paths` matcher (worker has no startup hook). */\nlet pathsMatcher: PathsMatcher | null = null;\nlet pathsMatcherBuilt = false;\n\nfunction getPathsMatcher(): PathsMatcher | null {\n if (pathsMatcherBuilt) return pathsMatcher;\n pathsMatcherBuilt = true;\n const tsconfig = getTsconfig(process.cwd());\n pathsMatcher = tsconfig ? createPathsMatcher(tsconfig) : null;\n return pathsMatcher;\n}\n\ntype ResolveContext = {\n parentURL?: string;\n conditions?: string[];\n importAttributes?: Record<string, string>;\n};\n\ntype ResolveResult = {\n url: string;\n format?: string;\n shortCircuit?: boolean;\n};\n\ntype NextResolve = (specifier: string, context?: ResolveContext) => Promise<ResolveResult>;\n\n/**\n * ESM loader `resolve()` hook.\n *\n * The framework's own resolver (`ownResolve`) handles tsconfig `paths`,\n * aliases, and TS extension/`index` probing. Specifiers it doesn't own\n * (bare npm, `node:`, `file:`) fall through to `nextResolve` (Node\n * default). After resolution, stamps a `?v=<N>` version token onto any\n * `.ts` / `.tsx` file that lives under the configured `src/` root.\n *\n * The version token is the cache-bust mechanism: when a file changes the main\n * thread bumps its counter, the next `import()` produces a new URL, Node sees\n * a cache miss, and loads fresh content — native ESM cycles are preserved\n * because static imports remain static throughout.\n *\n * **parentURL cleaning**: the resolved URL carries `?v=N` as a suffix. If a\n * module loaded this way statically imports a relative sibling, Node uses that\n * `?v=N` URL as the `parentURL` for the next `resolve()` call. Passing it\n * unmodified to `nextResolve()` causes tsx to produce paths like\n * `some-sibling.ts?v=0` (the query leaks into URL joining). We strip `?v=N`\n * from `parentURL` before forwarding to prevent this.\n *\n * @example\n * // First import of user.model.ts → ?v=0\n * // File watcher bumps → next import → ?v=1 → fresh module\n */\nexport async function resolve(\n specifier: string,\n context: ResolveContext,\n nextResolve: NextResolve,\n): Promise<ResolveResult> {\n const cleanContext =\n context.parentURL?.includes(\"?v=\")\n ? { ...context, parentURL: context.parentURL.replace(/\\?v=\\d+$/, \"\") }\n : context;\n\n // The framework's own resolver owns tsconfig `paths` + TS\n // extension/index probing. Anything it returns `null` for (bare npm,\n // `node:`, `file:`) falls through to Node default via `nextResolve`.\n const owned = ownResolve(\n specifier,\n cleanContext.parentURL,\n getPathsMatcher(),\n existsSync,\n );\n // When we produced the URL ourselves we did NOT call nextResolve, so the\n // result must short-circuit the loader chain or Node throws\n // ERR_LOADER_CHAIN_INCOMPLETE (e.g. for @warlock.js/* .ts that resolve\n // outside the project src root and hit the early return below).\n const result: ResolveResult = owned\n ? { url: owned, shortCircuit: true }\n : await nextResolve(specifier, cleanContext);\n\n const { url } = result;\n\n // Ground-truth recorder (no-op unless WARLOCK_RESOLVE_CAPTURE is set):\n // logs the answer for this (specifier, parentURL) so the Phase B\n // resolver can be proven to reproduce tsx exactly.\n captureResolution({\n specifier,\n parentURL: cleanContext.parentURL,\n url,\n format: result.format,\n });\n\n const cleanUrl = url.replace(/\\?v=\\d+$/, \"\");\n\n if (\n !cleanUrl.startsWith(srcRootUrl) ||\n (!cleanUrl.endsWith(\".ts\") && !cleanUrl.endsWith(\".tsx\"))\n ) {\n return result;\n }\n\n const absolutePath = fileURLToPath(cleanUrl);\n const version = getVersion(absolutePath);\n\n // Spread the full result from nextResolve (preserves `format` and any other\n // fields tsx sets) and only override `url`. Dropping `format` would cause\n // tsx's load hook to see an undefined format and fall through to Node's\n // default loader, which rejects unknown extensions like .tsx.\n return { ...result, url: `${cleanUrl}?v=${version}`, shortCircuit: true };\n}\n"],"mappings":";;;;;;;;;;;;AAWA,IAAI,aAAa;;;;;;;AAQjB,SAAgB,WAAW,SAAuB;CAChD,aAAa,cAAc,OAAO,EAAE,OAAO;AAC7C;;AAGA,IAAI,eAAoC;AACxC,IAAI,oBAAoB;AAExB,SAAS,kBAAuC;CAC9C,IAAI,mBAAmB,OAAO;CAC9B,oBAAoB;CACpB,MAAM,WAAW,YAAY,QAAQ,IAAI,CAAC;CAC1C,eAAe,WAAW,mBAAmB,QAAQ,IAAI;CACzD,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,eAAsB,QACpB,WACA,SACA,aACwB;CACxB,MAAM,eACJ,QAAQ,WAAW,SAAS,KAAK,IAC7B;EAAE,GAAG;EAAS,WAAW,QAAQ,UAAU,QAAQ,YAAY,EAAE;CAAE,IACnE;CAKN,MAAM,QAAQ,WACZ,WACA,aAAa,WACb,gBAAgB,GAChB,UACF;CAKA,MAAM,SAAwB,QAC1B;EAAE,KAAK;EAAO,cAAc;CAAK,IACjC,MAAM,YAAY,WAAW,YAAY;CAE7C,MAAM,EAAE,QAAQ;CAKhB,kBAAkB;EAChB;EACA,WAAW,aAAa;EACxB;EACA,QAAQ,OAAO;CACjB,CAAC;CAED,MAAM,WAAW,IAAI,QAAQ,YAAY,EAAE;CAE3C,IACE,CAAC,SAAS,WAAW,UAAU,KAC9B,CAAC,SAAS,SAAS,KAAK,KAAK,CAAC,SAAS,SAAS,MAAM,GAEvD,OAAO;CAIT,MAAM,UAAU,WADK,cAAc,QACG,CAAC;CAMvC,OAAO;EAAE,GAAG;EAAQ,KAAK,GAAG,SAAS,KAAK;EAAW,cAAc;CAAK;AAC1E"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region ../../@warlock.js/core/src/dev-server/loader/source-slug.ts
|
|
2
|
+
/**
|
|
3
|
+
* Human-readable label for a cached transpile file, derived from the last
|
|
4
|
+
* three path segments of the source file.
|
|
5
|
+
*
|
|
6
|
+
* Purely cosmetic: it rides in front of the content hash in the cache
|
|
7
|
+
* filename so `ls .warlock/transpile/<shard>/` is browsable when debugging.
|
|
8
|
+
* It is **never** used as a cache key, module URL, or source-map
|
|
9
|
+
* `sourcefile` — correctness still rests entirely on the content hash, so
|
|
10
|
+
* the slug is allowed to be lossy (sanitised, capped).
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* sourceSlug("D:/proj/src/app/vectors/utils/locales.ts")
|
|
14
|
+
* // → "vectors-utils-locales"
|
|
15
|
+
*/
|
|
16
|
+
function sourceSlug(absolutePath) {
|
|
17
|
+
return absolutePath.replace(/\\/g, "/").replace(/\.[cm]?[jt]sx?$/, "").split("/").filter(Boolean).slice(-3).join("-").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "src";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
//#endregion
|
|
21
|
+
export { sourceSlug };
|
|
22
|
+
//# sourceMappingURL=source-slug.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"source-slug.mjs","names":[],"sources":["../../../../../../../@warlock.js/core/src/dev-server/loader/source-slug.ts"],"sourcesContent":["/**\n * Human-readable label for a cached transpile file, derived from the last\n * three path segments of the source file.\n *\n * Purely cosmetic: it rides in front of the content hash in the cache\n * filename so `ls .warlock/transpile/<shard>/` is browsable when debugging.\n * It is **never** used as a cache key, module URL, or source-map\n * `sourcefile` — correctness still rests entirely on the content hash, so\n * the slug is allowed to be lossy (sanitised, capped).\n *\n * @example\n * sourceSlug(\"D:/proj/src/app/vectors/utils/locales.ts\")\n * // → \"vectors-utils-locales\"\n */\nexport function sourceSlug(absolutePath: string): string {\n const noExt = absolutePath.replace(/\\\\/g, \"/\").replace(/\\.[cm]?[jt]sx?$/, \"\");\n\n const slug = noExt\n .split(\"/\")\n .filter(Boolean)\n .slice(-3)\n .join(\"-\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n\n // The hash guarantees uniqueness; the slug is just a label, so capping it\n // is safe and keeps total path length sane on deep trees / Windows.\n return slug.slice(0, 60) || \"src\";\n}\n"],"mappings":";;;;;;;;;;;;;;;AAcA,SAAgB,WAAW,cAA8B;CAcvD,OAbc,aAAa,QAAQ,OAAO,GAAG,EAAE,QAAQ,mBAAmB,EAEzD,EACd,MAAM,GAAG,EACT,OAAO,OAAO,EACd,MAAM,EAAE,EACR,KAAK,GAAG,EACR,YAAY,EACZ,QAAQ,eAAe,GAAG,EAC1B,QAAQ,YAAY,EAIb,EAAE,MAAM,GAAG,EAAE,KAAK;AAC9B"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
4
|
|
|
4
5
|
//#region ../../@warlock.js/core/src/dev-server/loader/transpile-cache.ts
|
|
5
6
|
/**
|
|
@@ -51,7 +52,168 @@ function computeFingerprint(parts) {
|
|
|
51
52
|
});
|
|
52
53
|
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
53
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Derive the cache key for a source file. The NUL separator makes
|
|
57
|
+
* `sourceText` and `fingerprint` unambiguous (no concatenation collision).
|
|
58
|
+
*/
|
|
59
|
+
function cacheKey(sourceText, fingerprint) {
|
|
60
|
+
return createHash("sha256").update(sourceText).update("\0").update(fingerprint).digest("hex");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Content-hash-addressed transpile cache stored on disk.
|
|
64
|
+
*
|
|
65
|
+
* Layout: `<cacheDir>/<first2-of-key>/<key>.js` plus a sibling `.js.map`.
|
|
66
|
+
* Sharding by the first two hex chars keeps any single directory small on
|
|
67
|
+
* large projects. GC metadata is derived from filesystem mtime so there is
|
|
68
|
+
* no second source of truth (no sidecar index to keep consistent).
|
|
69
|
+
*/
|
|
70
|
+
var TranspileCache = class {
|
|
71
|
+
constructor(cacheDir) {
|
|
72
|
+
this.cacheDir = cacheDir;
|
|
73
|
+
}
|
|
74
|
+
shardDir(key) {
|
|
75
|
+
return path.join(this.cacheDir, key.slice(0, 2));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* `label` is an optional human-readable prefix (e.g. a path slug). It is
|
|
79
|
+
* cosmetic only — the content `key` is still what makes the name unique,
|
|
80
|
+
* so the same `(key, label)` pair must be passed to `get` and `put`.
|
|
81
|
+
*/
|
|
82
|
+
fileName(key, label, ext) {
|
|
83
|
+
return label ? `${label}.${key}${ext}` : `${key}${ext}`;
|
|
84
|
+
}
|
|
85
|
+
codePath(key, label) {
|
|
86
|
+
return path.join(this.shardDir(key), this.fileName(key, label, ".js"));
|
|
87
|
+
}
|
|
88
|
+
mapPath(key, label) {
|
|
89
|
+
return path.join(this.shardDir(key), this.fileName(key, label, ".js.map"));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Return the cached entry for `key`, or `null` on a miss. Synchronous on
|
|
93
|
+
* purpose: this sits on the module-load hot path and must not pay an
|
|
94
|
+
* event-loop hop per import.
|
|
95
|
+
*/
|
|
96
|
+
get(key, label) {
|
|
97
|
+
try {
|
|
98
|
+
const code = readFileSync(this.codePath(key, label), "utf8");
|
|
99
|
+
let map = "";
|
|
100
|
+
try {
|
|
101
|
+
map = readFileSync(this.mapPath(key, label), "utf8");
|
|
102
|
+
} catch {}
|
|
103
|
+
return {
|
|
104
|
+
code,
|
|
105
|
+
map
|
|
106
|
+
};
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Store an entry for `key`. Written atomically (temp file + rename) so a
|
|
113
|
+
* concurrent {@link get} never observes a half-written module.
|
|
114
|
+
*/
|
|
115
|
+
put(key, entry, label) {
|
|
116
|
+
mkdirSync(this.shardDir(key), { recursive: true });
|
|
117
|
+
this.atomicWrite(this.codePath(key, label), entry.code);
|
|
118
|
+
if (entry.map) this.atomicWrite(this.mapPath(key, label), entry.map);
|
|
119
|
+
}
|
|
120
|
+
atomicWrite(filePath, content) {
|
|
121
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
122
|
+
try {
|
|
123
|
+
writeFileSync(tempPath, content);
|
|
124
|
+
renameSync(tempPath, filePath);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
try {
|
|
127
|
+
rmSync(tempPath, { force: true });
|
|
128
|
+
} catch {}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Opportunistic eviction. Cheap, runs at boot (not on the hot path).
|
|
134
|
+
* Removes age-expired entries first, then trims to the size budget by
|
|
135
|
+
* evicting least-recently-modified entries. Both bounds are optional;
|
|
136
|
+
* with neither set this is a no-op.
|
|
137
|
+
*/
|
|
138
|
+
gc(options = {}) {
|
|
139
|
+
const { maxBytes, maxAgeMs } = options;
|
|
140
|
+
if (maxBytes === void 0 && maxAgeMs === void 0) return;
|
|
141
|
+
const files = this.scan();
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
let live = files;
|
|
144
|
+
if (maxAgeMs !== void 0) {
|
|
145
|
+
live = [];
|
|
146
|
+
for (const file of files) if (now - file.mtimeMs > maxAgeMs) this.evict(file);
|
|
147
|
+
else live.push(file);
|
|
148
|
+
}
|
|
149
|
+
if (maxBytes !== void 0) {
|
|
150
|
+
let total = live.reduce((sum, file) => sum + file.size, 0);
|
|
151
|
+
if (total > maxBytes) {
|
|
152
|
+
live.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
153
|
+
for (const file of live) {
|
|
154
|
+
if (total <= maxBytes) break;
|
|
155
|
+
total -= file.size;
|
|
156
|
+
this.evict(file);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
scan() {
|
|
162
|
+
const result = [];
|
|
163
|
+
let shards;
|
|
164
|
+
try {
|
|
165
|
+
shards = readdirSync(this.cacheDir);
|
|
166
|
+
} catch {
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
for (const shard of shards) {
|
|
170
|
+
const shardPath = path.join(this.cacheDir, shard);
|
|
171
|
+
let entries;
|
|
172
|
+
try {
|
|
173
|
+
entries = readdirSync(shardPath);
|
|
174
|
+
} catch {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
for (const entry of entries) {
|
|
178
|
+
if (!entry.endsWith(".js")) continue;
|
|
179
|
+
const codePath = path.join(shardPath, entry);
|
|
180
|
+
try {
|
|
181
|
+
const stat = statSync(codePath);
|
|
182
|
+
const mapPath = `${codePath}.map`;
|
|
183
|
+
let mapSize = 0;
|
|
184
|
+
try {
|
|
185
|
+
mapSize = statSync(mapPath).size;
|
|
186
|
+
} catch {}
|
|
187
|
+
result.push({
|
|
188
|
+
codePath,
|
|
189
|
+
mapPath,
|
|
190
|
+
size: stat.size + mapSize,
|
|
191
|
+
mtimeMs: stat.mtimeMs
|
|
192
|
+
});
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
evict(file) {
|
|
199
|
+
try {
|
|
200
|
+
rmSync(file.codePath, { force: true });
|
|
201
|
+
} catch {}
|
|
202
|
+
try {
|
|
203
|
+
rmSync(file.mapPath, { force: true });
|
|
204
|
+
} catch {}
|
|
205
|
+
}
|
|
206
|
+
/** Remove the entire cache directory (used by `--fresh`). */
|
|
207
|
+
clear() {
|
|
208
|
+
try {
|
|
209
|
+
rmSync(this.cacheDir, {
|
|
210
|
+
recursive: true,
|
|
211
|
+
force: true
|
|
212
|
+
});
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
54
216
|
|
|
55
217
|
//#endregion
|
|
56
|
-
export { computeFingerprint };
|
|
218
|
+
export { TranspileCache, cacheKey, computeFingerprint };
|
|
57
219
|
//# sourceMappingURL=transpile-cache.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transpile-cache.mjs","names":[],"sources":["../../../../../../../@warlock.js/core/src/dev-server/loader/transpile-cache.ts"],"sourcesContent":["/**\n * Persisted transpile cache.\n *\n * Keyed by a hash of *source content* plus a transform-options fingerprint.\n * Same source + same options → same key → same output, always. Changed\n * content → different key → fresh transpile. There is no path-keyed entry\n * anywhere, so the old \"stack trace points at a mangled cache filename\" bug\n * class is structurally impossible — the on-disk filename is opaque and the\n * source identity lives only inside the source map.\n *\n * This module is pure and hook-agnostic on purpose: it knows how to read,\n * write and evict cache entries given a key, nothing about the ESM loader,\n * `?v=N` versioning, or esbuild. Phase 2 wires it into the load hook.\n *\n * @example\n * const fp = computeFingerprint({\n * esbuildVersion: esbuild.version,\n * cacheEpoch: CACHE_EPOCH,\n * compilerOptions,\n * });\n * const key = cacheKey(sourceText, fp);\n * const hit = cache.get(key);\n * if (!hit) cache.put(key, { code, map });\n */\n\nimport { createHash } from \"node:crypto\";\nimport {\n mkdirSync,\n readdirSync,\n readFileSync,\n renameSync,\n rmSync,\n statSync,\n writeFileSync,\n} from \"node:fs\";\nimport path from \"node:path\";\n\n/**\n * A cached transpile result: the emitted JS and its source map (raw JSON\n * string, exactly as esbuild produced it). Either may be empty for code\n * that produced no map.\n */\nexport type TranspileEntry = {\n code: string;\n map: string;\n};\n\n/**\n * Inputs that change transpiled output for identical source text. Bumping\n * any of these must invalidate every entry — that is achieved structurally\n * by folding them into the key, not by sweeping the cache.\n */\nexport type FingerprintParts = {\n /** `esbuild.version` — a transform-engine upgrade can change output. */\n esbuildVersion: string;\n /**\n * Monotonic cache-format epoch owned by the framework. Bump it whenever\n * the cache contract (entry shape, map handling, key derivation) changes\n * so old entries are guaranteed to miss after a framework upgrade.\n */\n cacheEpoch: number;\n /**\n * The resolved tsconfig `compilerOptions` blob, hashed wholesale rather\n * than cherry-picking fields — safer against tsconfig drift (a new option\n * that affects output can't silently serve stale code).\n */\n compilerOptions: unknown;\n};\n\n/**\n * Current cache-format epoch. Bump on any change to {@link TranspileEntry}\n * shape, the storage layout, or how the loader consumes entries.\n */\nexport const CACHE_EPOCH = 1;\n\n/**\n * Stable JSON stringify — object keys sorted recursively so semantically\n * equal option blobs always produce the same fingerprint regardless of key\n * insertion order.\n */\nfunction stableStringify(value: unknown): string {\n if (value === null || typeof value !== \"object\") {\n return JSON.stringify(value) ?? \"null\";\n }\n\n if (Array.isArray(value)) {\n return `[${value.map(stableStringify).join(\",\")}]`;\n }\n\n const entries = Object.keys(value as Record<string, unknown>)\n .sort()\n .map(key => {\n const child = stableStringify((value as Record<string, unknown>)[key]);\n return `${JSON.stringify(key)}:${child}`;\n });\n\n return `{${entries.join(\",\")}}`;\n}\n\n/**\n * Fold every output-affecting input into one short hex fingerprint that\n * becomes part of every cache key.\n */\nexport function computeFingerprint(parts: FingerprintParts): string {\n const canonical = stableStringify({\n esbuildVersion: parts.esbuildVersion,\n cacheEpoch: parts.cacheEpoch,\n compilerOptions: parts.compilerOptions,\n });\n\n return createHash(\"sha256\").update(canonical).digest(\"hex\").slice(0, 16);\n}\n\n/**\n * Derive the cache key for a source file. The NUL separator makes\n * `sourceText` and `fingerprint` unambiguous (no concatenation collision).\n */\nexport function cacheKey(sourceText: string, fingerprint: string): string {\n return createHash(\"sha256\")\n .update(sourceText)\n .update(\"\\0\")\n .update(fingerprint)\n .digest(\"hex\");\n}\n\n/** Options for {@link TranspileCache.gc}. */\nexport type GcOptions = {\n /** Evict least-recently-modified entries once total size exceeds this. */\n maxBytes?: number;\n /** Evict entries whose mtime is older than this many milliseconds. */\n maxAgeMs?: number;\n};\n\ntype CacheFileInfo = {\n codePath: string;\n mapPath: string;\n size: number;\n mtimeMs: number;\n};\n\n/**\n * Content-hash-addressed transpile cache stored on disk.\n *\n * Layout: `<cacheDir>/<first2-of-key>/<key>.js` plus a sibling `.js.map`.\n * Sharding by the first two hex chars keeps any single directory small on\n * large projects. GC metadata is derived from filesystem mtime so there is\n * no second source of truth (no sidecar index to keep consistent).\n */\nexport class TranspileCache {\n public constructor(private readonly cacheDir: string) {}\n\n private shardDir(key: string): string {\n return path.join(this.cacheDir, key.slice(0, 2));\n }\n\n /**\n * `label` is an optional human-readable prefix (e.g. a path slug). It is\n * cosmetic only — the content `key` is still what makes the name unique,\n * so the same `(key, label)` pair must be passed to `get` and `put`.\n */\n private fileName(key: string, label: string | undefined, ext: string): string {\n return label ? `${label}.${key}${ext}` : `${key}${ext}`;\n }\n\n private codePath(key: string, label?: string): string {\n return path.join(this.shardDir(key), this.fileName(key, label, \".js\"));\n }\n\n private mapPath(key: string, label?: string): string {\n return path.join(this.shardDir(key), this.fileName(key, label, \".js.map\"));\n }\n\n /**\n * Return the cached entry for `key`, or `null` on a miss. Synchronous on\n * purpose: this sits on the module-load hot path and must not pay an\n * event-loop hop per import.\n */\n public get(key: string, label?: string): TranspileEntry | null {\n try {\n const code = readFileSync(this.codePath(key, label), \"utf8\");\n let map = \"\";\n try {\n map = readFileSync(this.mapPath(key, label), \"utf8\");\n } catch {\n // A code entry with no map is valid (e.g. map-less transform).\n }\n return { code, map };\n } catch {\n return null;\n }\n }\n\n /**\n * Store an entry for `key`. Written atomically (temp file + rename) so a\n * concurrent {@link get} never observes a half-written module.\n */\n public put(key: string, entry: TranspileEntry, label?: string): void {\n const shard = this.shardDir(key);\n mkdirSync(shard, { recursive: true });\n\n this.atomicWrite(this.codePath(key, label), entry.code);\n if (entry.map) {\n this.atomicWrite(this.mapPath(key, label), entry.map);\n }\n }\n\n private atomicWrite(filePath: string, content: string): void {\n const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;\n try {\n writeFileSync(tempPath, content);\n renameSync(tempPath, filePath);\n } catch (error) {\n try {\n rmSync(tempPath, { force: true });\n } catch {\n // best effort — original error is the one that matters\n }\n throw error;\n }\n }\n\n /**\n * Opportunistic eviction. Cheap, runs at boot (not on the hot path).\n * Removes age-expired entries first, then trims to the size budget by\n * evicting least-recently-modified entries. Both bounds are optional;\n * with neither set this is a no-op.\n */\n public gc(options: GcOptions = {}): void {\n const { maxBytes, maxAgeMs } = options;\n if (maxBytes === undefined && maxAgeMs === undefined) return;\n\n const files = this.scan();\n const now = Date.now();\n\n let live = files;\n\n if (maxAgeMs !== undefined) {\n live = [];\n for (const file of files) {\n if (now - file.mtimeMs > maxAgeMs) {\n this.evict(file);\n } else {\n live.push(file);\n }\n }\n }\n\n if (maxBytes !== undefined) {\n let total = live.reduce((sum, file) => sum + file.size, 0);\n if (total > maxBytes) {\n // Oldest first — least-recently-modified is the eviction order.\n live.sort((a, b) => a.mtimeMs - b.mtimeMs);\n for (const file of live) {\n if (total <= maxBytes) break;\n total -= file.size;\n this.evict(file);\n }\n }\n }\n }\n\n private scan(): CacheFileInfo[] {\n const result: CacheFileInfo[] = [];\n\n let shards: string[];\n try {\n shards = readdirSync(this.cacheDir);\n } catch {\n return result;\n }\n\n for (const shard of shards) {\n const shardPath = path.join(this.cacheDir, shard);\n let entries: string[];\n try {\n entries = readdirSync(shardPath);\n } catch {\n continue;\n }\n\n for (const entry of entries) {\n if (!entry.endsWith(\".js\")) continue;\n const codePath = path.join(shardPath, entry);\n try {\n const stat = statSync(codePath);\n const mapPath = `${codePath}.map`;\n let mapSize = 0;\n try {\n mapSize = statSync(mapPath).size;\n } catch {\n // no map sidecar — fine\n }\n result.push({\n codePath,\n mapPath,\n size: stat.size + mapSize,\n mtimeMs: stat.mtimeMs,\n });\n } catch {\n // entry vanished mid-scan — skip\n }\n }\n }\n\n return result;\n }\n\n private evict(file: CacheFileInfo): void {\n try {\n rmSync(file.codePath, { force: true });\n } catch {\n // best effort\n }\n try {\n rmSync(file.mapPath, { force: true });\n } catch {\n // best effort\n }\n }\n\n /** Remove the entire cache directory (used by `--fresh`). */\n public clear(): void {\n try {\n rmSync(this.cacheDir, { recursive: true, force: true });\n } catch {\n // best effort\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgFA,SAAS,gBAAgB,OAAwB;CAC/C,IAAI,UAAU,QAAQ,OAAO,UAAU,UACrC,OAAO,KAAK,UAAU,KAAK,KAAK;CAGlC,IAAI,MAAM,QAAQ,KAAK,GACrB,OAAO,IAAI,MAAM,IAAI,eAAe,EAAE,KAAK,GAAG,EAAE;CAUlD,OAAO,IAPS,OAAO,KAAK,KAAgC,EACzD,KAAK,EACL,KAAI,QAAO;EACV,MAAM,QAAQ,gBAAiB,MAAkC,IAAI;EACrE,OAAO,GAAG,KAAK,UAAU,GAAG,EAAE,GAAG;CACnC,CAEe,EAAE,KAAK,GAAG,EAAE;AAC/B;;;;;AAMA,SAAgB,mBAAmB,OAAiC;CAClE,MAAM,YAAY,gBAAgB;EAChC,gBAAgB,MAAM;EACtB,YAAY,MAAM;EAClB,iBAAiB,MAAM;CACzB,CAAC;CAED,OAAO,WAAW,QAAQ,EAAE,OAAO,SAAS,EAAE,OAAO,KAAK,EAAE,MAAM,GAAG,EAAE;AACzE"}
|
|
1
|
+
{"version":3,"file":"transpile-cache.mjs","names":[],"sources":["../../../../../../../@warlock.js/core/src/dev-server/loader/transpile-cache.ts"],"sourcesContent":["/**\n * Persisted transpile cache.\n *\n * Keyed by a hash of *source content* plus a transform-options fingerprint.\n * Same source + same options → same key → same output, always. Changed\n * content → different key → fresh transpile. There is no path-keyed entry\n * anywhere, so the old \"stack trace points at a mangled cache filename\" bug\n * class is structurally impossible — the on-disk filename is opaque and the\n * source identity lives only inside the source map.\n *\n * This module is pure and hook-agnostic on purpose: it knows how to read,\n * write and evict cache entries given a key, nothing about the ESM loader,\n * `?v=N` versioning, or esbuild. Phase 2 wires it into the load hook.\n *\n * @example\n * const fp = computeFingerprint({\n * esbuildVersion: esbuild.version,\n * cacheEpoch: CACHE_EPOCH,\n * compilerOptions,\n * });\n * const key = cacheKey(sourceText, fp);\n * const hit = cache.get(key);\n * if (!hit) cache.put(key, { code, map });\n */\n\nimport { createHash } from \"node:crypto\";\nimport {\n mkdirSync,\n readdirSync,\n readFileSync,\n renameSync,\n rmSync,\n statSync,\n writeFileSync,\n} from \"node:fs\";\nimport path from \"node:path\";\n\n/**\n * A cached transpile result: the emitted JS and its source map (raw JSON\n * string, exactly as esbuild produced it). Either may be empty for code\n * that produced no map.\n */\nexport type TranspileEntry = {\n code: string;\n map: string;\n};\n\n/**\n * Inputs that change transpiled output for identical source text. Bumping\n * any of these must invalidate every entry — that is achieved structurally\n * by folding them into the key, not by sweeping the cache.\n */\nexport type FingerprintParts = {\n /** `esbuild.version` — a transform-engine upgrade can change output. */\n esbuildVersion: string;\n /**\n * Monotonic cache-format epoch owned by the framework. Bump it whenever\n * the cache contract (entry shape, map handling, key derivation) changes\n * so old entries are guaranteed to miss after a framework upgrade.\n */\n cacheEpoch: number;\n /**\n * The resolved tsconfig `compilerOptions` blob, hashed wholesale rather\n * than cherry-picking fields — safer against tsconfig drift (a new option\n * that affects output can't silently serve stale code).\n */\n compilerOptions: unknown;\n};\n\n/**\n * Current cache-format epoch. Bump on any change to {@link TranspileEntry}\n * shape, the storage layout, or how the loader consumes entries.\n */\nexport const CACHE_EPOCH = 1;\n\n/**\n * Stable JSON stringify — object keys sorted recursively so semantically\n * equal option blobs always produce the same fingerprint regardless of key\n * insertion order.\n */\nfunction stableStringify(value: unknown): string {\n if (value === null || typeof value !== \"object\") {\n return JSON.stringify(value) ?? \"null\";\n }\n\n if (Array.isArray(value)) {\n return `[${value.map(stableStringify).join(\",\")}]`;\n }\n\n const entries = Object.keys(value as Record<string, unknown>)\n .sort()\n .map(key => {\n const child = stableStringify((value as Record<string, unknown>)[key]);\n return `${JSON.stringify(key)}:${child}`;\n });\n\n return `{${entries.join(\",\")}}`;\n}\n\n/**\n * Fold every output-affecting input into one short hex fingerprint that\n * becomes part of every cache key.\n */\nexport function computeFingerprint(parts: FingerprintParts): string {\n const canonical = stableStringify({\n esbuildVersion: parts.esbuildVersion,\n cacheEpoch: parts.cacheEpoch,\n compilerOptions: parts.compilerOptions,\n });\n\n return createHash(\"sha256\").update(canonical).digest(\"hex\").slice(0, 16);\n}\n\n/**\n * Derive the cache key for a source file. The NUL separator makes\n * `sourceText` and `fingerprint` unambiguous (no concatenation collision).\n */\nexport function cacheKey(sourceText: string, fingerprint: string): string {\n return createHash(\"sha256\")\n .update(sourceText)\n .update(\"\\0\")\n .update(fingerprint)\n .digest(\"hex\");\n}\n\n/** Options for {@link TranspileCache.gc}. */\nexport type GcOptions = {\n /** Evict least-recently-modified entries once total size exceeds this. */\n maxBytes?: number;\n /** Evict entries whose mtime is older than this many milliseconds. */\n maxAgeMs?: number;\n};\n\ntype CacheFileInfo = {\n codePath: string;\n mapPath: string;\n size: number;\n mtimeMs: number;\n};\n\n/**\n * Content-hash-addressed transpile cache stored on disk.\n *\n * Layout: `<cacheDir>/<first2-of-key>/<key>.js` plus a sibling `.js.map`.\n * Sharding by the first two hex chars keeps any single directory small on\n * large projects. GC metadata is derived from filesystem mtime so there is\n * no second source of truth (no sidecar index to keep consistent).\n */\nexport class TranspileCache {\n public constructor(private readonly cacheDir: string) {}\n\n private shardDir(key: string): string {\n return path.join(this.cacheDir, key.slice(0, 2));\n }\n\n /**\n * `label` is an optional human-readable prefix (e.g. a path slug). It is\n * cosmetic only — the content `key` is still what makes the name unique,\n * so the same `(key, label)` pair must be passed to `get` and `put`.\n */\n private fileName(key: string, label: string | undefined, ext: string): string {\n return label ? `${label}.${key}${ext}` : `${key}${ext}`;\n }\n\n private codePath(key: string, label?: string): string {\n return path.join(this.shardDir(key), this.fileName(key, label, \".js\"));\n }\n\n private mapPath(key: string, label?: string): string {\n return path.join(this.shardDir(key), this.fileName(key, label, \".js.map\"));\n }\n\n /**\n * Return the cached entry for `key`, or `null` on a miss. Synchronous on\n * purpose: this sits on the module-load hot path and must not pay an\n * event-loop hop per import.\n */\n public get(key: string, label?: string): TranspileEntry | null {\n try {\n const code = readFileSync(this.codePath(key, label), \"utf8\");\n let map = \"\";\n try {\n map = readFileSync(this.mapPath(key, label), \"utf8\");\n } catch {\n // A code entry with no map is valid (e.g. map-less transform).\n }\n return { code, map };\n } catch {\n return null;\n }\n }\n\n /**\n * Store an entry for `key`. Written atomically (temp file + rename) so a\n * concurrent {@link get} never observes a half-written module.\n */\n public put(key: string, entry: TranspileEntry, label?: string): void {\n const shard = this.shardDir(key);\n mkdirSync(shard, { recursive: true });\n\n this.atomicWrite(this.codePath(key, label), entry.code);\n if (entry.map) {\n this.atomicWrite(this.mapPath(key, label), entry.map);\n }\n }\n\n private atomicWrite(filePath: string, content: string): void {\n const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;\n try {\n writeFileSync(tempPath, content);\n renameSync(tempPath, filePath);\n } catch (error) {\n try {\n rmSync(tempPath, { force: true });\n } catch {\n // best effort — original error is the one that matters\n }\n throw error;\n }\n }\n\n /**\n * Opportunistic eviction. Cheap, runs at boot (not on the hot path).\n * Removes age-expired entries first, then trims to the size budget by\n * evicting least-recently-modified entries. Both bounds are optional;\n * with neither set this is a no-op.\n */\n public gc(options: GcOptions = {}): void {\n const { maxBytes, maxAgeMs } = options;\n if (maxBytes === undefined && maxAgeMs === undefined) return;\n\n const files = this.scan();\n const now = Date.now();\n\n let live = files;\n\n if (maxAgeMs !== undefined) {\n live = [];\n for (const file of files) {\n if (now - file.mtimeMs > maxAgeMs) {\n this.evict(file);\n } else {\n live.push(file);\n }\n }\n }\n\n if (maxBytes !== undefined) {\n let total = live.reduce((sum, file) => sum + file.size, 0);\n if (total > maxBytes) {\n // Oldest first — least-recently-modified is the eviction order.\n live.sort((a, b) => a.mtimeMs - b.mtimeMs);\n for (const file of live) {\n if (total <= maxBytes) break;\n total -= file.size;\n this.evict(file);\n }\n }\n }\n }\n\n private scan(): CacheFileInfo[] {\n const result: CacheFileInfo[] = [];\n\n let shards: string[];\n try {\n shards = readdirSync(this.cacheDir);\n } catch {\n return result;\n }\n\n for (const shard of shards) {\n const shardPath = path.join(this.cacheDir, shard);\n let entries: string[];\n try {\n entries = readdirSync(shardPath);\n } catch {\n continue;\n }\n\n for (const entry of entries) {\n if (!entry.endsWith(\".js\")) continue;\n const codePath = path.join(shardPath, entry);\n try {\n const stat = statSync(codePath);\n const mapPath = `${codePath}.map`;\n let mapSize = 0;\n try {\n mapSize = statSync(mapPath).size;\n } catch {\n // no map sidecar — fine\n }\n result.push({\n codePath,\n mapPath,\n size: stat.size + mapSize,\n mtimeMs: stat.mtimeMs,\n });\n } catch {\n // entry vanished mid-scan — skip\n }\n }\n }\n\n return result;\n }\n\n private evict(file: CacheFileInfo): void {\n try {\n rmSync(file.codePath, { force: true });\n } catch {\n // best effort\n }\n try {\n rmSync(file.mapPath, { force: true });\n } catch {\n // best effort\n }\n }\n\n /** Remove the entire cache directory (used by `--fresh`). */\n public clear(): void {\n try {\n rmSync(this.cacheDir, { recursive: true, force: true });\n } catch {\n // best effort\n }\n }\n}\n"],"mappings":"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"}
|