@decohere/flux 0.0.1-dev.112 → 0.0.1-dev.114
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 +32 -6
- package/commonjs/src/rewrite-serve-specifier.js +9 -7
- package/commonjs/src/source-module-loader.js +90 -23
- package/commonjs/src/transpile-disk-cache.js +148 -0
- package/es6/src/rewrite-serve-specifier.js +10 -8
- package/es6/src/source-module-loader.js +91 -24
- package/es6/src/transpile-disk-cache.js +142 -0
- package/package.json +6 -6
- package/types/src/hot-reload-server.d.ts +2 -1
- package/types/src/source-module-loader.d.ts +16 -1
- package/types/src/transpile-disk-cache.d.ts +38 -0
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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:
|
|
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
|
|
34
|
-
*
|
|
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
|
-
|
|
38
|
-
//
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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:
|
|
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.
|
|
237
|
-
|
|
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,
|
|
243
|
-
return
|
|
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
|
|
284
|
+
fullPath
|
|
271
285
|
});
|
|
272
|
-
const
|
|
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 ${
|
|
275
|
-
throw new Error(`Failed to transpile ${
|
|
301
|
+
this.logger.error(`Failed to transpile ${fullPath}`, response);
|
|
302
|
+
throw new Error(`Failed to transpile ${fullPath}`);
|
|
276
303
|
}
|
|
277
|
-
this.
|
|
278
|
-
return
|
|
279
|
-
|
|
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
|
|
24
|
-
*
|
|
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
|
-
|
|
28
|
-
//
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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:
|
|
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.
|
|
181
|
-
|
|
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,
|
|
187
|
-
return
|
|
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
|
|
228
|
+
fullPath
|
|
215
229
|
});
|
|
216
|
-
const
|
|
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 ${
|
|
219
|
-
throw new Error(`Failed to transpile ${
|
|
245
|
+
this.logger.error(`Failed to transpile ${fullPath}`, response);
|
|
246
|
+
throw new Error(`Failed to transpile ${fullPath}`);
|
|
220
247
|
}
|
|
221
|
-
this.
|
|
222
|
-
return
|
|
223
|
-
|
|
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.
|
|
3
|
+
"version": "0.0.1-dev.114",
|
|
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.
|
|
29
|
-
"@decohere/forge": "0.0.1-dev.
|
|
30
|
-
"@decohere/mesh": "0.0.1-dev.
|
|
31
|
-
"@decohere/nexus": "0.0.1-dev.
|
|
32
|
-
"@decohere/scribe": "0.0.1-dev.
|
|
28
|
+
"@decohere/crucible": "0.0.1-dev.114",
|
|
29
|
+
"@decohere/forge": "0.0.1-dev.114",
|
|
30
|
+
"@decohere/mesh": "0.0.1-dev.114",
|
|
31
|
+
"@decohere/nexus": "0.0.1-dev.114",
|
|
32
|
+
"@decohere/scribe": "0.0.1-dev.114",
|
|
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}.
|
|
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
|
+
}
|