@decohere/flux 0.0.1-dev.13 → 0.0.1-dev.130

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.
Files changed (48) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +273 -0
  3. package/commonjs/src/deferred-script-runner.js +56 -0
  4. package/commonjs/src/flux.command.js +3 -3
  5. package/commonjs/src/flux.js +56 -0
  6. package/commonjs/src/flux.module.js +9 -8
  7. package/commonjs/src/hot-reload-server.js +307 -219
  8. package/commonjs/src/index.js +34 -2
  9. package/commonjs/src/rewrite-serve-specifier.js +61 -0
  10. package/commonjs/src/run.action.js +47 -0
  11. package/commonjs/src/run.command.js +65 -0
  12. package/commonjs/src/serve.action.js +78 -7
  13. package/commonjs/src/serve.command.js +35 -4
  14. package/commonjs/src/slice-serve.js +130 -0
  15. package/commonjs/src/source-module-loader.js +388 -0
  16. package/commonjs/src/static-imports.js +37 -0
  17. package/commonjs/src/transpile-disk-cache.js +148 -0
  18. package/es6/src/deferred-script-runner.js +46 -0
  19. package/es6/src/flux.command.js +3 -3
  20. package/es6/src/flux.js +41 -0
  21. package/es6/src/flux.module.js +9 -8
  22. package/es6/src/hot-reload-server.js +287 -175
  23. package/es6/src/index.js +5 -3
  24. package/es6/src/rewrite-serve-specifier.js +51 -0
  25. package/es6/src/run.action.js +37 -0
  26. package/es6/src/run.command.js +55 -0
  27. package/es6/src/serve.action.js +73 -9
  28. package/es6/src/serve.command.js +35 -4
  29. package/es6/src/slice-serve.js +105 -0
  30. package/es6/src/source-module-loader.js +332 -0
  31. package/es6/src/static-imports.js +27 -0
  32. package/es6/src/transpile-disk-cache.js +142 -0
  33. package/package.json +17 -6
  34. package/types/src/deferred-script-runner.d.ts +18 -0
  35. package/types/src/flux.command.d.ts +2 -2
  36. package/types/src/flux.d.ts +43 -0
  37. package/types/src/flux.module.d.ts +6 -4
  38. package/types/src/hot-reload-server.d.ts +57 -23
  39. package/types/src/index.d.ts +8 -1
  40. package/types/src/rewrite-serve-specifier.d.ts +14 -0
  41. package/types/src/run.action.d.ts +15 -0
  42. package/types/src/run.command.d.ts +11 -0
  43. package/types/src/serve.action.d.ts +13 -3
  44. package/types/src/serve.command.d.ts +7 -1
  45. package/types/src/slice-serve.d.ts +52 -0
  46. package/types/src/source-module-loader.d.ts +104 -0
  47. package/types/src/static-imports.d.ts +5 -0
  48. package/types/src/transpile-disk-cache.d.ts +38 -0
@@ -0,0 +1,332 @@
1
+ function _ts_decorate(decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
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
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ }
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { registerHooks } from "node:module";
9
+ import path from "node:path";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
+ import { injectable } from "@decohere/crucible/decorators";
12
+ import { BuildTargets, createWorkspace, SwcTranspiler } from "@decohere/forge";
13
+ import { packageAliases, sourceExtensions } from "@decohere/workspace";
14
+ import { FileEx, io } from "@decohere/core/io";
15
+ import { scribe } from "@decohere/scribe";
16
+ import { rewriteServeSpecifier } from "./rewrite-serve-specifier.js";
17
+ import { staticImports } from "./static-imports.js";
18
+ import { TranspileDiskCache } from "./transpile-disk-cache.js";
19
+ export class SourceModuleLoader {
20
+ logger = scribe.logger(SourceModuleLoader.name);
21
+ transpiler = new SwcTranspiler();
22
+ disposed = false;
23
+ hooksRegistered = false;
24
+ workspace;
25
+ buildConfig = "debug";
26
+ fileInfoMap = new Map();
27
+ transpileCache = new Map();
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();
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; null when caching is off. */ diskCache = null;
31
+ /** Up-front compiles in flight, shared by entries booting at the same time. */ compilingAhead = new Map();
32
+ /** SWC options per project, built on first use and kept until the next `configure()`. */ swcConfigs = new Map();
33
+ commonJsConfigs = new Map();
34
+ aliasPaths = {};
35
+ aliasBaseUrl = path.resolve(".");
36
+ versionFileUrl = (fileUrl)=>fileUrl;
37
+ onResolved;
38
+ getWorkspace() {
39
+ return this.workspace;
40
+ }
41
+ hasSourceFile(absolutePath) {
42
+ return this.fileInfoMap.has(absolutePath);
43
+ }
44
+ sourceFilePaths() {
45
+ return this.fileInfoMap.keys();
46
+ }
47
+ setSourceFile(absolutePath, project) {
48
+ this.fileInfoMap.set(absolutePath, {
49
+ project
50
+ });
51
+ }
52
+ async configure(options) {
53
+ this.disposed = false;
54
+ // A new Map, not clear(): an up-front compile from before can't write into this one.
55
+ this.transpileCache = new Map();
56
+ this.reachedFrom = new Map();
57
+ this.swcConfigs.clear();
58
+ this.commonJsConfigs.clear();
59
+ this.buildConfig = options.buildConfig;
60
+ this.workspace = options.workspace ?? await createWorkspace();
61
+ this.versionFileUrl = options.versionFileUrl ?? ((fileUrl)=>fileUrl);
62
+ this.onResolved = options.onResolved;
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();
67
+ const aliases = this.workspace.getPackageAliases(this.buildConfig);
68
+ this.aliasBaseUrl = aliases.baseUrl;
69
+ this.aliasPaths = aliases.paths;
70
+ }
71
+ registerHooks() {
72
+ if (this.hooksRegistered) return;
73
+ this.hooksRegistered = true;
74
+ // Compiled code carries inline source maps; Node only applies them to stack traces when
75
+ // this is on. Without it, errors point at compiled line numbers.
76
+ process.setSourceMapsEnabled(true);
77
+ registerHooks({
78
+ resolve: (specifier, context, next)=>{
79
+ if (this.disposed) return next(specifier, context);
80
+ const parentUrl = context.parentURL ?? "";
81
+ const rewritten = rewriteServeSpecifier({
82
+ specifier,
83
+ parentUrl,
84
+ isWatchedTs: (filePath)=>this.sourceInfo(filePath) != null,
85
+ resolveBare: (bare)=>packageAliases.resolvePath(bare, this.aliasPaths, this.aliasBaseUrl),
86
+ versionFileUrl: this.versionFileUrl
87
+ }) ?? specifier;
88
+ const result = next(rewritten, context);
89
+ this.onResolved?.(result, parentUrl);
90
+ return result;
91
+ },
92
+ load: (url, context, next)=>{
93
+ if (this.disposed) return next(url, context);
94
+ const result = this.loadTs(url);
95
+ if (result != null) {
96
+ return {
97
+ format: result.format,
98
+ source: result.source,
99
+ shortCircuit: true
100
+ };
101
+ }
102
+ return next(url, context);
103
+ }
104
+ });
105
+ }
106
+ async importEntry(absolutePath) {
107
+ await this.compileAhead(path.resolve(absolutePath));
108
+ const url = this.versionFileUrl(io.pathToFileUrl(path.resolve(absolutePath)));
109
+ return import(url);
110
+ }
111
+ invalidateTranspile(...absolutePaths) {
112
+ for (const fullPath of absolutePaths){
113
+ this.transpileCache.delete(fullPath);
114
+ this.transpileVersions.set(fullPath, (this.transpileVersions.get(fullPath) ?? 0) + 1);
115
+ }
116
+ }
117
+ dispose() {
118
+ this.disposed = true;
119
+ this.transpileCache = new Map();
120
+ this.reachedFrom = new Map();
121
+ this.swcConfigs.clear();
122
+ this.commonJsConfigs.clear();
123
+ this.fileInfoMap.clear();
124
+ this.workspace?.[Symbol.dispose]();
125
+ this.workspace = undefined;
126
+ }
127
+ /**
128
+ * The file as a source flux compiles: a workspace package's file, or any other TypeScript
129
+ * source on disk outside `node_modules`, which Node would otherwise only strip of types, failing
130
+ * on enums, decorators and parameter properties. Null for everything else.
131
+ */ sourceInfo(fullPath) {
132
+ const fileInfo = this.fileInfoMap.get(fullPath);
133
+ if (fileInfo != null) return fileInfo;
134
+ const outsidePackages = sourceExtensions.isSource(fullPath) && !fullPath.split(/[/\\]/).includes("node_modules") && existsSync(fullPath);
135
+ return outsidePackages ? {} : null;
136
+ }
137
+ async buildFileInfoMap(buildConfig) {
138
+ const projects = this.workspace?.getProjects(buildConfig) ?? [];
139
+ const entries = await Promise.all(projects.map(async (project)=>{
140
+ const files = await project.getAllFiles();
141
+ return files.map((file)=>[
142
+ file.fullPath,
143
+ {
144
+ project
145
+ }
146
+ ]);
147
+ }));
148
+ return new Map(entries.flat());
149
+ }
150
+ /**
151
+ * Compiles the entry and the source files it imports, before Node imports it. The load
152
+ * hook is synchronous, so it would compile one file at a time on the main thread; async SWC
153
+ * runs on the thread pool. Walks out from the entry, compiling each file's imports in parallel,
154
+ * so files the app never imports (tests, other apps) are not compiled. A reboot walks out only
155
+ * from the files it reached before that are no longer cached (the saved ones), so their new
156
+ * imports are found without visiting the rest of the graph again.
157
+ *
158
+ * A file that fails here is left out of the cache instead of failing the boot: the load hook
159
+ * then compiles it again and throws as before, naming the file. The walk is a hint; whatever
160
+ * it misses is compiled by the load hook when Node gets to it.
161
+ */ async compileAhead(entryPath) {
162
+ const reached = this.reachedFrom.get(entryPath);
163
+ const starts = reached == null ? [
164
+ entryPath
165
+ ] : [
166
+ ...reached
167
+ ].filter((fullPath)=>!this.transpileCache.has(fullPath));
168
+ const seen = new Set(reached);
169
+ for (const start of starts)seen.delete(start);
170
+ const visit = async (fullPath)=>{
171
+ if (seen.has(fullPath)) return;
172
+ seen.add(fullPath);
173
+ const code = await this.compiledAhead(fullPath);
174
+ if (code == null) return;
175
+ const parentUrl = pathToFileURL(fullPath).toString();
176
+ await Promise.all(staticImports.scan(code).map((specifier)=>{
177
+ const target = this.sourcePathOf(specifier, parentUrl);
178
+ return target == null ? undefined : visit(target);
179
+ }));
180
+ };
181
+ await Promise.all(starts.map(visit));
182
+ this.reachedFrom.set(entryPath, seen);
183
+ }
184
+ /** The file's compiled code, from the cache or compiled now; undefined if it isn't a source. */ compiledAhead(fullPath) {
185
+ const fileInfo = this.sourceInfo(fullPath);
186
+ if (fileInfo == null) return Promise.resolve(undefined);
187
+ const cached = this.transpileCache.get(fullPath);
188
+ if (cached != null) return Promise.resolve(cached);
189
+ const inFlight = this.compilingAhead.get(fullPath);
190
+ if (inFlight != null) return inFlight;
191
+ const version = this.transpileVersions.get(fullPath);
192
+ const cache = this.transpileCache;
193
+ const compiling = this.compileAsync(fullPath, fileInfo.project).then((code)=>{
194
+ if (code == null) return undefined;
195
+ // Saved, reconfigured or disposed while compiling: don't cache stale code.
196
+ const current = !this.disposed && cache === this.transpileCache && version === this.transpileVersions.get(fullPath);
197
+ if (current) cache.set(fullPath, code);
198
+ return code;
199
+ }).finally(()=>this.compilingAhead.delete(fullPath));
200
+ this.compilingAhead.set(fullPath, compiling);
201
+ return compiling;
202
+ }
203
+ /** The source file a specifier names, mapped the way the resolve hook maps it. */ sourcePathOf(specifier, parentUrl) {
204
+ const url = rewriteServeSpecifier({
205
+ specifier,
206
+ parentUrl,
207
+ isWatchedTs: (filePath)=>this.sourceInfo(filePath) != null,
208
+ resolveBare: (bare)=>packageAliases.resolvePath(bare, this.aliasPaths, this.aliasBaseUrl),
209
+ versionFileUrl: (fileUrl)=>fileUrl
210
+ });
211
+ return url == null ? null : fileURLToPath(url);
212
+ }
213
+ loadTs(url) {
214
+ if (!url.startsWith("file:")) return null;
215
+ // fileURLToPath, not URL.pathname: decodes %20 and gives Windows paths, so files
216
+ // whose path has a space still match the workspace's package list.
217
+ const cleanPath = fileURLToPath(url);
218
+ const fileInfo = this.sourceInfo(cleanPath);
219
+ if (fileInfo == null) return null;
220
+ const format = sourceExtensions.isCommonJs(cleanPath) ? "commonjs" : "module";
221
+ const cached = this.transpileCache.get(cleanPath);
222
+ if (cached != null) return {
223
+ source: cached,
224
+ format
225
+ };
226
+ const code = this.compileSync(cleanPath, fileInfo.project);
227
+ this.transpileCache.set(cleanPath, code);
228
+ return {
229
+ source: code,
230
+ format
231
+ };
232
+ }
233
+ /**
234
+ * Compiles a file for the load hook, through the disk cache. Throws, naming the file,
235
+ * when it can't be read or doesn't compile.
236
+ */ compileSync(fullPath, project) {
237
+ const file = new FileEx({
238
+ fullPath
239
+ });
240
+ const baseConfig = this.swcConfigFor(project, fullPath);
241
+ let source;
242
+ try {
243
+ source = readFileSync(fullPath, "utf8");
244
+ } catch (error) {
245
+ this.logger.error(`Failed to transpile ${fullPath}`, error);
246
+ throw new Error(`Failed to transpile ${fullPath}`, {
247
+ cause: error
248
+ });
249
+ }
250
+ const key = this.diskCache?.keyFor(fullPath, source, baseConfig);
251
+ const hit = key == null ? undefined : this.diskCache?.readSync(key);
252
+ if (hit != null) return hit;
253
+ const response = this.transpiler.transformCodeSync(source, file, BuildTargets.es6, baseConfig);
254
+ if (response.outcome !== "success") {
255
+ this.logger.error(`Failed to transpile ${fullPath}`, response);
256
+ throw new Error(`Failed to transpile ${fullPath}`);
257
+ }
258
+ if (key != null) this.diskCache?.writeSync(key, response.code);
259
+ return response.code;
260
+ }
261
+ /**
262
+ * Compiles a file ahead, through the disk cache; undefined when it doesn't compile, which the
263
+ * load hook then reports (see {@link compileAhead}). The source and the cache entry are read
264
+ * synchronously: for small files that is far cheaper than a trip through libuv's thread pool,
265
+ * which async SWC needs for the compiles that miss.
266
+ */ async compileAsync(fullPath, project) {
267
+ const file = new FileEx({
268
+ fullPath
269
+ });
270
+ const baseConfig = this.swcConfigFor(project, fullPath);
271
+ let source;
272
+ try {
273
+ source = readFileSync(fullPath, "utf8");
274
+ } catch {
275
+ // Left to the load hook, which reads it again and fails naming it, as for a file
276
+ // that doesn't compile.
277
+ return undefined;
278
+ }
279
+ const key = this.diskCache?.keyFor(fullPath, source, baseConfig);
280
+ const hit = key == null ? undefined : this.diskCache?.readSync(key);
281
+ if (hit != null) return hit;
282
+ const response = await this.transpiler.transformCode(source, file, BuildTargets.es6, baseConfig);
283
+ if (response.outcome !== "success") return undefined;
284
+ if (key != null) await this.diskCache?.write(key, response.code);
285
+ return response.code;
286
+ }
287
+ /**
288
+ * The project's SWC options, built once: rebuilding them for every file was about a quarter
289
+ * of load time. Import specifiers are left as written, without `jsc.paths` or
290
+ * `resolveFully`: the resolve hook maps them to source files (aliases, `.js` to `.ts`,
291
+ * directory indexes), so SWC does not resolve them a second time against the disk.
292
+ */ swcConfigFor(project, fullPath) {
293
+ const config = this.esModuleConfigFor(project);
294
+ if (!sourceExtensions.isCommonJs(fullPath)) return config;
295
+ // `.cts` is CommonJS: Node loads it with `require` semantics, so SWC emits CommonJS.
296
+ // Kept per project, like the ES module options, so the disk cache hashes them once.
297
+ const cached = this.commonJsConfigs.get(project);
298
+ if (cached != null) return cached;
299
+ const commonJs = {
300
+ ...config,
301
+ module: {
302
+ type: "commonjs"
303
+ }
304
+ };
305
+ this.commonJsConfigs.set(project, commonJs);
306
+ return commonJs;
307
+ }
308
+ /** Forge's defaults when there is no project: decorators and their metadata on. */ esModuleConfigFor(project) {
309
+ const cached = this.swcConfigs.get(project);
310
+ if (cached != null) return cached;
311
+ const base = this.transpiler.createBaseConfig(BuildTargets.es6, {
312
+ tsConfig: project?.getTsConfigSync(),
313
+ userConfig: project?.getSwcConfigSync(),
314
+ aliasPaths: false,
315
+ workspace: this.workspace
316
+ });
317
+ // `module` is the ES module config (`type: "es6"` above); the spread loses the narrowing.
318
+ const module = {
319
+ ...base.module,
320
+ resolveFully: false
321
+ };
322
+ const config = {
323
+ ...base,
324
+ module
325
+ };
326
+ this.swcConfigs.set(project, config);
327
+ return config;
328
+ }
329
+ }
330
+ SourceModuleLoader = _ts_decorate([
331
+ injectable()
332
+ ], SourceModuleLoader);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The module specifiers a compiled ES module names in `import … from`, `export … from`, bare
3
+ * `import "x"` and literal `import("x")`.
4
+ *
5
+ * Only a hint for compiling ahead: a specifier it misses is compiled when Node loads it,
6
+ * and one it finds by mistake (an import written inside a string) costs one extra compile. So a
7
+ * scan of SWC's output is enough; it never decides what runs.
8
+ */ const STATIC = /\b(?:import|export)\s*(?:[\w$*{},\s]+?\s*from\s*)?["']([^"'\n]+)["']/g;
9
+ const DYNAMIC = /\bimport\s*\(\s*["']([^"'\n]+)["']\s*\)/g;
10
+ function scan(code) {
11
+ const found = new Set();
12
+ for (const pattern of [
13
+ STATIC,
14
+ DYNAMIC
15
+ ]){
16
+ for (const match of code.matchAll(pattern)){
17
+ const specifier = match[1];
18
+ if (specifier != null) found.add(specifier);
19
+ }
20
+ }
21
+ return [
22
+ ...found
23
+ ];
24
+ }
25
+ export const staticImports = {
26
+ scan
27
+ };
@@ -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.
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.13",
3
+ "version": "0.0.1-dev.130",
4
4
  "description": "Hot module reload server for Node.js ESM applications",
5
5
  "keywords": [
6
6
  "typescript",
@@ -11,6 +11,15 @@
11
11
  "watch",
12
12
  "decohere"
13
13
  ],
14
+ "homepage": "https://github.com/typesmiths/decohere/tree/main/projects/flux#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/typesmiths/decohere/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/typesmiths/decohere.git",
21
+ "directory": "projects/flux"
22
+ },
14
23
  "license": "ISC",
15
24
  "author": "Typesmiths",
16
25
  "type": "module",
@@ -25,11 +34,13 @@
25
34
  "test": "echo \"Error: no test specified\" && exit 1"
26
35
  },
27
36
  "dependencies": {
28
- "@decohere/crucible": "0.0.1-dev.13",
29
- "@decohere/forge": "0.0.1-dev.13",
30
- "@decohere/mesh": "0.0.1-dev.13",
31
- "@decohere/nexus": "0.0.1-dev.13",
32
- "@decohere/scribe": "0.0.1-dev.13",
37
+ "@decohere/core": "0.0.1-dev.130",
38
+ "@decohere/crucible": "0.0.1-dev.130",
39
+ "@decohere/devkit": "0.0.1-dev.130",
40
+ "@decohere/forge": "0.0.1-dev.130",
41
+ "@decohere/scribe": "0.0.1-dev.130",
42
+ "@decohere/workspace": "0.0.1-dev.130",
43
+ "commander": "^13.1.0",
33
44
  "rxjs": "^7.8.2"
34
45
  },
35
46
  "devDependencies": {}
@@ -0,0 +1,18 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ export type DeferredScriptStart = {
3
+ readonly label: string;
4
+ readonly cwd: string;
5
+ readonly script: string;
6
+ /** Workspace root used to detect pnpm/npm/yarn. */
7
+ readonly workspaceRoot: string;
8
+ };
9
+ /**
10
+ * Nixpacks-style child processes for foreign packages in a flux serve slice.
11
+ * Killed when the Flux container disposes (SIGINT → RootModule teardown).
12
+ */
13
+ export declare class DeferredScriptRunner implements AsyncDisposable {
14
+ private readonly logger;
15
+ private readonly children;
16
+ start(options: DeferredScriptStart): ChildProcess;
17
+ [Symbol.asyncDispose](): Promise<void>;
18
+ }
@@ -1,5 +1,5 @@
1
- import type { Actions } from "@decohere/nexus/cli";
2
- import { type CommandHandler, type CommandResponse, type CommandType, DecohereCommand } from "@decohere/nexus/cli";
1
+ import type { Actions } from "@decohere/devkit/cli";
2
+ import { type CommandHandler, type CommandResponse, type CommandType, DecohereCommand } from "@decohere/devkit/cli";
3
3
  export declare class FluxCommand extends DecohereCommand {
4
4
  constructor(commands: Record<string, CommandType>, handler: CommandHandler);
5
5
  run(_actions: Actions, _options: never): Promise<CommandResponse>;
@@ -0,0 +1,43 @@
1
+ import type { Workspace } from "@decohere/workspace";
2
+ import { HotReloadServer } from "./hot-reload-server.js";
3
+ export type FluxRunOptions = {
4
+ /** Workspace configuration name. Defaults to `debug` (`--configuration`). */
5
+ configuration?: string;
6
+ /** Arguments forwarded to the entry (bin-style `process.argv.slice(2)`). */
7
+ args?: string[];
8
+ /**
9
+ * Pre-built Workspace instead of `WorkspaceFactory`.
10
+ * Useful in tests to skip filesystem discovery.
11
+ */
12
+ workspace?: Workspace;
13
+ };
14
+ export type FluxServeOptions = {
15
+ /** Workspace configuration name. Defaults to `debug` (`--configuration`). */
16
+ configuration?: string;
17
+ /** Name of the project to serve. Defaults to the first project in the workspace. */
18
+ project?: string;
19
+ /**
20
+ * Multiple Flux app package names to boot in one process (directory slice).
21
+ * When set, takes precedence over {@link project}.
22
+ */
23
+ projects?: readonly string[];
24
+ /**
25
+ * Pre-built Workspace instead of `WorkspaceFactory`.
26
+ * Useful in tests to skip filesystem discovery.
27
+ */
28
+ workspace?: Workspace;
29
+ /**
30
+ * Quiet window in milliseconds after the last affecting save before a reboot.
31
+ * Raise this for agent save trains.
32
+ */
33
+ rebootDebounceMs?: number;
34
+ };
35
+ declare function run(entry: string, options?: FluxRunOptions): Promise<unknown>;
36
+ declare function serve(options?: FluxServeOptions): Promise<HotReloadServer>;
37
+ declare function dispose(): void;
38
+ export declare const flux: {
39
+ run: typeof run;
40
+ serve: typeof serve;
41
+ dispose: typeof dispose;
42
+ };
43
+ export {};
@@ -1,14 +1,16 @@
1
- import type { WorkspaceFactoryOptions } from "@decohere/nexus/cli";
2
- import { ModuleManifest } from "@decohere/nexus/cli";
3
- import type { ContainerBuilder } from "@decohere/nexus/composition";
1
+ import type { WorkspaceFactoryOptions } from "@decohere/devkit/cli";
2
+ import { ModuleManifest } from "@decohere/devkit/cli";
3
+ import type { ContainerBuilder } from "@decohere/core/composition";
4
4
  import { FluxCommand } from "./flux.command.js";
5
+ import { RunAction } from "./run.action.js";
5
6
  import { ServeAction } from "./serve.action.js";
6
7
  export declare class FluxModule extends ModuleManifest {
7
8
  readonly id = "flux";
8
9
  readonly command: typeof FluxCommand;
9
10
  readonly actions: {
10
11
  serve: typeof ServeAction;
12
+ run: typeof RunAction;
11
13
  };
12
14
  registerServices(builder: ContainerBuilder): void;
13
- createWorkspace(options: WorkspaceFactoryOptions): Promise<import("@decohere/mesh").Workspace>;
15
+ createWorkspace(options: WorkspaceFactoryOptions): Promise<import("@decohere/workspace").Workspace>;
14
16
  }