@decohere/flux 0.0.1-dev.112 → 0.0.1-dev.113

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/README.md CHANGED
@@ -109,19 +109,19 @@ export default {
109
109
  };
110
110
  ```
111
111
 
112
- These are three ways to start the same serve (flags combine; `flux serve --project my-api --reboot-debounce-ms 2000` is valid):
112
+ These are three ways to start the same serve (flags combine; `flux serve --project my-api --reboot-debounce-ms 500` is valid):
113
113
 
114
114
  ```bash
115
115
  flux serve
116
116
  flux serve --project my-api
117
- flux serve --reboot-debounce-ms 2000
117
+ flux serve --reboot-debounce-ms 500
118
118
  ```
119
119
 
120
120
  | | What it does |
121
121
  |---|---|
122
122
  | `flux serve` | Boots the first discovered project (Mesh lists packages by `package.json` `name`, A→Z). |
123
123
  | `--project my-api` | Boots that package by name. |
124
- | `--reboot-debounce-ms 2000` | Same serve, but waits 2s after the last save before rebooting (default 250ms). |
124
+ | `--reboot-debounce-ms 500` | Same serve, but waits 500ms after the last save before rebooting (default 250ms). |
125
125
 
126
126
  Use `--project` or [directory slices](#directory-slice-flux-serve-token) when the first package is not the app you want.
127
127
 
@@ -132,10 +132,25 @@ Ctrl+C (SIGINT) or SIGTERM (Docker, CI, process managers) disposes the entry, th
132
132
  | `[slices…]` | — | One or more module tokens among discovered packages (union; see **Directory slice**) |
133
133
  | `--configuration <name>` | `debug` | Build configuration name (not a file path) |
134
134
  | `--project <name>` | first discovered package | Single package name to serve (not with slices) |
135
- | `--reboot-debounce-ms <ms>` | `250` | Quiet window after the last affecting save before a reboot. Use ~2000 when an agent writes many files in sequence. |
135
+ | `--reboot-debounce-ms <ms>` | `250` | Quiet window after the last affecting save before a reboot. See [Reboot debounce](#reboot-debounce) before raising it. |
136
136
 
137
137
  On a watched change that reaches a served entry, Flux invalidates that subgraph, calls `asyncDispose` on the previous default export, and re-imports. Rapid saves are trailing-debounced and serialized (overlapping dispose cannot run). A change that never reaches an entry does nothing. Each reboot logs `flux reboot` / `flux ready` at info (project names, triggering files, elapsed ms) so the event stays visible when the app boot is chatty. File-watch detail stays at trace.
138
138
 
139
+ ### Reboot debounce
140
+
141
+ Saves less than the window apart (250ms by default) become one reboot. Saves further apart each reboot, and a save during a reboot folds into one follow-up reboot. Whatever the timing, the app ends up on the last save; a reboot in between may fail (an import not yet updated) and logs the error.
142
+
143
+ The window adds its length to every reload, including a single save. Measured on a rename across six files (#484):
144
+
145
+ | Gap between saves | Reboots at 250ms | Reboots at 2000ms |
146
+ |---|---|---|
147
+ | 0–100ms: parallel edits by an agent, a formatter, a checkout | 1 | 1 |
148
+ | 400ms–1.5s | 6 | 1 |
149
+ | 4s: an agent's edits made one tool call at a time | 6 | 6 |
150
+ | Time from the last save to the app ready | ~0.3s | ~2s |
151
+
152
+ So keep the default. An agent's saves in one go land inside it, and its one-at-a-time saves are seconds apart, beyond any window short enough for a person's save. Raise it only for a tool you know saves at a steady pace inside your chosen window.
153
+
139
154
  ### Dispose on reboot
140
155
 
141
156
  `serve` loads the entry’s **default export**. If that object implements `Symbol.asyncDispose`, Flux calls it:
@@ -200,6 +215,17 @@ Foreign scripts win over `main.ts` so Vite (and similar) apps are never loaded i
200
215
 
201
216
  Logs name skipped libraries and deferred packages (script + cwd). Child processes are stopped when the CLI exits (SIGINT or SIGTERM → dispose).
202
217
 
218
+ ## Compile cache
219
+
220
+ Flux keeps the code it compiles on disk, so starting again compiles only what changed (#514). On a cold start with nothing changed, it reads every file from the cache instead of compiling it (`pnpm bench flux-load`, `flux.load.cold-cached`).
221
+
222
+ - **Where:** `<workspace>/node_modules/.cache/decohere/flux`. When the workspace has no `node_modules`, it's under `$XDG_CACHE_HOME/decohere`, then the OS temp directory.
223
+ - **Settings:** `DECOHERE_CACHE_DIR=<dir>` moves the cache; `DECOHERE_CACHE=0` turns it off.
224
+ - **When it's used:** entries are keyed by the file's path and source, the SWC options it compiles with and SWC's version. Any change to those is a miss, never stale code.
225
+ - **Concurrency:** entries are written to a temp file and renamed into place, so processes starting at once share one cache safely.
226
+ - **Housekeeping:** entries untouched for 30 days are pruned, at most once a day. To clear the cache, delete the folder. `forge clean` leaves it alone, because cleaning runs before every build.
227
+ - **If it can't be used:** when flux can't read or write the folder, it warns once and runs without the cache.
228
+
203
229
  ## Limits
204
230
 
205
231
  Flux does not support `createRequire`, worker threads, or browser HMR. Reloads always dispose and re-import the entry — there is no `import.meta.hot.accept()` boundary yet.
@@ -214,7 +240,7 @@ Only source files (`.ts`) that a served entry imports trigger a reboot.
214
240
 
215
241
  **Memory grows over a long session.** Each reboot imports changed modules under a new `?version=` URL, and Node's ES module cache never releases the old versions. That's fine for a working day; if a session runs for days with thousands of reboots, restart it.
216
242
 
217
- Flux uses **Forge’s compiler**, with one SWC config per project built once per run. SWC leaves imports as written; Flux's resolve hook maps each one to its `.ts` source: `@scope/pkg` and `@scope/pkg/sub` through the Mesh aliases (and tsconfig `paths`), `./file.js` and `./file` to `file.ts`, and `./dir` to `dir/index.ts`. Doing it in one place, instead of in SWC (`jsc.paths`, `resolveFully`) and again in the hook, made loading about 20% faster once Node and SWC are warm (`pnpm bench flux-load`, #513). Before Node imports an entry, Flux compiles it and the files it imports, following their imports out from the entry, in parallel on SWC's thread pool (#661). The load hook is synchronous and would otherwise compile one file at a time; files the app never imports, such as tests, are not compiled. A file that fails to compile there is compiled again when Node loads it, and that load fails with the file's name, as before. That is a debug rule so you can edit a library and hit HMR without a pre-build or `node_modules` workspace symlink. Release still resolves through real `node_modules` (published `exports` / `main`).
243
+ Flux uses **Forge’s compiler**, with one SWC config per project built once per run. SWC leaves imports as written; Flux's resolve hook maps each one to its TypeScript source: `@scope/pkg` and `@scope/pkg/sub` through the Mesh aliases (and tsconfig `paths`), `./file.js` and `./file` to `file.ts` or `file.tsx`, `./file.mjs` to `file.mts`, `./file.cjs` to `file.cts` (which runs as CommonJS), and `./dir` to `dir/index.ts`. Doing it in one place, instead of in SWC (`jsc.paths`, `resolveFully`) and again in the hook, made loading about 20% faster once Node and SWC are warm (`pnpm bench flux-load`, #513). Before Node imports an entry, Flux compiles it and the files it imports, following their imports out from the entry, in parallel on SWC's thread pool (#661). The load hook is synchronous and would otherwise compile one file at a time; files the app never imports, such as tests, are not compiled. A file that fails to compile there is compiled again when Node loads it, and that load fails with the file's name, as before. That is a debug rule so you can edit a library and hit HMR without a pre-build or `node_modules` workspace symlink. Release still resolves through real `node_modules` (published `exports` / `main`).
218
244
 
219
245
  Why aliases, and a tree of where a symlink misses `src/index.ts`: [Forge — debug path aliases](../forge/README.md#debug-path-aliases-not-node_modules-links).
220
246
 
@@ -234,7 +260,7 @@ const server = await flux.serve({
234
260
  configuration: "debug",
235
261
  project: "my-api", // optional single package
236
262
  // projects: ["api", "svc"], // optional multi-entry
237
- rebootDebounceMs: 2000, // optional; default 250
263
+ rebootDebounceMs: 500, // optional; default 250
238
264
  });
239
265
  server.on("reboot", () => {}); // after dispose + boot attempt for affected entries
240
266
  await server.dispose();
@@ -30,16 +30,18 @@ function rewriteServeSpecifier(options) {
30
30
  return null;
31
31
  }
32
32
  /**
33
- * The watched `.ts` file a path names: itself (with `.js` read as `.ts`, or `.ts` added), else
34
- * the `index.ts` of the directory it names. A file wins over a directory of the same name.
33
+ * The watched source file a path names: itself, or the source its extension stands for (`.js` is
34
+ * `.ts` or `.tsx`, `.mjs` is `.mts`, `.cjs` is `.cts`, none adds `.ts` or `.tsx`), else the
35
+ * `index` source of the directory it names. A file wins over a directory of the same name.
35
36
  */ function watchedTs(filePath, isWatchedTs) {
36
37
  const withoutSlash = filePath.replace(/[/\\]+$/, "");
37
- const stem = withoutSlash.replace(/\.(js|ts)$/, "");
38
- // From the stem: Mesh's alias lookup has already added `.ts` to a subpath that names a
39
- // directory (`@lib/models` arrives as `…/models.ts`).
38
+ // Mesh's alias lookup may have added `.ts` to a subpath that names a directory (`@lib/models`
39
+ // arrives as `…/models.ts`), so the directory is the path without any extension.
40
+ const directory = withoutSlash.replace(/\.([mc]?[jt]sx?)$/, "");
40
41
  const candidates = [
41
- `${stem}.ts`,
42
- _nodepath.default.join(stem, "index.ts")
42
+ ..._mesh.sourceExtensions.candidates(withoutSlash),
43
+ ..._mesh.sourceExtensions.candidates(directory),
44
+ ..._mesh.sourceExtensions.candidates(_nodepath.default.join(directory, "index"))
43
45
  ];
44
46
  return candidates.find((candidate)=>isWatchedTs(candidate)) ?? null;
45
47
  }
@@ -8,6 +8,7 @@ Object.defineProperty(exports, "SourceModuleLoader", {
8
8
  return SourceModuleLoader;
9
9
  }
10
10
  });
11
+ const _nodefs = require("node:fs");
11
12
  const _nodemodule = require("node:module");
12
13
  const _nodepath = /*#__PURE__*/ _interop_require_default(require("node:path"));
13
14
  const _nodeurl = require("node:url");
@@ -18,6 +19,7 @@ const _io = require("@decohere/nexus/io");
18
19
  const _scribe = require("@decohere/scribe");
19
20
  const _rewriteservespecifier = require("./rewrite-serve-specifier.js");
20
21
  const _staticimports = require("./static-imports.js");
22
+ const _transpilediskcache = require("./transpile-disk-cache.js");
21
23
  function _interop_require_default(obj) {
22
24
  return obj && obj.__esModule ? obj : {
23
25
  default: obj
@@ -81,8 +83,10 @@ class SourceModuleLoader {
81
83
  transpileCache = new Map();
82
84
  /** Bumped when a file's cached code is dropped, so an up-front compile already running can't put it back. */ transpileVersions = new Map();
83
85
  /** The source files each entry's walk reached, so a reboot only walks out from what changed. */ reachedFrom = new Map();
86
+ /** Compiled code on disk across runs (#514); null when caching is off. */ diskCache = null;
84
87
  /** Up-front compiles in flight, shared by entries booting at the same time. */ compilingAhead = new Map();
85
88
  /** SWC options per project, built on first use and kept until the next `configure()`. */ swcConfigs = new Map();
89
+ commonJsConfigs = new Map();
86
90
  aliasPaths = {};
87
91
  aliasBaseUrl = _nodepath.default.resolve(".");
88
92
  versionFileUrl = (fileUrl)=>fileUrl;
@@ -90,9 +94,6 @@ class SourceModuleLoader {
90
94
  getWorkspace() {
91
95
  return this.workspace;
92
96
  }
93
- getBuildConfig() {
94
- return this.buildConfig;
95
- }
96
97
  hasSourceFile(absolutePath) {
97
98
  return this.fileInfoMap.has(absolutePath);
98
99
  }
@@ -110,11 +111,15 @@ class SourceModuleLoader {
110
111
  this.transpileCache = new Map();
111
112
  this.reachedFrom = new Map();
112
113
  this.swcConfigs.clear();
114
+ this.commonJsConfigs.clear();
113
115
  this.buildConfig = options.buildConfig;
114
116
  this.workspace = options.workspace ?? await (0, _forge.createWorkspace)();
115
117
  this.versionFileUrl = options.versionFileUrl ?? ((fileUrl)=>fileUrl);
116
118
  this.onResolved = options.onResolved;
117
119
  this.fileInfoMap = await this.buildFileInfoMap(this.buildConfig);
120
+ this.diskCache = _transpilediskcache.TranspileDiskCache.forWorkspace(this.workspace.rootDir);
121
+ // In the background; it turns the cache off, with a warning, rather than throw.
122
+ void this.diskCache?.pruneIfDue();
118
123
  const aliases = this.workspace.getPackageAliases(this.buildConfig);
119
124
  this.aliasBaseUrl = aliases.baseUrl;
120
125
  this.aliasPaths = aliases.paths;
@@ -145,7 +150,7 @@ class SourceModuleLoader {
145
150
  const result = this.loadTs(url);
146
151
  if (result != null) {
147
152
  return {
148
- format: "module",
153
+ format: result.format,
149
154
  source: result.source,
150
155
  shortCircuit: true
151
156
  };
@@ -163,9 +168,6 @@ class SourceModuleLoader {
163
168
  for (const fullPath of absolutePaths){
164
169
  this.transpileCache.delete(fullPath);
165
170
  this.transpileVersions.set(fullPath, (this.transpileVersions.get(fullPath) ?? 0) + 1);
166
- this.transpiler.invalidateContentCache(new _io.FileEx({
167
- fullPath
168
- }));
169
171
  }
170
172
  }
171
173
  dispose() {
@@ -173,6 +175,7 @@ class SourceModuleLoader {
173
175
  this.transpileCache = new Map();
174
176
  this.reachedFrom = new Map();
175
177
  this.swcConfigs.clear();
178
+ this.commonJsConfigs.clear();
176
179
  this.fileInfoMap.clear();
177
180
  this.workspace?.[Symbol.dispose]();
178
181
  this.workspace = undefined;
@@ -233,14 +236,12 @@ class SourceModuleLoader {
233
236
  if (inFlight != null) return inFlight;
234
237
  const version = this.transpileVersions.get(fullPath);
235
238
  const cache = this.transpileCache;
236
- const compiling = this.transpiler.transformFileAsync(new _io.FileEx({
237
- fullPath
238
- }), _forge.BuildTargets.es6, {}, this.swcConfigFor(fileInfo.project)).then((response)=>{
239
- if (response.outcome !== "success") return undefined;
239
+ const compiling = this.compileAsync(fullPath, fileInfo.project).then((code)=>{
240
+ if (code == null) return undefined;
240
241
  // Saved, reconfigured or disposed while compiling: don't cache stale code.
241
242
  const current = !this.disposed && cache === this.transpileCache && version === this.transpileVersions.get(fullPath);
242
- if (current) cache.set(fullPath, response.code);
243
- return response.code;
243
+ if (current) cache.set(fullPath, code);
244
+ return code;
244
245
  }).finally(()=>this.compilingAhead.delete(fullPath));
245
246
  this.compilingAhead.set(fullPath, compiling);
246
247
  return compiling;
@@ -262,29 +263,95 @@ class SourceModuleLoader {
262
263
  const cleanPath = (0, _nodeurl.fileURLToPath)(url);
263
264
  const fileInfo = this.fileInfoMap.get(cleanPath);
264
265
  if (fileInfo == null) return null;
266
+ const format = _mesh.sourceExtensions.isCommonJs(cleanPath) ? "commonjs" : "module";
265
267
  const cached = this.transpileCache.get(cleanPath);
266
268
  if (cached != null) return {
267
- source: cached
269
+ source: cached,
270
+ format
268
271
  };
272
+ const code = this.compileSync(cleanPath, fileInfo.project);
273
+ this.transpileCache.set(cleanPath, code);
274
+ return {
275
+ source: code,
276
+ format
277
+ };
278
+ }
279
+ /**
280
+ * Compiles a file for the load hook, through the disk cache (#514). Throws, naming the file,
281
+ * when it can't be read or doesn't compile.
282
+ */ compileSync(fullPath, project) {
269
283
  const file = new _io.FileEx({
270
- fullPath: cleanPath
284
+ fullPath
271
285
  });
272
- const response = this.transpiler.transformFileSync(file, _forge.BuildTargets.es6, {}, this.swcConfigFor(fileInfo.project));
286
+ const baseConfig = this.swcConfigFor(project, fullPath);
287
+ let source;
288
+ try {
289
+ source = (0, _nodefs.readFileSync)(fullPath, "utf8");
290
+ } catch (error) {
291
+ this.logger.error(`Failed to transpile ${fullPath}`, error);
292
+ throw new Error(`Failed to transpile ${fullPath}`, {
293
+ cause: error
294
+ });
295
+ }
296
+ const key = this.diskCache?.keyFor(fullPath, source, baseConfig);
297
+ const hit = key == null ? undefined : this.diskCache?.readSync(key);
298
+ if (hit != null) return hit;
299
+ const response = this.transpiler.transformCodeSync(source, file, _forge.BuildTargets.es6, baseConfig);
273
300
  if (response.outcome !== "success") {
274
- this.logger.error(`Failed to transpile ${cleanPath}`, response);
275
- throw new Error(`Failed to transpile ${cleanPath}`);
301
+ this.logger.error(`Failed to transpile ${fullPath}`, response);
302
+ throw new Error(`Failed to transpile ${fullPath}`);
276
303
  }
277
- this.transpileCache.set(cleanPath, response.code);
278
- return {
279
- source: response.code
280
- };
304
+ if (key != null) this.diskCache?.writeSync(key, response.code);
305
+ return response.code;
306
+ }
307
+ /**
308
+ * Compiles a file ahead, through the disk cache; undefined when it doesn't compile, which the
309
+ * load hook then reports (see {@link compileAhead}). The source and the cache entry are read
310
+ * synchronously: for small files that is far cheaper than a trip through libuv's thread pool,
311
+ * which async SWC needs for the compiles that miss (#514).
312
+ */ async compileAsync(fullPath, project) {
313
+ const file = new _io.FileEx({
314
+ fullPath
315
+ });
316
+ const baseConfig = this.swcConfigFor(project, fullPath);
317
+ let source;
318
+ try {
319
+ source = (0, _nodefs.readFileSync)(fullPath, "utf8");
320
+ } catch {
321
+ // Left to the load hook, which reads it again and fails naming it, as for a file
322
+ // that doesn't compile.
323
+ return undefined;
324
+ }
325
+ const key = this.diskCache?.keyFor(fullPath, source, baseConfig);
326
+ const hit = key == null ? undefined : this.diskCache?.readSync(key);
327
+ if (hit != null) return hit;
328
+ const response = await this.transpiler.transformCode(source, file, _forge.BuildTargets.es6, baseConfig);
329
+ if (response.outcome !== "success") return undefined;
330
+ if (key != null) await this.diskCache?.write(key, response.code);
331
+ return response.code;
281
332
  }
282
333
  /**
283
334
  * The project's SWC options, built once: rebuilding them for every file was about a quarter
284
335
  * of load time (#513). Import specifiers are left as written, without `jsc.paths` or
285
336
  * `resolveFully`: the resolve hook maps them to source files (aliases, `.js` to `.ts`,
286
337
  * directory indexes), so SWC does not resolve them a second time against the disk.
287
- */ swcConfigFor(project) {
338
+ */ swcConfigFor(project, fullPath) {
339
+ const config = this.esModuleConfigFor(project);
340
+ if (!_mesh.sourceExtensions.isCommonJs(fullPath)) return config;
341
+ // `.cts` is CommonJS: Node loads it with `require` semantics, so SWC emits CommonJS (#518).
342
+ // Kept per project, like the ES module options, so the disk cache hashes them once.
343
+ const cached = this.commonJsConfigs.get(project);
344
+ if (cached != null) return cached;
345
+ const commonJs = {
346
+ ...config,
347
+ module: {
348
+ type: "commonjs"
349
+ }
350
+ };
351
+ this.commonJsConfigs.set(project, commonJs);
352
+ return commonJs;
353
+ }
354
+ esModuleConfigFor(project) {
288
355
  const cached = this.swcConfigs.get(project);
289
356
  if (cached != null) return cached;
290
357
  const base = this.transpiler.createBaseConfig(_forge.BuildTargets.es6, {
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ Object.defineProperty(exports, "TranspileDiskCache", {
6
+ enumerable: true,
7
+ get: function() {
8
+ return TranspileDiskCache;
9
+ }
10
+ });
11
+ const _nodecrypto = require("node:crypto");
12
+ const _nodefs = /*#__PURE__*/ _interop_require_default(require("node:fs"));
13
+ const _nodepath = /*#__PURE__*/ _interop_require_default(require("node:path"));
14
+ const _forge = require("@decohere/forge");
15
+ const _scribe = require("@decohere/scribe");
16
+ function _interop_require_default(obj) {
17
+ return obj && obj.__esModule ? obj : {
18
+ default: obj
19
+ };
20
+ }
21
+ /** Bump when what flux stores changes, so older entries stop matching. */ const FORMAT = 1;
22
+ /** Entries untouched for this long are pruned. */ const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
23
+ /** Pruning runs at most this often, recorded by the mtime of a marker file. */ const PRUNE_EVERY_MS = 24 * 60 * 60 * 1000;
24
+ const PRUNE_MARKER = ".last-prune";
25
+ class TranspileDiskCache {
26
+ dir;
27
+ logger = _scribe.scribe.logger(TranspileDiskCache.name);
28
+ disabled = false;
29
+ constructor(dir){
30
+ this.dir = dir;
31
+ }
32
+ /** The cache for a workspace, or null when caching is off (`DECOHERE_CACHE=0`). */ static forWorkspace(rootDir) {
33
+ const base = _forge.transpileCache.directory(rootDir);
34
+ return base == null ? null : new TranspileDiskCache(_nodepath.default.join(base, "flux"));
35
+ }
36
+ get directory() {
37
+ return this.dir;
38
+ }
39
+ /**
40
+ * The key for a file compiled with `baseOptions`: the per-file options are those plus the
41
+ * file's path (forge's `createFileConfig`), so hashing the base once per project and adding
42
+ * the path covers them without serialising options for every file.
43
+ */ keyFor(fullPath, source, baseOptions) {
44
+ return (0, _nodecrypto.createHash)("sha256").update(`${FORMAT}\0${_forge.transpileCache.swcVersion}\0${this.optionsHash(baseOptions)}\0`).update(`${fullPath}\0`).update(source).digest("hex");
45
+ }
46
+ optionsHashes = new WeakMap();
47
+ optionsHash(options) {
48
+ const cached = this.optionsHashes.get(options);
49
+ if (cached != null) return cached;
50
+ const hash = (0, _nodecrypto.createHash)("sha256").update(JSON.stringify(options)).digest("hex");
51
+ this.optionsHashes.set(options, hash);
52
+ return hash;
53
+ }
54
+ readSync(key) {
55
+ if (this.disabled) return undefined;
56
+ try {
57
+ return _nodefs.default.readFileSync(this.entryPath(key), "utf8");
58
+ } catch (error) {
59
+ return this.missOrDisable(error);
60
+ }
61
+ }
62
+ async read(key) {
63
+ if (this.disabled) return undefined;
64
+ try {
65
+ return await _nodefs.default.promises.readFile(this.entryPath(key), "utf8");
66
+ } catch (error) {
67
+ return this.missOrDisable(error);
68
+ }
69
+ }
70
+ writeSync(key, code) {
71
+ if (this.disabled) return;
72
+ const file = this.entryPath(key);
73
+ const temp = this.tempPath(file);
74
+ try {
75
+ _nodefs.default.mkdirSync(_nodepath.default.dirname(file), {
76
+ recursive: true
77
+ });
78
+ _nodefs.default.writeFileSync(temp, code);
79
+ _nodefs.default.renameSync(temp, file);
80
+ } catch (error) {
81
+ this.disable("write", error);
82
+ }
83
+ }
84
+ async write(key, code) {
85
+ if (this.disabled) return;
86
+ const file = this.entryPath(key);
87
+ const temp = this.tempPath(file);
88
+ try {
89
+ await _nodefs.default.promises.mkdir(_nodepath.default.dirname(file), {
90
+ recursive: true
91
+ });
92
+ await _nodefs.default.promises.writeFile(temp, code);
93
+ await _nodefs.default.promises.rename(temp, file);
94
+ } catch (error) {
95
+ this.disable("write", error);
96
+ }
97
+ }
98
+ /** Deletes entries older than 30 days, if it hasn't done so in the last day. */ async pruneIfDue(now = Date.now()) {
99
+ if (this.disabled) return;
100
+ const marker = _nodepath.default.join(this.dir, PRUNE_MARKER);
101
+ try {
102
+ const last = await _nodefs.default.promises.stat(marker).then((stat)=>stat.mtimeMs, (error)=>{
103
+ if (error.code === "ENOENT") return 0;
104
+ throw error;
105
+ });
106
+ if (now - last < PRUNE_EVERY_MS) return;
107
+ await _nodefs.default.promises.mkdir(this.dir, {
108
+ recursive: true
109
+ });
110
+ await _nodefs.default.promises.writeFile(marker, "");
111
+ for (const shard of (await _nodefs.default.promises.readdir(this.dir, {
112
+ withFileTypes: true
113
+ }))){
114
+ if (!shard.isDirectory()) continue;
115
+ const shardDir = _nodepath.default.join(this.dir, shard.name);
116
+ for (const entry of (await _nodefs.default.promises.readdir(shardDir))){
117
+ // Old entries, and temp files a crashed process left mid-write.
118
+ const file = _nodepath.default.join(shardDir, entry);
119
+ const mtime = await _nodefs.default.promises.stat(file).then((stat)=>stat.mtimeMs, (error)=>{
120
+ // Renamed or removed by another process since the listing: nothing to prune.
121
+ if (error.code === "ENOENT") return undefined;
122
+ throw error;
123
+ });
124
+ if (mtime != null && now - mtime > MAX_AGE_MS) await _nodefs.default.promises.rm(file, {
125
+ force: true
126
+ });
127
+ }
128
+ }
129
+ } catch (error) {
130
+ this.disable("prune", error);
131
+ }
132
+ }
133
+ entryPath(key) {
134
+ return _nodepath.default.join(this.dir, key.slice(0, 2), `${key}.js`);
135
+ }
136
+ tempPath(file) {
137
+ return `${file}.${process.pid}.${(0, _nodecrypto.randomBytes)(4).toString("hex")}.tmp`;
138
+ }
139
+ /** A missing entry is a miss; any other read error turns the cache off. */ missOrDisable(error) {
140
+ if (error.code !== "ENOENT") this.disable("read", error);
141
+ return undefined;
142
+ }
143
+ disable(operation, error) {
144
+ if (this.disabled) return;
145
+ this.disabled = true;
146
+ this.logger.warn(`Transpile cache turned off for this run: could not ${operation} ${this.dir} (${String(error)}). ` + "Flux compiles without it. Set DECOHERE_CACHE_DIR to a writable directory, or DECOHERE_CACHE=0 to silence this.");
147
+ }
148
+ }
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { fileURLToPath, pathToFileURL } from "node:url";
3
- import { packageAliases } from "@decohere/mesh";
3
+ import { packageAliases, sourceExtensions } from "@decohere/mesh";
4
4
  /**
5
5
  * Map a resolve-hook specifier to a versioned watched `.ts` file URL.
6
6
  * Covers relative `.js` / `.ts` / extensionless imports, directory imports (`./models` is
@@ -20,16 +20,18 @@ import { packageAliases } from "@decohere/mesh";
20
20
  return null;
21
21
  }
22
22
  /**
23
- * The watched `.ts` file a path names: itself (with `.js` read as `.ts`, or `.ts` added), else
24
- * the `index.ts` of the directory it names. A file wins over a directory of the same name.
23
+ * The watched source file a path names: itself, or the source its extension stands for (`.js` is
24
+ * `.ts` or `.tsx`, `.mjs` is `.mts`, `.cjs` is `.cts`, none adds `.ts` or `.tsx`), else the
25
+ * `index` source of the directory it names. A file wins over a directory of the same name.
25
26
  */ function watchedTs(filePath, isWatchedTs) {
26
27
  const withoutSlash = filePath.replace(/[/\\]+$/, "");
27
- const stem = withoutSlash.replace(/\.(js|ts)$/, "");
28
- // From the stem: Mesh's alias lookup has already added `.ts` to a subpath that names a
29
- // directory (`@lib/models` arrives as `…/models.ts`).
28
+ // Mesh's alias lookup may have added `.ts` to a subpath that names a directory (`@lib/models`
29
+ // arrives as `…/models.ts`), so the directory is the path without any extension.
30
+ const directory = withoutSlash.replace(/\.([mc]?[jt]sx?)$/, "");
30
31
  const candidates = [
31
- `${stem}.ts`,
32
- path.join(stem, "index.ts")
32
+ ...sourceExtensions.candidates(withoutSlash),
33
+ ...sourceExtensions.candidates(directory),
34
+ ...sourceExtensions.candidates(path.join(directory, "index"))
33
35
  ];
34
36
  return candidates.find((candidate)=>isWatchedTs(candidate)) ?? null;
35
37
  }
@@ -4,16 +4,18 @@ function _ts_decorate(decorators, target, key, desc) {
4
4
  else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  }
7
+ import { readFileSync } from "node:fs";
7
8
  import { registerHooks } from "node:module";
8
9
  import path from "node:path";
9
10
  import { fileURLToPath, pathToFileURL } from "node:url";
10
11
  import { injectable } from "@decohere/crucible/decorators";
11
12
  import { BuildTargets, createWorkspace, SwcTranspiler } from "@decohere/forge";
12
- import { packageAliases } from "@decohere/mesh";
13
+ import { packageAliases, sourceExtensions } from "@decohere/mesh";
13
14
  import { FileEx, io } from "@decohere/nexus/io";
14
15
  import { scribe } from "@decohere/scribe";
15
16
  import { rewriteServeSpecifier } from "./rewrite-serve-specifier.js";
16
17
  import { staticImports } from "./static-imports.js";
18
+ import { TranspileDiskCache } from "./transpile-disk-cache.js";
17
19
  export class SourceModuleLoader {
18
20
  logger = scribe.logger(SourceModuleLoader.name);
19
21
  transpiler = new SwcTranspiler();
@@ -25,8 +27,10 @@ export class SourceModuleLoader {
25
27
  transpileCache = new Map();
26
28
  /** Bumped when a file's cached code is dropped, so an up-front compile already running can't put it back. */ transpileVersions = new Map();
27
29
  /** The source files each entry's walk reached, so a reboot only walks out from what changed. */ reachedFrom = new Map();
30
+ /** Compiled code on disk across runs (#514); null when caching is off. */ diskCache = null;
28
31
  /** Up-front compiles in flight, shared by entries booting at the same time. */ compilingAhead = new Map();
29
32
  /** SWC options per project, built on first use and kept until the next `configure()`. */ swcConfigs = new Map();
33
+ commonJsConfigs = new Map();
30
34
  aliasPaths = {};
31
35
  aliasBaseUrl = path.resolve(".");
32
36
  versionFileUrl = (fileUrl)=>fileUrl;
@@ -34,9 +38,6 @@ export class SourceModuleLoader {
34
38
  getWorkspace() {
35
39
  return this.workspace;
36
40
  }
37
- getBuildConfig() {
38
- return this.buildConfig;
39
- }
40
41
  hasSourceFile(absolutePath) {
41
42
  return this.fileInfoMap.has(absolutePath);
42
43
  }
@@ -54,11 +55,15 @@ export class SourceModuleLoader {
54
55
  this.transpileCache = new Map();
55
56
  this.reachedFrom = new Map();
56
57
  this.swcConfigs.clear();
58
+ this.commonJsConfigs.clear();
57
59
  this.buildConfig = options.buildConfig;
58
60
  this.workspace = options.workspace ?? await createWorkspace();
59
61
  this.versionFileUrl = options.versionFileUrl ?? ((fileUrl)=>fileUrl);
60
62
  this.onResolved = options.onResolved;
61
63
  this.fileInfoMap = await this.buildFileInfoMap(this.buildConfig);
64
+ this.diskCache = TranspileDiskCache.forWorkspace(this.workspace.rootDir);
65
+ // In the background; it turns the cache off, with a warning, rather than throw.
66
+ void this.diskCache?.pruneIfDue();
62
67
  const aliases = this.workspace.getPackageAliases(this.buildConfig);
63
68
  this.aliasBaseUrl = aliases.baseUrl;
64
69
  this.aliasPaths = aliases.paths;
@@ -89,7 +94,7 @@ export class SourceModuleLoader {
89
94
  const result = this.loadTs(url);
90
95
  if (result != null) {
91
96
  return {
92
- format: "module",
97
+ format: result.format,
93
98
  source: result.source,
94
99
  shortCircuit: true
95
100
  };
@@ -107,9 +112,6 @@ export class SourceModuleLoader {
107
112
  for (const fullPath of absolutePaths){
108
113
  this.transpileCache.delete(fullPath);
109
114
  this.transpileVersions.set(fullPath, (this.transpileVersions.get(fullPath) ?? 0) + 1);
110
- this.transpiler.invalidateContentCache(new FileEx({
111
- fullPath
112
- }));
113
115
  }
114
116
  }
115
117
  dispose() {
@@ -117,6 +119,7 @@ export class SourceModuleLoader {
117
119
  this.transpileCache = new Map();
118
120
  this.reachedFrom = new Map();
119
121
  this.swcConfigs.clear();
122
+ this.commonJsConfigs.clear();
120
123
  this.fileInfoMap.clear();
121
124
  this.workspace?.[Symbol.dispose]();
122
125
  this.workspace = undefined;
@@ -177,14 +180,12 @@ export class SourceModuleLoader {
177
180
  if (inFlight != null) return inFlight;
178
181
  const version = this.transpileVersions.get(fullPath);
179
182
  const cache = this.transpileCache;
180
- const compiling = this.transpiler.transformFileAsync(new FileEx({
181
- fullPath
182
- }), BuildTargets.es6, {}, this.swcConfigFor(fileInfo.project)).then((response)=>{
183
- if (response.outcome !== "success") return undefined;
183
+ const compiling = this.compileAsync(fullPath, fileInfo.project).then((code)=>{
184
+ if (code == null) return undefined;
184
185
  // Saved, reconfigured or disposed while compiling: don't cache stale code.
185
186
  const current = !this.disposed && cache === this.transpileCache && version === this.transpileVersions.get(fullPath);
186
- if (current) cache.set(fullPath, response.code);
187
- return response.code;
187
+ if (current) cache.set(fullPath, code);
188
+ return code;
188
189
  }).finally(()=>this.compilingAhead.delete(fullPath));
189
190
  this.compilingAhead.set(fullPath, compiling);
190
191
  return compiling;
@@ -206,29 +207,95 @@ export class SourceModuleLoader {
206
207
  const cleanPath = fileURLToPath(url);
207
208
  const fileInfo = this.fileInfoMap.get(cleanPath);
208
209
  if (fileInfo == null) return null;
210
+ const format = sourceExtensions.isCommonJs(cleanPath) ? "commonjs" : "module";
209
211
  const cached = this.transpileCache.get(cleanPath);
210
212
  if (cached != null) return {
211
- source: cached
213
+ source: cached,
214
+ format
212
215
  };
216
+ const code = this.compileSync(cleanPath, fileInfo.project);
217
+ this.transpileCache.set(cleanPath, code);
218
+ return {
219
+ source: code,
220
+ format
221
+ };
222
+ }
223
+ /**
224
+ * Compiles a file for the load hook, through the disk cache (#514). Throws, naming the file,
225
+ * when it can't be read or doesn't compile.
226
+ */ compileSync(fullPath, project) {
213
227
  const file = new FileEx({
214
- fullPath: cleanPath
228
+ fullPath
215
229
  });
216
- const response = this.transpiler.transformFileSync(file, BuildTargets.es6, {}, this.swcConfigFor(fileInfo.project));
230
+ const baseConfig = this.swcConfigFor(project, fullPath);
231
+ let source;
232
+ try {
233
+ source = readFileSync(fullPath, "utf8");
234
+ } catch (error) {
235
+ this.logger.error(`Failed to transpile ${fullPath}`, error);
236
+ throw new Error(`Failed to transpile ${fullPath}`, {
237
+ cause: error
238
+ });
239
+ }
240
+ const key = this.diskCache?.keyFor(fullPath, source, baseConfig);
241
+ const hit = key == null ? undefined : this.diskCache?.readSync(key);
242
+ if (hit != null) return hit;
243
+ const response = this.transpiler.transformCodeSync(source, file, BuildTargets.es6, baseConfig);
217
244
  if (response.outcome !== "success") {
218
- this.logger.error(`Failed to transpile ${cleanPath}`, response);
219
- throw new Error(`Failed to transpile ${cleanPath}`);
245
+ this.logger.error(`Failed to transpile ${fullPath}`, response);
246
+ throw new Error(`Failed to transpile ${fullPath}`);
220
247
  }
221
- this.transpileCache.set(cleanPath, response.code);
222
- return {
223
- source: response.code
224
- };
248
+ if (key != null) this.diskCache?.writeSync(key, response.code);
249
+ return response.code;
250
+ }
251
+ /**
252
+ * Compiles a file ahead, through the disk cache; undefined when it doesn't compile, which the
253
+ * load hook then reports (see {@link compileAhead}). The source and the cache entry are read
254
+ * synchronously: for small files that is far cheaper than a trip through libuv's thread pool,
255
+ * which async SWC needs for the compiles that miss (#514).
256
+ */ async compileAsync(fullPath, project) {
257
+ const file = new FileEx({
258
+ fullPath
259
+ });
260
+ const baseConfig = this.swcConfigFor(project, fullPath);
261
+ let source;
262
+ try {
263
+ source = readFileSync(fullPath, "utf8");
264
+ } catch {
265
+ // Left to the load hook, which reads it again and fails naming it, as for a file
266
+ // that doesn't compile.
267
+ return undefined;
268
+ }
269
+ const key = this.diskCache?.keyFor(fullPath, source, baseConfig);
270
+ const hit = key == null ? undefined : this.diskCache?.readSync(key);
271
+ if (hit != null) return hit;
272
+ const response = await this.transpiler.transformCode(source, file, BuildTargets.es6, baseConfig);
273
+ if (response.outcome !== "success") return undefined;
274
+ if (key != null) await this.diskCache?.write(key, response.code);
275
+ return response.code;
225
276
  }
226
277
  /**
227
278
  * The project's SWC options, built once: rebuilding them for every file was about a quarter
228
279
  * of load time (#513). Import specifiers are left as written, without `jsc.paths` or
229
280
  * `resolveFully`: the resolve hook maps them to source files (aliases, `.js` to `.ts`,
230
281
  * directory indexes), so SWC does not resolve them a second time against the disk.
231
- */ swcConfigFor(project) {
282
+ */ swcConfigFor(project, fullPath) {
283
+ const config = this.esModuleConfigFor(project);
284
+ if (!sourceExtensions.isCommonJs(fullPath)) return config;
285
+ // `.cts` is CommonJS: Node loads it with `require` semantics, so SWC emits CommonJS (#518).
286
+ // Kept per project, like the ES module options, so the disk cache hashes them once.
287
+ const cached = this.commonJsConfigs.get(project);
288
+ if (cached != null) return cached;
289
+ const commonJs = {
290
+ ...config,
291
+ module: {
292
+ type: "commonjs"
293
+ }
294
+ };
295
+ this.commonJsConfigs.set(project, commonJs);
296
+ return commonJs;
297
+ }
298
+ esModuleConfigFor(project) {
232
299
  const cached = this.swcConfigs.get(project);
233
300
  if (cached != null) return cached;
234
301
  const base = this.transpiler.createBaseConfig(BuildTargets.es6, {
@@ -0,0 +1,142 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { transpileCache } from "@decohere/forge";
5
+ import { scribe } from "@decohere/scribe";
6
+ /** Bump when what flux stores changes, so older entries stop matching. */ const FORMAT = 1;
7
+ /** Entries untouched for this long are pruned. */ const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
8
+ /** Pruning runs at most this often, recorded by the mtime of a marker file. */ const PRUNE_EVERY_MS = 24 * 60 * 60 * 1000;
9
+ const PRUNE_MARKER = ".last-prune";
10
+ /**
11
+ * Compiled code on disk, so a cold start doesn't compile files that haven't changed (#514).
12
+ * Content-addressed: the key hashes the file's path and source, the SWC options it compiles
13
+ * with, SWC's version and this cache's format, so any change to those is a miss, never a stale
14
+ * hit. Entries are written to a temp file and renamed into place, so a process reading an entry
15
+ * another is writing sees all of it or none.
16
+ *
17
+ * The cache is an optimisation: if it can't read or write its directory, it says so once and
18
+ * turns itself off for the process, and flux compiles as it would without it.
19
+ */ export class TranspileDiskCache {
20
+ dir;
21
+ logger = scribe.logger(TranspileDiskCache.name);
22
+ disabled = false;
23
+ constructor(dir){
24
+ this.dir = dir;
25
+ }
26
+ /** The cache for a workspace, or null when caching is off (`DECOHERE_CACHE=0`). */ static forWorkspace(rootDir) {
27
+ const base = transpileCache.directory(rootDir);
28
+ return base == null ? null : new TranspileDiskCache(path.join(base, "flux"));
29
+ }
30
+ get directory() {
31
+ return this.dir;
32
+ }
33
+ /**
34
+ * The key for a file compiled with `baseOptions`: the per-file options are those plus the
35
+ * file's path (forge's `createFileConfig`), so hashing the base once per project and adding
36
+ * the path covers them without serialising options for every file.
37
+ */ keyFor(fullPath, source, baseOptions) {
38
+ return createHash("sha256").update(`${FORMAT}\0${transpileCache.swcVersion}\0${this.optionsHash(baseOptions)}\0`).update(`${fullPath}\0`).update(source).digest("hex");
39
+ }
40
+ optionsHashes = new WeakMap();
41
+ optionsHash(options) {
42
+ const cached = this.optionsHashes.get(options);
43
+ if (cached != null) return cached;
44
+ const hash = createHash("sha256").update(JSON.stringify(options)).digest("hex");
45
+ this.optionsHashes.set(options, hash);
46
+ return hash;
47
+ }
48
+ readSync(key) {
49
+ if (this.disabled) return undefined;
50
+ try {
51
+ return fs.readFileSync(this.entryPath(key), "utf8");
52
+ } catch (error) {
53
+ return this.missOrDisable(error);
54
+ }
55
+ }
56
+ async read(key) {
57
+ if (this.disabled) return undefined;
58
+ try {
59
+ return await fs.promises.readFile(this.entryPath(key), "utf8");
60
+ } catch (error) {
61
+ return this.missOrDisable(error);
62
+ }
63
+ }
64
+ writeSync(key, code) {
65
+ if (this.disabled) return;
66
+ const file = this.entryPath(key);
67
+ const temp = this.tempPath(file);
68
+ try {
69
+ fs.mkdirSync(path.dirname(file), {
70
+ recursive: true
71
+ });
72
+ fs.writeFileSync(temp, code);
73
+ fs.renameSync(temp, file);
74
+ } catch (error) {
75
+ this.disable("write", error);
76
+ }
77
+ }
78
+ async write(key, code) {
79
+ if (this.disabled) return;
80
+ const file = this.entryPath(key);
81
+ const temp = this.tempPath(file);
82
+ try {
83
+ await fs.promises.mkdir(path.dirname(file), {
84
+ recursive: true
85
+ });
86
+ await fs.promises.writeFile(temp, code);
87
+ await fs.promises.rename(temp, file);
88
+ } catch (error) {
89
+ this.disable("write", error);
90
+ }
91
+ }
92
+ /** Deletes entries older than 30 days, if it hasn't done so in the last day. */ async pruneIfDue(now = Date.now()) {
93
+ if (this.disabled) return;
94
+ const marker = path.join(this.dir, PRUNE_MARKER);
95
+ try {
96
+ const last = await fs.promises.stat(marker).then((stat)=>stat.mtimeMs, (error)=>{
97
+ if (error.code === "ENOENT") return 0;
98
+ throw error;
99
+ });
100
+ if (now - last < PRUNE_EVERY_MS) return;
101
+ await fs.promises.mkdir(this.dir, {
102
+ recursive: true
103
+ });
104
+ await fs.promises.writeFile(marker, "");
105
+ for (const shard of (await fs.promises.readdir(this.dir, {
106
+ withFileTypes: true
107
+ }))){
108
+ if (!shard.isDirectory()) continue;
109
+ const shardDir = path.join(this.dir, shard.name);
110
+ for (const entry of (await fs.promises.readdir(shardDir))){
111
+ // Old entries, and temp files a crashed process left mid-write.
112
+ const file = path.join(shardDir, entry);
113
+ const mtime = await fs.promises.stat(file).then((stat)=>stat.mtimeMs, (error)=>{
114
+ // Renamed or removed by another process since the listing: nothing to prune.
115
+ if (error.code === "ENOENT") return undefined;
116
+ throw error;
117
+ });
118
+ if (mtime != null && now - mtime > MAX_AGE_MS) await fs.promises.rm(file, {
119
+ force: true
120
+ });
121
+ }
122
+ }
123
+ } catch (error) {
124
+ this.disable("prune", error);
125
+ }
126
+ }
127
+ entryPath(key) {
128
+ return path.join(this.dir, key.slice(0, 2), `${key}.js`);
129
+ }
130
+ tempPath(file) {
131
+ return `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
132
+ }
133
+ /** A missing entry is a miss; any other read error turns the cache off. */ missOrDisable(error) {
134
+ if (error.code !== "ENOENT") this.disable("read", error);
135
+ return undefined;
136
+ }
137
+ disable(operation, error) {
138
+ if (this.disabled) return;
139
+ this.disabled = true;
140
+ this.logger.warn(`Transpile cache turned off for this run: could not ${operation} ${this.dir} (${String(error)}). ` + "Flux compiles without it. Set DECOHERE_CACHE_DIR to a writable directory, or DECOHERE_CACHE=0 to silence this.");
141
+ }
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decohere/flux",
3
- "version": "0.0.1-dev.112",
3
+ "version": "0.0.1-dev.113",
4
4
  "description": "Hot module reload server for Node.js ESM applications",
5
5
  "keywords": [
6
6
  "typescript",
@@ -25,11 +25,11 @@
25
25
  "test": "echo \"Error: no test specified\" && exit 1"
26
26
  },
27
27
  "dependencies": {
28
- "@decohere/crucible": "0.0.1-dev.112",
29
- "@decohere/forge": "0.0.1-dev.112",
30
- "@decohere/mesh": "0.0.1-dev.112",
31
- "@decohere/nexus": "0.0.1-dev.112",
32
- "@decohere/scribe": "0.0.1-dev.112",
28
+ "@decohere/crucible": "0.0.1-dev.113",
29
+ "@decohere/forge": "0.0.1-dev.113",
30
+ "@decohere/mesh": "0.0.1-dev.113",
31
+ "@decohere/nexus": "0.0.1-dev.113",
32
+ "@decohere/scribe": "0.0.1-dev.113",
33
33
  "commander": "^13.1.0",
34
34
  "rxjs": "^7.8.2"
35
35
  },
@@ -28,7 +28,8 @@ export interface HotReloadServerOptions {
28
28
  workspace?: Workspace;
29
29
  /**
30
30
  * Quiet window in milliseconds after the last affecting save before a reboot.
31
- * Defaults to {@link DEFAULT_REBOOT_DEBOUNCE_MS}. Raise this for agent save trains.
31
+ * Defaults to {@link DEFAULT_REBOOT_DEBOUNCE_MS}. Every reload waits this long, so a larger
32
+ * window only pays off for saves known to come at a steady pace inside it (#484).
32
33
  */
33
34
  rebootDebounceMs?: number;
34
35
  }
@@ -34,16 +34,18 @@ export declare class SourceModuleLoader {
34
34
  private transpileVersions;
35
35
  /** The source files each entry's walk reached, so a reboot only walks out from what changed. */
36
36
  private reachedFrom;
37
+ /** Compiled code on disk across runs (#514); null when caching is off. */
38
+ private diskCache;
37
39
  /** Up-front compiles in flight, shared by entries booting at the same time. */
38
40
  private compilingAhead;
39
41
  /** SWC options per project, built on first use and kept until the next `configure()`. */
40
42
  private swcConfigs;
43
+ private commonJsConfigs;
41
44
  private aliasPaths;
42
45
  private aliasBaseUrl;
43
46
  private versionFileUrl;
44
47
  private onResolved?;
45
48
  getWorkspace(): Workspace | undefined;
46
- getBuildConfig(): string;
47
49
  hasSourceFile(absolutePath: string): boolean;
48
50
  sourceFilePaths(): IterableIterator<string>;
49
51
  setSourceFile(absolutePath: string, project: Project): void;
@@ -71,6 +73,18 @@ export declare class SourceModuleLoader {
71
73
  /** The source file a specifier names, mapped the way the resolve hook maps it. */
72
74
  private sourcePathOf;
73
75
  private loadTs;
76
+ /**
77
+ * Compiles a file for the load hook, through the disk cache (#514). Throws, naming the file,
78
+ * when it can't be read or doesn't compile.
79
+ */
80
+ private compileSync;
81
+ /**
82
+ * Compiles a file ahead, through the disk cache; undefined when it doesn't compile, which the
83
+ * load hook then reports (see {@link compileAhead}). The source and the cache entry are read
84
+ * synchronously: for small files that is far cheaper than a trip through libuv's thread pool,
85
+ * which async SWC needs for the compiles that miss (#514).
86
+ */
87
+ private compileAsync;
74
88
  /**
75
89
  * The project's SWC options, built once: rebuilding them for every file was about a quarter
76
90
  * of load time (#513). Import specifiers are left as written, without `jsc.paths` or
@@ -78,4 +92,5 @@ export declare class SourceModuleLoader {
78
92
  * directory indexes), so SWC does not resolve them a second time against the disk.
79
93
  */
80
94
  private swcConfigFor;
95
+ private esModuleConfigFor;
81
96
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Compiled code on disk, so a cold start doesn't compile files that haven't changed (#514).
3
+ * Content-addressed: the key hashes the file's path and source, the SWC options it compiles
4
+ * with, SWC's version and this cache's format, so any change to those is a miss, never a stale
5
+ * hit. Entries are written to a temp file and renamed into place, so a process reading an entry
6
+ * another is writing sees all of it or none.
7
+ *
8
+ * The cache is an optimisation: if it can't read or write its directory, it says so once and
9
+ * turns itself off for the process, and flux compiles as it would without it.
10
+ */
11
+ export declare class TranspileDiskCache {
12
+ private readonly dir;
13
+ private readonly logger;
14
+ private disabled;
15
+ private constructor();
16
+ /** The cache for a workspace, or null when caching is off (`DECOHERE_CACHE=0`). */
17
+ static forWorkspace(rootDir: string): TranspileDiskCache | null;
18
+ get directory(): string;
19
+ /**
20
+ * The key for a file compiled with `baseOptions`: the per-file options are those plus the
21
+ * file's path (forge's `createFileConfig`), so hashing the base once per project and adding
22
+ * the path covers them without serialising options for every file.
23
+ */
24
+ keyFor(fullPath: string, source: string, baseOptions: object): string;
25
+ private readonly optionsHashes;
26
+ private optionsHash;
27
+ readSync(key: string): string | undefined;
28
+ read(key: string): Promise<string | undefined>;
29
+ writeSync(key: string, code: string): void;
30
+ write(key: string, code: string): Promise<void>;
31
+ /** Deletes entries older than 30 days, if it hasn't done so in the last day. */
32
+ pruneIfDue(now?: number): Promise<void>;
33
+ private entryPath;
34
+ private tempPath;
35
+ /** A missing entry is a miss; any other read error turns the cache off. */
36
+ private missOrDisable;
37
+ private disable;
38
+ }