@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 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; that compiler uses 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.
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**. Forge debug builds path aliases from Mesh (`name` → source `main`) and rewrites `import "@scope/pkg"` to that `.ts` file. 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`).
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
- if (bareTarget != null && isWatchedTs(bareTarget)) {
21
- return versionFileUrl((0, _nodeurl.pathToFileURL)(bareTarget).toString());
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
- let filePath = (0, _nodeurl.fileURLToPath)(resolved);
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 and bare workspace
6
- * package names (dynamic `import()` never goes through SWC path rewrite).
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
- if (bareTarget != null && isWatchedTs(bareTarget)) {
15
- return versionFileUrl(pathToFileURL(bareTarget).toString());
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
- let filePath = fileURLToPath(resolved);
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.104",
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.104",
29
- "@decohere/forge": "0.0.1-dev.104",
30
- "@decohere/mesh": "0.0.1-dev.104",
31
- "@decohere/nexus": "0.0.1-dev.104",
32
- "@decohere/scribe": "0.0.1-dev.104",
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 and bare workspace
11
- * package names (dynamic `import()` never goes through SWC path rewrite).
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
  }