@decohere/flux 0.0.1-dev.104 → 0.0.1-dev.106
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 +2 -2
- package/commonjs/bench/load.bench.js +145 -0
- package/commonjs/src/rewrite-serve-specifier.js +24 -9
- package/commonjs/src/source-module-loader.js +30 -10
- package/es6/bench/load.bench.js +136 -0
- package/es6/src/rewrite-serve-specifier.js +22 -11
- package/es6/src/source-module-loader.js +30 -10
- package/package.json +6 -6
- package/types/bench/load.bench.d.ts +1 -0
- package/types/src/rewrite-serve-specifier.d.ts +3 -2
- package/types/src/source-module-loader.d.ts +9 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
In-process TypeScript load for Node.js ESM apps — either one-shot (`flux run`) or hot-reload serve (`flux serve`).
|
|
4
4
|
|
|
5
|
-
Use Flux when you want to run source `.ts` directly. Flux transpiles with the **Forge** SWC compiler
|
|
5
|
+
Use Flux when you want to run source `.ts` directly. Flux transpiles with the **Forge** SWC compiler and maps imports to source with path aliases Mesh builds dynamically from the workspace (`package.json` `name` → source `main`). Do not use Flux as a browser bundler, a worker-thread HMR, or a production process manager.
|
|
6
6
|
|
|
7
7
|
Point Flux at a tree of TypeScript packages (`package.json` at each project root). [Mesh](../mesh/README.md) is how that tree is deduced — you do not run Flux against Mesh. Layout and glob overrides: [Mesh — workspace layout](../mesh/README.md#workspace-layout).
|
|
8
8
|
|
|
@@ -214,7 +214,7 @@ Only source files (`.ts`) that a served entry imports trigger a reboot.
|
|
|
214
214
|
|
|
215
215
|
**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
216
|
|
|
217
|
-
Flux uses **Forge’s compiler
|
|
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`, #513). 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
218
|
|
|
219
219
|
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
220
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cold-load benchmark for flux: how long the load hook takes to compile and import a workspace
|
|
3
|
+
* whose files import each other, the way an app's do (#513).
|
|
4
|
+
*
|
|
5
|
+
* pnpm bench:flux # 8 libraries × 40 files, 10 processes
|
|
6
|
+
* BENCH_LIBS=4 BENCH_FILES=100 BENCH_RUNS=5 pnpm bench:flux
|
|
7
|
+
*
|
|
8
|
+
* Generates the workspace in a temp directory: each library re-exports a `models/` directory
|
|
9
|
+
* through its index (a directory import), each model imports the previous one (relative, with
|
|
10
|
+
* `.js`) and the previous library by package name, and has a decorated class. Each process
|
|
11
|
+
* imports the last library twice under different URL versions with a fresh loader cache, so
|
|
12
|
+
* both loads transpile every file: the first is a real cold start, the second runs with Node
|
|
13
|
+
* and SWC warmed up. Prints the median of each and the time per file.
|
|
14
|
+
*/ "use strict";
|
|
15
|
+
Object.defineProperty(exports, "__esModule", {
|
|
16
|
+
value: true
|
|
17
|
+
});
|
|
18
|
+
const _nodechild_process = require("node:child_process");
|
|
19
|
+
const _nodefs = require("node:fs");
|
|
20
|
+
const _nodeos = require("node:os");
|
|
21
|
+
const _nodepath = /*#__PURE__*/ _interop_require_default(require("node:path"));
|
|
22
|
+
const _nodeurl = require("node:url");
|
|
23
|
+
const _forge = require("@decohere/forge");
|
|
24
|
+
const _sourcemoduleloader = require("../src/source-module-loader.js");
|
|
25
|
+
function _interop_require_default(obj) {
|
|
26
|
+
return obj && obj.__esModule ? obj : {
|
|
27
|
+
default: obj
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const libs = Number(process.env.BENCH_LIBS ?? 8);
|
|
31
|
+
const filesPerLib = Number(process.env.BENCH_FILES ?? 40);
|
|
32
|
+
const runs = Number(process.env.BENCH_RUNS ?? 10);
|
|
33
|
+
function write(file, content) {
|
|
34
|
+
(0, _nodefs.mkdirSync)(_nodepath.default.dirname(file), {
|
|
35
|
+
recursive: true
|
|
36
|
+
});
|
|
37
|
+
(0, _nodefs.writeFileSync)(file, content);
|
|
38
|
+
}
|
|
39
|
+
function generate(root) {
|
|
40
|
+
write(_nodepath.default.join(root, "package.json"), JSON.stringify({
|
|
41
|
+
name: "flux-bench",
|
|
42
|
+
type: "module"
|
|
43
|
+
}));
|
|
44
|
+
write(_nodepath.default.join(root, "tsconfig.json"), JSON.stringify({
|
|
45
|
+
compilerOptions: {
|
|
46
|
+
experimentalDecorators: true
|
|
47
|
+
}
|
|
48
|
+
}));
|
|
49
|
+
for(let lib = 0; lib < libs; lib++){
|
|
50
|
+
const dir = _nodepath.default.join(root, "projects", `lib-${lib}`);
|
|
51
|
+
write(_nodepath.default.join(dir, "package.json"), JSON.stringify({
|
|
52
|
+
name: `@bench/lib-${lib}`,
|
|
53
|
+
type: "module",
|
|
54
|
+
main: "src/index.ts"
|
|
55
|
+
}));
|
|
56
|
+
const previousLib = lib > 0 ? `@bench/lib-${lib - 1}` : null;
|
|
57
|
+
write(_nodepath.default.join(dir, "src/index.ts"), `export * from "./models";\n`);
|
|
58
|
+
const exports1 = [];
|
|
59
|
+
for(let file = 0; file < filesPerLib; file++){
|
|
60
|
+
const name = `Model${lib}x${file}`;
|
|
61
|
+
const previous = file > 0 ? `Model${lib}x${file - 1}` : null;
|
|
62
|
+
write(_nodepath.default.join(dir, "src/models", `model-${file}.ts`), [
|
|
63
|
+
previous ? `import { ${previous} } from "./model-${file - 1}.js";` : "",
|
|
64
|
+
previousLib && file === 0 ? `import { Model${lib - 1}x0 } from "${previousLib}";` : "",
|
|
65
|
+
`const tag = (_target: unknown) => {};`,
|
|
66
|
+
`@tag`,
|
|
67
|
+
`export class ${name} {`,
|
|
68
|
+
`\tpublic readonly id: number = ${file};`,
|
|
69
|
+
`\tpublic constructor(public readonly label: string = "${name}") {}`,
|
|
70
|
+
`\tpublic describe(): string { return \`\${this.label}:\${this.id}\`; }`,
|
|
71
|
+
previous ? `\tpublic previous = ${previous};` : "",
|
|
72
|
+
previousLib && file === 0 ? `\tpublic upstream = Model${lib - 1}x0;` : "",
|
|
73
|
+
`}`
|
|
74
|
+
].filter(Boolean).join("\n"));
|
|
75
|
+
exports1.push(`export * from "./model-${file}.js";`);
|
|
76
|
+
}
|
|
77
|
+
write(_nodepath.default.join(dir, "src/models/index.ts"), `${exports1.join("\n")}\n`);
|
|
78
|
+
}
|
|
79
|
+
return _nodepath.default.join(root, "projects", `lib-${libs - 1}`, "src/index.ts");
|
|
80
|
+
}
|
|
81
|
+
/** One sample, in this process: the first load (cold) and a second one (warm Node and SWC). */ async function sample() {
|
|
82
|
+
const root = (0, _nodefs.mkdtempSync)(_nodepath.default.join((0, _nodeos.tmpdir)(), "flux-bench-"));
|
|
83
|
+
try {
|
|
84
|
+
const entry = generate(root);
|
|
85
|
+
const workspace = await (0, _forge.createWorkspace)({
|
|
86
|
+
rootDir: root
|
|
87
|
+
});
|
|
88
|
+
const loader = new _sourcemoduleloader.SourceModuleLoader();
|
|
89
|
+
const load = async (version)=>{
|
|
90
|
+
const started = performance.now();
|
|
91
|
+
await loader.configure({
|
|
92
|
+
buildConfig: "debug",
|
|
93
|
+
workspace,
|
|
94
|
+
versionFileUrl: (fileUrl)=>`${fileUrl}?v=${version}`
|
|
95
|
+
});
|
|
96
|
+
loader.registerHooks();
|
|
97
|
+
await loader.importEntry(entry);
|
|
98
|
+
return performance.now() - started;
|
|
99
|
+
};
|
|
100
|
+
const cold = await load(1);
|
|
101
|
+
const warm = await load(2);
|
|
102
|
+
return {
|
|
103
|
+
files: [
|
|
104
|
+
...loader.sourceFilePaths()
|
|
105
|
+
].length,
|
|
106
|
+
cold,
|
|
107
|
+
warm
|
|
108
|
+
};
|
|
109
|
+
} finally{
|
|
110
|
+
(0, _nodefs.rmSync)(root, {
|
|
111
|
+
recursive: true,
|
|
112
|
+
force: true
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const median = (values)=>[
|
|
117
|
+
...values
|
|
118
|
+
].sort((a, b)=>a - b)[Math.floor(values.length / 2)] ?? 0;
|
|
119
|
+
if (process.argv.includes("--sample")) {
|
|
120
|
+
console.log(JSON.stringify(await sample()));
|
|
121
|
+
} else {
|
|
122
|
+
// Each sample runs in a fresh process: Node never unloads modules, so loads in one process
|
|
123
|
+
// slow down as the heap grows and the numbers drift.
|
|
124
|
+
const results = Array.from({
|
|
125
|
+
length: runs
|
|
126
|
+
}, ()=>{
|
|
127
|
+
const child = (0, _nodechild_process.spawnSync)(process.execPath, [
|
|
128
|
+
...process.execArgv,
|
|
129
|
+
(0, _nodeurl.fileURLToPath)(require("url").pathToFileURL(__filename).toString()),
|
|
130
|
+
"--sample"
|
|
131
|
+
], {
|
|
132
|
+
encoding: "utf8",
|
|
133
|
+
env: process.env
|
|
134
|
+
});
|
|
135
|
+
if (child.status !== 0) throw new Error(`sample failed:\n${child.stderr}`);
|
|
136
|
+
const last = child.stdout.trim().split("\n").at(-1) ?? "";
|
|
137
|
+
return JSON.parse(last);
|
|
138
|
+
});
|
|
139
|
+
const files = results[0]?.files ?? 0;
|
|
140
|
+
const report = (label, values)=>{
|
|
141
|
+
const ms = median(values);
|
|
142
|
+
return `${label} ${ms.toFixed(1)} ms (${(ms / files * 1000).toFixed(0)} µs/file)`;
|
|
143
|
+
};
|
|
144
|
+
console.log(`flux load, ${files} files, median of ${runs} processes: ` + `${report("first", results.map((r)=>r.cold))}, ` + `${report("second", results.map((r)=>r.warm))}`);
|
|
145
|
+
}
|
|
@@ -8,8 +8,14 @@ Object.defineProperty(exports, "rewriteServeSpecifier", {
|
|
|
8
8
|
return rewriteServeSpecifier;
|
|
9
9
|
}
|
|
10
10
|
});
|
|
11
|
+
const _nodepath = /*#__PURE__*/ _interop_require_default(require("node:path"));
|
|
11
12
|
const _nodeurl = require("node:url");
|
|
12
13
|
const _mesh = require("@decohere/mesh");
|
|
14
|
+
function _interop_require_default(obj) {
|
|
15
|
+
return obj && obj.__esModule ? obj : {
|
|
16
|
+
default: obj
|
|
17
|
+
};
|
|
18
|
+
}
|
|
13
19
|
function rewriteServeSpecifier(options) {
|
|
14
20
|
const { specifier, parentUrl, isWatchedTs, resolveBare, versionFileUrl } = options;
|
|
15
21
|
const relativeTs = resolveRelativeWatchedTs(specifier, parentUrl, isWatchedTs);
|
|
@@ -17,11 +23,26 @@ function rewriteServeSpecifier(options) {
|
|
|
17
23
|
return versionFileUrl((0, _nodeurl.pathToFileURL)(relativeTs).toString());
|
|
18
24
|
}
|
|
19
25
|
const bareTarget = resolveBare(specifier);
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
const bareTs = bareTarget == null ? null : watchedTs(bareTarget, isWatchedTs);
|
|
27
|
+
if (bareTs != null) {
|
|
28
|
+
return versionFileUrl((0, _nodeurl.pathToFileURL)(bareTs).toString());
|
|
22
29
|
}
|
|
23
30
|
return null;
|
|
24
31
|
}
|
|
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.
|
|
35
|
+
*/ function watchedTs(filePath, isWatchedTs) {
|
|
36
|
+
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`).
|
|
40
|
+
const candidates = [
|
|
41
|
+
`${stem}.ts`,
|
|
42
|
+
_nodepath.default.join(stem, "index.ts")
|
|
43
|
+
];
|
|
44
|
+
return candidates.find((candidate)=>isWatchedTs(candidate)) ?? null;
|
|
45
|
+
}
|
|
25
46
|
function resolveRelativeWatchedTs(specifier, parentUrl, isWatchedTs) {
|
|
26
47
|
if (_mesh.packageAliases.isBareSpecifier(specifier)) return null;
|
|
27
48
|
if (!(specifier.startsWith(".") || specifier.startsWith("/"))) return null;
|
|
@@ -34,11 +55,5 @@ function resolveRelativeWatchedTs(specifier, parentUrl, isWatchedTs) {
|
|
|
34
55
|
return null;
|
|
35
56
|
}
|
|
36
57
|
if (resolved.protocol !== "file:") return null;
|
|
37
|
-
|
|
38
|
-
if (filePath.endsWith(".js")) {
|
|
39
|
-
filePath = `${filePath.slice(0, -3)}.ts`;
|
|
40
|
-
} else if (!filePath.endsWith(".ts")) {
|
|
41
|
-
filePath = `${filePath}.ts`;
|
|
42
|
-
}
|
|
43
|
-
return isWatchedTs(filePath) ? filePath : null;
|
|
58
|
+
return watchedTs((0, _nodeurl.fileURLToPath)(resolved), isWatchedTs);
|
|
44
59
|
}
|
|
@@ -78,6 +78,7 @@ class SourceModuleLoader {
|
|
|
78
78
|
buildConfig = "debug";
|
|
79
79
|
fileInfoMap = new Map();
|
|
80
80
|
transpileCache = new Map();
|
|
81
|
+
/** SWC options per project, built on first use and kept until the next `configure()`. */ swcConfigs = new Map();
|
|
81
82
|
aliasPaths = {};
|
|
82
83
|
aliasBaseUrl = _nodepath.default.resolve(".");
|
|
83
84
|
versionFileUrl = (fileUrl)=>fileUrl;
|
|
@@ -102,6 +103,7 @@ class SourceModuleLoader {
|
|
|
102
103
|
async configure(options) {
|
|
103
104
|
this.disposed = false;
|
|
104
105
|
this.transpileCache.clear();
|
|
106
|
+
this.swcConfigs.clear();
|
|
105
107
|
this.buildConfig = options.buildConfig;
|
|
106
108
|
this.workspace = options.workspace ?? await (0, _forge.createWorkspace)();
|
|
107
109
|
this.versionFileUrl = options.versionFileUrl ?? ((fileUrl)=>fileUrl);
|
|
@@ -161,6 +163,7 @@ class SourceModuleLoader {
|
|
|
161
163
|
dispose() {
|
|
162
164
|
this.disposed = true;
|
|
163
165
|
this.transpileCache.clear();
|
|
166
|
+
this.swcConfigs.clear();
|
|
164
167
|
this.fileInfoMap.clear();
|
|
165
168
|
this.workspace?.[Symbol.dispose]();
|
|
166
169
|
this.workspace = undefined;
|
|
@@ -189,19 +192,10 @@ class SourceModuleLoader {
|
|
|
189
192
|
if (cached != null) return {
|
|
190
193
|
source: cached
|
|
191
194
|
};
|
|
192
|
-
const { project } = fileInfo;
|
|
193
|
-
const tsConfig = project.getTsConfigSync();
|
|
194
|
-
const userConfig = project.getSwcConfigSync();
|
|
195
195
|
const file = new _io.FileEx({
|
|
196
196
|
fullPath: cleanPath
|
|
197
197
|
});
|
|
198
|
-
const response = this.transpiler.transformFileSync(file, _forge.BuildTargets.es6, {
|
|
199
|
-
tsConfig,
|
|
200
|
-
aliasPaths: true,
|
|
201
|
-
userConfig,
|
|
202
|
-
workspace: this.workspace,
|
|
203
|
-
projects: this.workspace?.getProjects(this.buildConfig)
|
|
204
|
-
});
|
|
198
|
+
const response = this.transpiler.transformFileSync(file, _forge.BuildTargets.es6, {}, this.swcConfigFor(fileInfo.project));
|
|
205
199
|
if (response.outcome !== "success") {
|
|
206
200
|
this.logger.error(`Failed to transpile ${cleanPath}`, response);
|
|
207
201
|
throw new Error(`Failed to transpile ${cleanPath}`);
|
|
@@ -211,6 +205,32 @@ class SourceModuleLoader {
|
|
|
211
205
|
source: response.code
|
|
212
206
|
};
|
|
213
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* The project's SWC options, built once: rebuilding them for every file was about a quarter
|
|
210
|
+
* of load time (#513). Import specifiers are left as written, without `jsc.paths` or
|
|
211
|
+
* `resolveFully`: the resolve hook maps them to source files (aliases, `.js` to `.ts`,
|
|
212
|
+
* directory indexes), so SWC does not resolve them a second time against the disk.
|
|
213
|
+
*/ swcConfigFor(project) {
|
|
214
|
+
const cached = this.swcConfigs.get(project);
|
|
215
|
+
if (cached != null) return cached;
|
|
216
|
+
const base = this.transpiler.createBaseConfig(_forge.BuildTargets.es6, {
|
|
217
|
+
tsConfig: project.getTsConfigSync(),
|
|
218
|
+
userConfig: project.getSwcConfigSync(),
|
|
219
|
+
aliasPaths: false,
|
|
220
|
+
workspace: this.workspace
|
|
221
|
+
});
|
|
222
|
+
// `module` is the ES module config (`type: "es6"` above); the spread loses the narrowing.
|
|
223
|
+
const module = {
|
|
224
|
+
...base.module,
|
|
225
|
+
resolveFully: false
|
|
226
|
+
};
|
|
227
|
+
const config = {
|
|
228
|
+
...base,
|
|
229
|
+
module
|
|
230
|
+
};
|
|
231
|
+
this.swcConfigs.set(project, config);
|
|
232
|
+
return config;
|
|
233
|
+
}
|
|
214
234
|
}
|
|
215
235
|
SourceModuleLoader = _ts_decorate([
|
|
216
236
|
(0, _decorators.injectable)()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cold-load benchmark for flux: how long the load hook takes to compile and import a workspace
|
|
3
|
+
* whose files import each other, the way an app's do (#513).
|
|
4
|
+
*
|
|
5
|
+
* pnpm bench:flux # 8 libraries × 40 files, 10 processes
|
|
6
|
+
* BENCH_LIBS=4 BENCH_FILES=100 BENCH_RUNS=5 pnpm bench:flux
|
|
7
|
+
*
|
|
8
|
+
* Generates the workspace in a temp directory: each library re-exports a `models/` directory
|
|
9
|
+
* through its index (a directory import), each model imports the previous one (relative, with
|
|
10
|
+
* `.js`) and the previous library by package name, and has a decorated class. Each process
|
|
11
|
+
* imports the last library twice under different URL versions with a fresh loader cache, so
|
|
12
|
+
* both loads transpile every file: the first is a real cold start, the second runs with Node
|
|
13
|
+
* and SWC warmed up. Prints the median of each and the time per file.
|
|
14
|
+
*/ import { spawnSync } from "node:child_process";
|
|
15
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { createWorkspace } from "@decohere/forge";
|
|
20
|
+
import { SourceModuleLoader } from "../src/source-module-loader.js";
|
|
21
|
+
const libs = Number(process.env.BENCH_LIBS ?? 8);
|
|
22
|
+
const filesPerLib = Number(process.env.BENCH_FILES ?? 40);
|
|
23
|
+
const runs = Number(process.env.BENCH_RUNS ?? 10);
|
|
24
|
+
function write(file, content) {
|
|
25
|
+
mkdirSync(path.dirname(file), {
|
|
26
|
+
recursive: true
|
|
27
|
+
});
|
|
28
|
+
writeFileSync(file, content);
|
|
29
|
+
}
|
|
30
|
+
function generate(root) {
|
|
31
|
+
write(path.join(root, "package.json"), JSON.stringify({
|
|
32
|
+
name: "flux-bench",
|
|
33
|
+
type: "module"
|
|
34
|
+
}));
|
|
35
|
+
write(path.join(root, "tsconfig.json"), JSON.stringify({
|
|
36
|
+
compilerOptions: {
|
|
37
|
+
experimentalDecorators: true
|
|
38
|
+
}
|
|
39
|
+
}));
|
|
40
|
+
for(let lib = 0; lib < libs; lib++){
|
|
41
|
+
const dir = path.join(root, "projects", `lib-${lib}`);
|
|
42
|
+
write(path.join(dir, "package.json"), JSON.stringify({
|
|
43
|
+
name: `@bench/lib-${lib}`,
|
|
44
|
+
type: "module",
|
|
45
|
+
main: "src/index.ts"
|
|
46
|
+
}));
|
|
47
|
+
const previousLib = lib > 0 ? `@bench/lib-${lib - 1}` : null;
|
|
48
|
+
write(path.join(dir, "src/index.ts"), `export * from "./models";\n`);
|
|
49
|
+
const exports = [];
|
|
50
|
+
for(let file = 0; file < filesPerLib; file++){
|
|
51
|
+
const name = `Model${lib}x${file}`;
|
|
52
|
+
const previous = file > 0 ? `Model${lib}x${file - 1}` : null;
|
|
53
|
+
write(path.join(dir, "src/models", `model-${file}.ts`), [
|
|
54
|
+
previous ? `import { ${previous} } from "./model-${file - 1}.js";` : "",
|
|
55
|
+
previousLib && file === 0 ? `import { Model${lib - 1}x0 } from "${previousLib}";` : "",
|
|
56
|
+
`const tag = (_target: unknown) => {};`,
|
|
57
|
+
`@tag`,
|
|
58
|
+
`export class ${name} {`,
|
|
59
|
+
`\tpublic readonly id: number = ${file};`,
|
|
60
|
+
`\tpublic constructor(public readonly label: string = "${name}") {}`,
|
|
61
|
+
`\tpublic describe(): string { return \`\${this.label}:\${this.id}\`; }`,
|
|
62
|
+
previous ? `\tpublic previous = ${previous};` : "",
|
|
63
|
+
previousLib && file === 0 ? `\tpublic upstream = Model${lib - 1}x0;` : "",
|
|
64
|
+
`}`
|
|
65
|
+
].filter(Boolean).join("\n"));
|
|
66
|
+
exports.push(`export * from "./model-${file}.js";`);
|
|
67
|
+
}
|
|
68
|
+
write(path.join(dir, "src/models/index.ts"), `${exports.join("\n")}\n`);
|
|
69
|
+
}
|
|
70
|
+
return path.join(root, "projects", `lib-${libs - 1}`, "src/index.ts");
|
|
71
|
+
}
|
|
72
|
+
/** One sample, in this process: the first load (cold) and a second one (warm Node and SWC). */ async function sample() {
|
|
73
|
+
const root = mkdtempSync(path.join(tmpdir(), "flux-bench-"));
|
|
74
|
+
try {
|
|
75
|
+
const entry = generate(root);
|
|
76
|
+
const workspace = await createWorkspace({
|
|
77
|
+
rootDir: root
|
|
78
|
+
});
|
|
79
|
+
const loader = new SourceModuleLoader();
|
|
80
|
+
const load = async (version)=>{
|
|
81
|
+
const started = performance.now();
|
|
82
|
+
await loader.configure({
|
|
83
|
+
buildConfig: "debug",
|
|
84
|
+
workspace,
|
|
85
|
+
versionFileUrl: (fileUrl)=>`${fileUrl}?v=${version}`
|
|
86
|
+
});
|
|
87
|
+
loader.registerHooks();
|
|
88
|
+
await loader.importEntry(entry);
|
|
89
|
+
return performance.now() - started;
|
|
90
|
+
};
|
|
91
|
+
const cold = await load(1);
|
|
92
|
+
const warm = await load(2);
|
|
93
|
+
return {
|
|
94
|
+
files: [
|
|
95
|
+
...loader.sourceFilePaths()
|
|
96
|
+
].length,
|
|
97
|
+
cold,
|
|
98
|
+
warm
|
|
99
|
+
};
|
|
100
|
+
} finally{
|
|
101
|
+
rmSync(root, {
|
|
102
|
+
recursive: true,
|
|
103
|
+
force: true
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const median = (values)=>[
|
|
108
|
+
...values
|
|
109
|
+
].sort((a, b)=>a - b)[Math.floor(values.length / 2)] ?? 0;
|
|
110
|
+
if (process.argv.includes("--sample")) {
|
|
111
|
+
console.log(JSON.stringify(await sample()));
|
|
112
|
+
} else {
|
|
113
|
+
// Each sample runs in a fresh process: Node never unloads modules, so loads in one process
|
|
114
|
+
// slow down as the heap grows and the numbers drift.
|
|
115
|
+
const results = Array.from({
|
|
116
|
+
length: runs
|
|
117
|
+
}, ()=>{
|
|
118
|
+
const child = spawnSync(process.execPath, [
|
|
119
|
+
...process.execArgv,
|
|
120
|
+
fileURLToPath(import.meta.url),
|
|
121
|
+
"--sample"
|
|
122
|
+
], {
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
env: process.env
|
|
125
|
+
});
|
|
126
|
+
if (child.status !== 0) throw new Error(`sample failed:\n${child.stderr}`);
|
|
127
|
+
const last = child.stdout.trim().split("\n").at(-1) ?? "";
|
|
128
|
+
return JSON.parse(last);
|
|
129
|
+
});
|
|
130
|
+
const files = results[0]?.files ?? 0;
|
|
131
|
+
const report = (label, values)=>{
|
|
132
|
+
const ms = median(values);
|
|
133
|
+
return `${label} ${ms.toFixed(1)} ms (${(ms / files * 1000).toFixed(0)} µs/file)`;
|
|
134
|
+
};
|
|
135
|
+
console.log(`flux load, ${files} files, median of ${runs} processes: ` + `${report("first", results.map((r)=>r.cold))}, ` + `${report("second", results.map((r)=>r.warm))}`);
|
|
136
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
2
3
|
import { packageAliases } from "@decohere/mesh";
|
|
3
4
|
/**
|
|
4
5
|
* Map a resolve-hook specifier to a versioned watched `.ts` file URL.
|
|
5
|
-
* Covers relative `.js` / `.ts` / extensionless imports
|
|
6
|
-
*
|
|
6
|
+
* Covers relative `.js` / `.ts` / extensionless imports, directory imports (`./models` is
|
|
7
|
+
* `./models/index.ts`), and bare workspace package names and subpaths. Flux compiles without
|
|
8
|
+
* SWC's own path resolution, so this is the only place imports are mapped to sources (#513).
|
|
7
9
|
*/ export function rewriteServeSpecifier(options) {
|
|
8
10
|
const { specifier, parentUrl, isWatchedTs, resolveBare, versionFileUrl } = options;
|
|
9
11
|
const relativeTs = resolveRelativeWatchedTs(specifier, parentUrl, isWatchedTs);
|
|
@@ -11,11 +13,26 @@ import { packageAliases } from "@decohere/mesh";
|
|
|
11
13
|
return versionFileUrl(pathToFileURL(relativeTs).toString());
|
|
12
14
|
}
|
|
13
15
|
const bareTarget = resolveBare(specifier);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
const bareTs = bareTarget == null ? null : watchedTs(bareTarget, isWatchedTs);
|
|
17
|
+
if (bareTs != null) {
|
|
18
|
+
return versionFileUrl(pathToFileURL(bareTs).toString());
|
|
16
19
|
}
|
|
17
20
|
return null;
|
|
18
21
|
}
|
|
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.
|
|
25
|
+
*/ function watchedTs(filePath, isWatchedTs) {
|
|
26
|
+
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`).
|
|
30
|
+
const candidates = [
|
|
31
|
+
`${stem}.ts`,
|
|
32
|
+
path.join(stem, "index.ts")
|
|
33
|
+
];
|
|
34
|
+
return candidates.find((candidate)=>isWatchedTs(candidate)) ?? null;
|
|
35
|
+
}
|
|
19
36
|
function resolveRelativeWatchedTs(specifier, parentUrl, isWatchedTs) {
|
|
20
37
|
if (packageAliases.isBareSpecifier(specifier)) return null;
|
|
21
38
|
if (!(specifier.startsWith(".") || specifier.startsWith("/"))) return null;
|
|
@@ -28,11 +45,5 @@ function resolveRelativeWatchedTs(specifier, parentUrl, isWatchedTs) {
|
|
|
28
45
|
return null;
|
|
29
46
|
}
|
|
30
47
|
if (resolved.protocol !== "file:") return null;
|
|
31
|
-
|
|
32
|
-
if (filePath.endsWith(".js")) {
|
|
33
|
-
filePath = `${filePath.slice(0, -3)}.ts`;
|
|
34
|
-
} else if (!filePath.endsWith(".ts")) {
|
|
35
|
-
filePath = `${filePath}.ts`;
|
|
36
|
-
}
|
|
37
|
-
return isWatchedTs(filePath) ? filePath : null;
|
|
48
|
+
return watchedTs(fileURLToPath(resolved), isWatchedTs);
|
|
38
49
|
}
|
|
@@ -22,6 +22,7 @@ export class SourceModuleLoader {
|
|
|
22
22
|
buildConfig = "debug";
|
|
23
23
|
fileInfoMap = new Map();
|
|
24
24
|
transpileCache = new Map();
|
|
25
|
+
/** SWC options per project, built on first use and kept until the next `configure()`. */ swcConfigs = new Map();
|
|
25
26
|
aliasPaths = {};
|
|
26
27
|
aliasBaseUrl = path.resolve(".");
|
|
27
28
|
versionFileUrl = (fileUrl)=>fileUrl;
|
|
@@ -46,6 +47,7 @@ export class SourceModuleLoader {
|
|
|
46
47
|
async configure(options) {
|
|
47
48
|
this.disposed = false;
|
|
48
49
|
this.transpileCache.clear();
|
|
50
|
+
this.swcConfigs.clear();
|
|
49
51
|
this.buildConfig = options.buildConfig;
|
|
50
52
|
this.workspace = options.workspace ?? await createWorkspace();
|
|
51
53
|
this.versionFileUrl = options.versionFileUrl ?? ((fileUrl)=>fileUrl);
|
|
@@ -105,6 +107,7 @@ export class SourceModuleLoader {
|
|
|
105
107
|
dispose() {
|
|
106
108
|
this.disposed = true;
|
|
107
109
|
this.transpileCache.clear();
|
|
110
|
+
this.swcConfigs.clear();
|
|
108
111
|
this.fileInfoMap.clear();
|
|
109
112
|
this.workspace?.[Symbol.dispose]();
|
|
110
113
|
this.workspace = undefined;
|
|
@@ -133,19 +136,10 @@ export class SourceModuleLoader {
|
|
|
133
136
|
if (cached != null) return {
|
|
134
137
|
source: cached
|
|
135
138
|
};
|
|
136
|
-
const { project } = fileInfo;
|
|
137
|
-
const tsConfig = project.getTsConfigSync();
|
|
138
|
-
const userConfig = project.getSwcConfigSync();
|
|
139
139
|
const file = new FileEx({
|
|
140
140
|
fullPath: cleanPath
|
|
141
141
|
});
|
|
142
|
-
const response = this.transpiler.transformFileSync(file, BuildTargets.es6, {
|
|
143
|
-
tsConfig,
|
|
144
|
-
aliasPaths: true,
|
|
145
|
-
userConfig,
|
|
146
|
-
workspace: this.workspace,
|
|
147
|
-
projects: this.workspace?.getProjects(this.buildConfig)
|
|
148
|
-
});
|
|
142
|
+
const response = this.transpiler.transformFileSync(file, BuildTargets.es6, {}, this.swcConfigFor(fileInfo.project));
|
|
149
143
|
if (response.outcome !== "success") {
|
|
150
144
|
this.logger.error(`Failed to transpile ${cleanPath}`, response);
|
|
151
145
|
throw new Error(`Failed to transpile ${cleanPath}`);
|
|
@@ -155,6 +149,32 @@ export class SourceModuleLoader {
|
|
|
155
149
|
source: response.code
|
|
156
150
|
};
|
|
157
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* The project's SWC options, built once: rebuilding them for every file was about a quarter
|
|
154
|
+
* of load time (#513). Import specifiers are left as written, without `jsc.paths` or
|
|
155
|
+
* `resolveFully`: the resolve hook maps them to source files (aliases, `.js` to `.ts`,
|
|
156
|
+
* directory indexes), so SWC does not resolve them a second time against the disk.
|
|
157
|
+
*/ swcConfigFor(project) {
|
|
158
|
+
const cached = this.swcConfigs.get(project);
|
|
159
|
+
if (cached != null) return cached;
|
|
160
|
+
const base = this.transpiler.createBaseConfig(BuildTargets.es6, {
|
|
161
|
+
tsConfig: project.getTsConfigSync(),
|
|
162
|
+
userConfig: project.getSwcConfigSync(),
|
|
163
|
+
aliasPaths: false,
|
|
164
|
+
workspace: this.workspace
|
|
165
|
+
});
|
|
166
|
+
// `module` is the ES module config (`type: "es6"` above); the spread loses the narrowing.
|
|
167
|
+
const module = {
|
|
168
|
+
...base.module,
|
|
169
|
+
resolveFully: false
|
|
170
|
+
};
|
|
171
|
+
const config = {
|
|
172
|
+
...base,
|
|
173
|
+
module
|
|
174
|
+
};
|
|
175
|
+
this.swcConfigs.set(project, config);
|
|
176
|
+
return config;
|
|
177
|
+
}
|
|
158
178
|
}
|
|
159
179
|
SourceModuleLoader = _ts_decorate([
|
|
160
180
|
injectable()
|
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.106",
|
|
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.106",
|
|
29
|
+
"@decohere/forge": "0.0.1-dev.106",
|
|
30
|
+
"@decohere/mesh": "0.0.1-dev.106",
|
|
31
|
+
"@decohere/nexus": "0.0.1-dev.106",
|
|
32
|
+
"@decohere/scribe": "0.0.1-dev.106",
|
|
33
33
|
"commander": "^13.1.0",
|
|
34
34
|
"rxjs": "^7.8.2"
|
|
35
35
|
},
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -7,7 +7,8 @@ export type RewriteServeSpecifierOptions = {
|
|
|
7
7
|
};
|
|
8
8
|
/**
|
|
9
9
|
* Map a resolve-hook specifier to a versioned watched `.ts` file URL.
|
|
10
|
-
* Covers relative `.js` / `.ts` / extensionless imports
|
|
11
|
-
*
|
|
10
|
+
* Covers relative `.js` / `.ts` / extensionless imports, directory imports (`./models` is
|
|
11
|
+
* `./models/index.ts`), and bare workspace package names and subpaths. Flux compiles without
|
|
12
|
+
* SWC's own path resolution, so this is the only place imports are mapped to sources (#513).
|
|
12
13
|
*/
|
|
13
14
|
export declare function rewriteServeSpecifier(options: RewriteServeSpecifierOptions): string | null;
|
|
@@ -30,6 +30,8 @@ export declare class SourceModuleLoader {
|
|
|
30
30
|
private buildConfig;
|
|
31
31
|
private fileInfoMap;
|
|
32
32
|
private transpileCache;
|
|
33
|
+
/** SWC options per project, built on first use and kept until the next `configure()`. */
|
|
34
|
+
private swcConfigs;
|
|
33
35
|
private aliasPaths;
|
|
34
36
|
private aliasBaseUrl;
|
|
35
37
|
private versionFileUrl;
|
|
@@ -46,4 +48,11 @@ export declare class SourceModuleLoader {
|
|
|
46
48
|
dispose(): void;
|
|
47
49
|
private buildFileInfoMap;
|
|
48
50
|
private loadTs;
|
|
51
|
+
/**
|
|
52
|
+
* The project's SWC options, built once: rebuilding them for every file was about a quarter
|
|
53
|
+
* of load time (#513). Import specifiers are left as written, without `jsc.paths` or
|
|
54
|
+
* `resolveFully`: the resolve hook maps them to source files (aliases, `.js` to `.ts`,
|
|
55
|
+
* directory indexes), so SWC does not resolve them a second time against the disk.
|
|
56
|
+
*/
|
|
57
|
+
private swcConfigFor;
|
|
49
58
|
}
|