ttsc 0.14.2 → 0.15.1

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 (44) hide show
  1. package/driver/emit_plugin.go +165 -0
  2. package/driver/plugins.go +37 -0
  3. package/go.mod +0 -3
  4. package/go.sum +0 -4
  5. package/lib/compiler/internal/resolveEmittedJavaScript.js +25 -4
  6. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  7. package/lib/launcher/internal/prepareExecution.d.ts +4 -1
  8. package/lib/launcher/internal/prepareExecution.js +13 -17
  9. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  10. package/lib/launcher/internal/registerRuntimeHooks.js +43 -8
  11. package/lib/launcher/internal/registerRuntimeHooks.js.map +1 -1
  12. package/lib/launcher/internal/runTtsx.js +46 -386
  13. package/lib/launcher/internal/runTtsx.js.map +1 -1
  14. package/lib/launcher/internal/runtimeHookPreload.d.ts +1 -0
  15. package/lib/launcher/internal/runtimeHookPreload.js +16 -0
  16. package/lib/launcher/internal/runtimeHookPreload.js.map +1 -0
  17. package/lib/launcher/internal/runtimeHooks.d.ts +16 -38
  18. package/lib/launcher/internal/runtimeHooks.js +672 -119
  19. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  20. package/lib/plugin/internal/buildSourcePlugin.js +120 -16
  21. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  22. package/lib/plugin/internal/loadProjectPlugins.js +15 -4
  23. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  24. package/package.json +8 -8
  25. package/shim/ast/parent.go +31 -0
  26. package/shim/compiler/emitassemble.go +30 -0
  27. package/shim/compiler/emithook.go +22 -0
  28. package/shim/compiler/go.mod +9 -0
  29. package/shim/compiler/go.sum +22 -0
  30. package/shim/core/modulekind.go +10 -0
  31. package/shim/printer/emithost.go +24 -0
  32. package/shim/printer/go.mod +9 -0
  33. package/shim/printer/go.sum +22 -0
  34. package/shim/transformers/go.mod +14 -0
  35. package/shim/transformers/go.sum +20 -0
  36. package/shim/transformers/shim.go +15 -0
  37. package/src/compiler/internal/resolveEmittedJavaScript.ts +35 -4
  38. package/src/launcher/internal/prepareExecution.ts +18 -21
  39. package/src/launcher/internal/registerRuntimeHooks.ts +53 -8
  40. package/src/launcher/internal/runTtsx.ts +61 -453
  41. package/src/launcher/internal/runtimeHookPreload.ts +14 -0
  42. package/src/launcher/internal/runtimeHooks.ts +794 -136
  43. package/src/plugin/internal/buildSourcePlugin.ts +147 -16
  44. package/src/plugin/internal/loadProjectPlugins.ts +16 -4
@@ -3,27 +3,46 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.resolve = resolve;
7
- exports.load = load;
6
+ exports.installRuntimeHooks = installRuntimeHooks;
7
+ exports.entryModuleFormat = entryModuleFormat;
8
+ const node_crypto_1 = __importDefault(require("node:crypto"));
8
9
  const node_fs_1 = __importDefault(require("node:fs"));
9
10
  const node_module_1 = require("node:module");
11
+ const node_os_1 = __importDefault(require("node:os"));
10
12
  const node_path_1 = __importDefault(require("node:path"));
11
13
  const node_url_1 = require("node:url");
14
+ const readProjectConfig_1 = require("../../compiler/internal/project/readProjectConfig");
15
+ const resolveEmittedJavaScript_1 = require("../../compiler/internal/resolveEmittedJavaScript");
16
+ const resolveTsgo_1 = require("../../compiler/internal/resolveTsgo");
17
+ const runBuild_1 = require("../../compiler/internal/runBuild");
18
+ const spawnNative_1 = require("../../compiler/internal/spawnNative");
12
19
  /**
13
- * Node.js module-customization hooks registered inside the child process that
14
- * `ttsx` spawns. They give the runtime the same whole-graph reach `tsx` has,
15
- * without weakening the compile gate: type-checking still happens up front in
16
- * `prepareExecution`'s tsgo build, and these hooks only affect how dependencies
17
- * are _loaded_ at runtime.
20
+ * Synchronous Node module hooks installed (via `module.registerHooks`) in the
21
+ * child process `ttsx` spawns to run a TypeScript entry _from source_.
18
22
  *
19
- * - `resolve` rescues extensionless relative imports anywhere in the graph (e.g.
20
- * a workspace dependency exporting raw `.ts` whose own sources use `import
21
- * "./foo"`), which Node's ESM resolver rejects.
22
- * - `load` transpiles raw `.ts` dependencies that live under `node_modules`,
23
- * where Node refuses to strip types at all.
23
+ * They give the runner ts-node-style whole-graph reach without weakening the
24
+ * compile gate. The owning entry project is type-checked and built up front (by
25
+ * `prepareExecution`, with its transform plugins such as typia); these hooks
26
+ * serve that build under the source URLs so `__dirname`/`import.meta.url` keep
27
+ * pointing at the source tree. Three load paths:
24
28
  *
25
- * Workspace neighbours (realpath outside `node_modules`) keep Node's native
26
- * type-stripping path, so they stay covered by the up-front compile gate.
29
+ * 1. A `.ts` belonging to the entry project → serve the pre-built emitted JS
30
+ * (transform plugins already applied), mapped by the project's `rootDir`.
31
+ * 2. Any other raw `.ts` dependency (a published or workspace package that ships
32
+ * source) → build its own owning `tsconfig.json` once via `runBuild` and
33
+ * serve the emit. A real build (not a type-strip) is required because Node's
34
+ * type-stripping cannot do cross-file type-only elision — e.g. a
35
+ * value-shaped import of a type+namespace merge survives stripping and
36
+ * dangles at runtime.
37
+ * 3. No owning tsconfig → transform the lone file by the format it resolves to: a
38
+ * CommonJS-classified file (`.cts`, or a `.ts` in a package without `type:
39
+ * "module"`) is lowered to CommonJS through a tsgo single-file emit so its
40
+ * `export` syntax becomes `module.exports`; any other (ESM) file keeps the
41
+ * fast in-process `mode: "transform"` type-strip.
42
+ *
43
+ * The hooks are synchronous and run on the main thread (not a loader worker):
44
+ * that is what lets a CommonJS `require("./x")` chain reach them and what makes
45
+ * `require.resolve(..., { paths })` inside `runBuild`'s plugin loader behave.
27
46
  */
28
47
  /** Source/JS extensions probed when an extensionless relative import fails. */
29
48
  const RESOLVABLE_EXTENSIONS = [
@@ -35,141 +54,673 @@ const RESOLVABLE_EXTENSIONS = [
35
54
  ".mjs",
36
55
  ".cjs",
37
56
  ];
38
- /** TypeScript source extensions the `load` hook transpiles under node_modules. */
57
+ /** TypeScript source extensions these hooks compile. */
39
58
  const TYPESCRIPT_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"];
59
+ let manifestCache;
60
+ function manifest() {
61
+ if (manifestCache !== undefined) {
62
+ return manifestCache;
63
+ }
64
+ const file = process.env.TTSX_RUNTIME_MANIFEST;
65
+ if (file === undefined || file.length === 0) {
66
+ manifestCache = null;
67
+ return manifestCache;
68
+ }
69
+ try {
70
+ manifestCache = JSON.parse(node_fs_1.default.readFileSync(file, "utf8"));
71
+ }
72
+ catch {
73
+ manifestCache = null;
74
+ }
75
+ return manifestCache;
76
+ }
77
+ let installed = false;
40
78
  /**
41
- * Rescue an extensionless relative specifier that Node's ESM resolver rejected.
79
+ * Install the source-loading hooks on the current (main) thread. Idempotent:
80
+ * the bootstrap installs them for the entry process, and `NODE_OPTIONS`
81
+ * re-imports the installer in every child process the program spawns — both may
82
+ * run in the same process.
42
83
  *
43
- * Node resolves the common cases itself; this hook only runs after
44
- * `nextResolve` throws, so a successful resolution is never perturbed. When the
45
- * throwing specifier is relative, the candidate extensions are probed against
46
- * the parent's directory and the first existing file (or directory index) wins.
47
- * A genuinely missing module finds no candidate and the original error is
48
- * rethrown, preserving `ERR_MODULE_NOT_FOUND`.
84
+ * Two hooks are needed, because `module.registerHooks` does not intercept a
85
+ * `require()` made from inside a CommonJS module that was itself reached
86
+ * through an ESM `import` (the interop translator loads it on the raw CJS
87
+ * path). The ESM graph goes through `registerHooks`; the CommonJS `require`
88
+ * graph goes through `Module._extensions` — the canonical loader extension
89
+ * point `ts-node`/`tsx` use for the same reason.
90
+ */
91
+ function installRuntimeHooks() {
92
+ if (installed) {
93
+ return;
94
+ }
95
+ installed = true;
96
+ (0, node_module_1.registerHooks)({ load, resolve });
97
+ installCommonJsHook();
98
+ }
99
+ /**
100
+ * Register a CommonJS `require` handler for each TypeScript source extension so
101
+ * a `require("./x")` chain compiles `.ts` the same way the ESM `load` hook
102
+ * does.
103
+ */
104
+ function installCommonJsHook() {
105
+ const extensions = node_module_1.Module._extensions;
106
+ const compile = (module, filename) => {
107
+ module._compile(resolveServedSource(filename).source, filename);
108
+ };
109
+ for (const extension of [".ts", ".tsx", ".cts"]) {
110
+ extensions[extension] = compile;
111
+ }
112
+ }
113
+ /**
114
+ * The module format of the entry source file, derived from the entry project's
115
+ * `module` option (via the runtime manifest) the same way the served files are
116
+ * classified. The bootstrap uses it to load the entry through a CommonJS
117
+ * `require` or an ESM `import`.
118
+ */
119
+ function entryModuleFormat(entryFile) {
120
+ return moduleFormat(entryFile, manifest()?.moduleOption) === "module"
121
+ ? "module"
122
+ : "commonjs";
123
+ }
124
+ /**
125
+ * Rescue an extensionless or directory relative specifier that Node's resolver
126
+ * rejected. Only runs after `nextResolve` throws, so a successful resolution is
127
+ * never perturbed; a genuinely missing module finds no candidate and the
128
+ * original error is rethrown, preserving `ERR_MODULE_NOT_FOUND`.
49
129
  */
50
- async function resolve(specifier, context, nextResolve) {
130
+ function resolve(specifier, context, nextResolve) {
51
131
  try {
52
- return await nextResolve(specifier, context);
132
+ return nextResolve(specifier, context);
53
133
  }
54
134
  catch (error) {
55
- const rescued = probeRelativeSpecifier(specifier, context.parentURL);
135
+ const rescued = probeRescuableSpecifier(specifier, context.parentURL);
56
136
  if (rescued === null) {
57
137
  throw error;
58
138
  }
59
139
  return { shortCircuit: true, url: rescued };
60
140
  }
61
141
  }
62
- /**
63
- * Transpile a raw `.ts` dependency that lives under `node_modules`.
64
- *
65
- * Node strips types for first-party `.ts` automatically but refuses to do so
66
- * under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). This
67
- * hook performs the same type-strip transform for those files, scoped strictly
68
- * to `node_modules` so workspace neighbours keep Node's native path.
69
- */
70
- async function load(url, context, nextLoad) {
71
- const filename = transpilableNodeModulesPath(url);
72
- if (filename === null) {
142
+ const builtProjects = new Map();
143
+ function load(url, context, nextLoad) {
144
+ if (!url.startsWith("file:")) {
73
145
  return nextLoad(url, context);
74
146
  }
75
- const source = transpile(filename);
147
+ const filename = (0, node_url_1.fileURLToPath)(url);
148
+ if (!isTypeScriptSource(filename)) {
149
+ return nextLoad(url, context);
150
+ }
151
+ const { source, moduleOption } = resolveServedSource(filename, url);
76
152
  return {
77
- format: moduleFormat(filename, source),
153
+ format: moduleFormat(filename, moduleOption),
78
154
  shortCircuit: true,
79
155
  source,
80
156
  };
81
157
  }
82
158
  /**
83
- * Probe candidate extensions for a relative `specifier` whose bare form failed
84
- * to resolve. Returns a `file:` URL string for the first match, or `null` when
85
- * the specifier is non-relative, has no usable parent, or matches nothing.
159
+ * Resolve the JavaScript to run for a TypeScript source file, in priority
160
+ * order: the entry project's pre-built emit (transform plugins applied), a
161
+ * built raw `.ts` dependency, or — when no tsconfig owns it — a `mode:
162
+ * "transform"` type-strip. Shared by the ESM `load` hook and the CommonJS
163
+ * `require` handler.
86
164
  */
87
- function probeRelativeSpecifier(specifier, parentURL) {
88
- if (!isRelativeSpecifier(specifier)) {
165
+ function resolveServedSource(filename, url = (0, node_url_1.pathToFileURL)(filename).href) {
166
+ const real = realPath(filename);
167
+ const served = serveEntryEmit(real);
168
+ if (served !== null) {
169
+ return { moduleOption: manifest()?.moduleOption, source: served };
170
+ }
171
+ const built = serveDependencyEmit(real);
172
+ if (built !== null) {
173
+ return built;
174
+ }
175
+ return {
176
+ moduleOption: undefined,
177
+ source: transformOrphanSource(filename, url),
178
+ };
179
+ }
180
+ /**
181
+ * Transform a TypeScript source file that no tsconfig owns (a published or
182
+ * vendored package that ships raw `.ts`/`.cts`/`.mts` straight under
183
+ * `node_modules`), choosing the lowering by the format the file resolves to.
184
+ *
185
+ * Node's in-process `stripTypeScriptTypes` only erases type syntax; it never
186
+ * rewrites ECMAScript `import`/`export` into CommonJS. That is correct for a
187
+ * file Node will load as ESM, but wrong for one classified CommonJS — a `.cts`,
188
+ * or a `.ts` in a package without `type: "module"` — when the author wrote it
189
+ * with module syntax (`export const`, `export namespace`, `export function`).
190
+ * Stripping leaves the `export` in place and Node's CommonJS loader dies with
191
+ * `SyntaxError: Unexpected token 'export'`. So a CommonJS-format orphan is
192
+ * lowered through a real tsgo `--module commonjs` single-file emit (which also
193
+ * handles `export =`), exactly the format decision tsgo would have made for an
194
+ * owning project; an ESM-format orphan keeps the fast in-process strip.
195
+ */
196
+ function transformOrphanSource(filename, url) {
197
+ if (moduleFormat(filename, undefined) === "commonjs") {
198
+ const lowered = emitOrphanAsCommonJs(filename);
199
+ if (lowered !== null) {
200
+ return lowered;
201
+ }
202
+ }
203
+ return (0, node_module_1.stripTypeScriptTypes)(node_fs_1.default.readFileSync(filename, "utf8"), {
204
+ mode: "transform",
205
+ sourceUrl: url,
206
+ });
207
+ }
208
+ /**
209
+ * Lower a single CommonJS-format source file to CommonJS JavaScript by running
210
+ * tsgo on the lone file with `--module commonjs`. Emit-only, no diagnostic gate
211
+ * (the entry project's up-front check is the type gate), matching
212
+ * `buildDependency`. Returns `null` when tsgo is unavailable or produced no
213
+ * output, so the caller can fall back to the in-process strip.
214
+ */
215
+ function emitOrphanAsCommonJs(filename) {
216
+ let tsgo;
217
+ try {
218
+ tsgo = (0, resolveTsgo_1.resolveTsgo)({ cwd: node_path_1.default.dirname(filename) }).binary;
219
+ }
220
+ catch {
89
221
  return null;
90
222
  }
91
- if (parentURL === undefined || !parentURL.startsWith("file:")) {
223
+ // Content-hash cache: a CJS-format orphan ('s tsgo single-file emit) is lowered
224
+ // once and reused by every other process in the run, and across runs. Without
225
+ // it a program that fans out into many processes (the automated test corpus
226
+ // imports the same vendored `.ts` deps from thousands of generated files) would
227
+ // re-spawn tsgo per file per process and crawl.
228
+ const cacheFile = orphanCacheFile(filename, tsgo);
229
+ if (cacheFile !== null) {
230
+ const hit = readFileOrNull(cacheFile);
231
+ if (hit !== null) {
232
+ return hit;
233
+ }
234
+ }
235
+ const outDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "ttsx-orphan-"));
236
+ try {
237
+ const res = (0, spawnNative_1.spawnNative)(tsgo, [
238
+ filename,
239
+ // The file is named on the command line, so any tsconfig tsgo would
240
+ // discover by walking up (the consumer's own) must be ignored — both
241
+ // because it is not this file's project and because tsgo errors out
242
+ // ("tsconfig.json is present but will not be loaded") otherwise.
243
+ "--ignoreConfig",
244
+ "--module",
245
+ "commonjs",
246
+ "--target",
247
+ "es2022",
248
+ // This is an emit-only lowering: the entry project's up-front build is
249
+ // the type gate, so the single-file pass does not need to type-check.
250
+ // Skipping the check (and the lib check it implies) cuts the per-file
251
+ // cost several-fold, which matters when a program generates and imports
252
+ // thousands of raw `.ts` files at runtime (a fanned-out test corpus) and
253
+ // each one would otherwise pay a full single-file check.
254
+ "--noCheck",
255
+ "--skipLibCheck",
256
+ "--outDir",
257
+ outDir,
258
+ "--listEmittedFiles",
259
+ ], { cwd: node_path_1.default.dirname(filename), encoding: "utf8" });
260
+ const emitted = parseFirstEmittedFile((0, spawnNative_1.outputText)(res.stdout));
261
+ const lowered = emitted === null ? null : readFileOrNull(emitted);
262
+ if (lowered !== null && cacheFile !== null) {
263
+ writeOrphanCache(cacheFile, lowered);
264
+ }
265
+ return lowered;
266
+ }
267
+ catch {
92
268
  return null;
93
269
  }
94
- if (hasConcreteExtension(specifier)) {
270
+ finally {
271
+ node_fs_1.default.rmSync(outDir, { force: true, recursive: true });
272
+ }
273
+ }
274
+ /** Cache root for lowered orphan sources, shared per run (and across runs when
275
+ * `TTSC_CACHE_DIR` points at a persisted directory). */
276
+ function orphanCacheRoot() {
277
+ const base = process.env.TTSC_CACHE_DIR && process.env.TTSC_CACHE_DIR.length !== 0
278
+ ? process.env.TTSC_CACHE_DIR
279
+ : node_path_1.default.join(node_os_1.default.tmpdir(), "ttsc-orphan");
280
+ return node_path_1.default.join(base, "ttsx-orphan-cjs");
281
+ }
282
+ /** Content-addressed cache path for one orphan file's CommonJS lowering, keyed
283
+ * by source bytes and the tsgo binary so a tsgo bump invalidates it. `null` when
284
+ * the source cannot be read. */
285
+ function orphanCacheFile(filename, tsgo) {
286
+ let source;
287
+ try {
288
+ source = node_fs_1.default.readFileSync(filename);
289
+ }
290
+ catch {
95
291
  return null;
96
292
  }
97
- const parentDir = node_path_1.default.dirname((0, node_url_1.fileURLToPath)(parentURL));
98
- const base = node_path_1.default.resolve(parentDir, specifier);
99
- for (const extension of RESOLVABLE_EXTENSIONS) {
100
- const candidate = base + extension;
101
- if (isFile(candidate)) {
102
- return (0, node_url_1.pathToFileURL)(candidate).href;
103
- }
293
+ const key = node_crypto_1.default
294
+ .createHash("sha256")
295
+ .update(tsgo)
296
+ .update("\0")
297
+ .update(source)
298
+ .digest("hex")
299
+ .slice(0, 32);
300
+ return node_path_1.default.join(orphanCacheRoot(), `${key}.js`);
301
+ }
302
+ /** Write the lowered source to its cache path atomically (temp + rename), so a
303
+ * concurrent reader never sees a half-written file. Best-effort: a failure just
304
+ * means the next process re-lowers. */
305
+ function writeOrphanCache(cacheFile, lowered) {
306
+ try {
307
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(cacheFile), { recursive: true });
308
+ const tmp = `${cacheFile}.${process.pid}.${Date.now()}.tmp`;
309
+ node_fs_1.default.writeFileSync(tmp, lowered);
310
+ node_fs_1.default.renameSync(tmp, cacheFile);
104
311
  }
105
- for (const extension of RESOLVABLE_EXTENSIONS) {
106
- const candidate = node_path_1.default.join(base, `index${extension}`);
107
- if (isFile(candidate)) {
108
- return (0, node_url_1.pathToFileURL)(candidate).href;
312
+ catch {
313
+ // ignore — caching is an optimization, correctness does not depend on it
314
+ }
315
+ }
316
+ /** First `TSFILE:` path tsgo printed under `--listEmittedFiles`, or `null`. */
317
+ function parseFirstEmittedFile(stdout) {
318
+ for (const line of stdout.split(/\r?\n/)) {
319
+ const match = line.match(/^TSFILE:\s*(.+)$/);
320
+ if (match?.[1]) {
321
+ return match[1].trim();
109
322
  }
110
323
  }
111
324
  return null;
112
325
  }
113
326
  /**
114
- * Return the filesystem path of a `file:` URL when it points at a TypeScript
115
- * source under `node_modules`, otherwise `null`. Both conditions must hold for
116
- * the `load` hook to take over: workspace `.ts` (outside `node_modules`) and
117
- * already-JavaScript files fall through to Node.
327
+ * Serve the entry project's pre-built JavaScript for a source file the build
328
+ * emitted, or `null` when the file is outside the build or its emit is
329
+ * missing.
330
+ *
331
+ * The bound is the project's `rootDir` (the source root the emit mirrors), not
332
+ * its tsconfig directory: a project can pull in a file from elsewhere via
333
+ * `files` with a wider `rootDir` (e.g. the lint config loader compiles a
334
+ * `*.config.ts` from any directory under `rootDir: "/"`). Anything outside
335
+ * `rootDir` cannot have a mirrored emit, so it falls through to the dependency
336
+ * paths.
118
337
  */
119
- function transpilableNodeModulesPath(url) {
120
- if (!url.startsWith("file:")) {
338
+ function serveEntryEmit(real) {
339
+ const m = manifest();
340
+ if (m === null) {
121
341
  return null;
122
342
  }
123
- const filename = (0, node_url_1.fileURLToPath)(url);
124
- if (!isTypeScriptSource(filename) || !isUnderNodeModules(filename)) {
343
+ if (!isWithin(real, m.rootDir)) {
125
344
  return null;
126
345
  }
127
- return filename;
346
+ const emitted = (0, resolveEmittedJavaScript_1.resolveEmittedJavaScript)({
347
+ emittedFiles: m.emittedFiles,
348
+ outDir: m.emitDir,
349
+ projectRoot: m.rootDir,
350
+ sourceFile: real,
351
+ });
352
+ return readFileOrNull(emitted);
128
353
  }
129
354
  /**
130
- * Strip TypeScript types from `filename`, transforming TS-only constructs
131
- * (enums, namespaces, parameter properties) so the result is runnable
132
- * JavaScript. No transpile cache is kept: Node caches every module by resolved
133
- * URL, so each dependency file reaches this hook at most once per run.
355
+ * True when `real` is `directory` itself or sits beneath it. Handles a root
356
+ * `directory` (`/`, `C:\`): naively appending a separator would yield `//`,
357
+ * which no path starts with, so a `rootDir: "/"` project would serve nothing.
134
358
  */
135
- function transpile(filename) {
136
- return (0, node_module_1.stripTypeScriptTypes)(node_fs_1.default.readFileSync(filename, "utf8"), {
137
- mode: "transform",
138
- sourceUrl: (0, node_url_1.pathToFileURL)(filename).href,
359
+ function isWithin(real, directory) {
360
+ if (real === directory) {
361
+ return true;
362
+ }
363
+ const prefix = directory.endsWith(node_path_1.default.sep)
364
+ ? directory
365
+ : directory + node_path_1.default.sep;
366
+ return real.startsWith(prefix);
367
+ }
368
+ /**
369
+ * Build the project that owns `real` (nearest `tsconfig.json` above its real
370
+ * path) and return its emitted JavaScript, or `null` when no tsconfig owns it
371
+ * or the project does not emit it. The build honours the dependency's own
372
+ * tsconfig (transform plugins included), so a source-shipping package that
373
+ * needs a transform behaves correctly at runtime.
374
+ */
375
+ function serveDependencyEmit(real) {
376
+ const tsconfig = nearestTsconfig(real);
377
+ if (tsconfig === null) {
378
+ return null;
379
+ }
380
+ let built;
381
+ try {
382
+ built = ensureProjectBuilt(tsconfig);
383
+ }
384
+ catch {
385
+ // The owning project produced no emit at all; fall back to type-stripping
386
+ // this single file rather than failing the whole run.
387
+ return null;
388
+ }
389
+ const emitted = (0, resolveEmittedJavaScript_1.resolveEmittedJavaScript)({
390
+ emittedFiles: built.emittedFiles,
391
+ outDir: built.emitDir,
392
+ projectRoot: built.rootDir,
393
+ sourceFile: real,
139
394
  });
395
+ const source = readFileOrNull(emitted);
396
+ return source === null ? null : { moduleOption: built.moduleOption, source };
140
397
  }
141
398
  /**
142
- * Decide the module format for a transpiled `node_modules` TypeScript file.
399
+ * Build the project that owns a dependency once per run and share the result
400
+ * across every process the program spawns.
143
401
  *
144
- * Type-stripping never rewrites module syntax, so the emitted `source` keeps
145
- * whatever `import`/`export` or `require` form the author wrote. The format
146
- * must match that syntax or Node mis-parses the module. The extension is
147
- * authoritative for `.mts`/`.cts`; otherwise the nearest `package.json` `type`
148
- * sets the baseline, but unambiguous ESM syntax overrides a CommonJS baseline
149
- * exactly as Node's own module-syntax detection does.
402
+ * A program (a benchmark, a worker pool) can fan out into many child processes,
403
+ * each of which inherits the runtime manifest and would otherwise rebuild every
404
+ * dependency from scratch — and worse, several at once into the same directory,
405
+ * corrupting each other. So the build output is content-keyed under the shared
406
+ * per-run cache: a finished build leaves a meta marker that any later process
407
+ * (or a second import in this one) reuses, and concurrent first-builders are
408
+ * serialised by an atomic lock directory.
150
409
  */
151
- function moduleFormat(filename, source) {
152
- if (filename.endsWith(".mts")) {
153
- return "module";
410
+ function ensureProjectBuilt(tsconfig) {
411
+ const cached = builtProjects.get(tsconfig);
412
+ if (cached !== undefined) {
413
+ return cached;
154
414
  }
155
- if (filename.endsWith(".cts")) {
156
- return "commonjs";
415
+ const key = node_crypto_1.default
416
+ .createHash("sha256")
417
+ .update(tsconfig)
418
+ .digest("hex")
419
+ .slice(0, 16);
420
+ const root = dependencyCacheRoot();
421
+ const emitDir = node_path_1.default.join(root, key);
422
+ const metaPath = node_path_1.default.join(root, `${key}.json`);
423
+ const lockDir = node_path_1.default.join(root, `${key}.lock`);
424
+ const reuse = readDependencyCache(emitDir, metaPath);
425
+ if (reuse !== null) {
426
+ builtProjects.set(tsconfig, reuse);
427
+ return reuse;
157
428
  }
158
- if (nearestPackageType(filename) === "module") {
159
- return "module";
429
+ node_fs_1.default.mkdirSync(root, { recursive: true });
430
+ const built = withBuildLock(lockDir, metaPath, emitDir, () => buildDependency(tsconfig, emitDir, metaPath));
431
+ builtProjects.set(tsconfig, built);
432
+ return built;
433
+ }
434
+ /** Reuse a dependency another process (or an earlier import) already built. */
435
+ function readDependencyCache(emitDir, metaPath) {
436
+ let meta;
437
+ try {
438
+ meta = JSON.parse(node_fs_1.default.readFileSync(metaPath, "utf8"));
439
+ }
440
+ catch {
441
+ return null;
160
442
  }
161
- return hasEsmSyntax(source) ? "module" : "commonjs";
443
+ if (!emittedAnything(emitDir)) {
444
+ return null;
445
+ }
446
+ return {
447
+ emitDir,
448
+ emittedFiles: undefined,
449
+ moduleOption: meta.moduleOption,
450
+ rootDir: meta.rootDir,
451
+ };
162
452
  }
163
453
  /**
164
- * True when `source` carries unambiguous ESM syntax: a top-level
165
- * `import`/`export` statement, or an `import.meta` reference (which is a syntax
166
- * error outside a module). Mirrors the markers Node's own detection keys on.
167
- * `stripTypeScriptTypes` has already removed comments, so a keyword inside a
168
- * stripped comment cannot produce a false positive.
454
+ * Run `build` while holding an exclusive lock for this dependency, re-checking
455
+ * the cache once the lock is held (a concurrent builder may have just
456
+ * finished). A waiter polls for the winner's meta marker, and steals an
457
+ * abandoned lock (a builder that crashed) after a generous timeout so the run
458
+ * never wedges.
169
459
  */
170
- function hasEsmSyntax(source) {
171
- return (/(?:^|[\n;])\s*(?:import|export)\b/.test(source) ||
172
- /\bimport\s*\.\s*meta\b/.test(source));
460
+ function withBuildLock(lockDir, metaPath, emitDir, build) {
461
+ const stealAfterMs = 600_000;
462
+ for (;;) {
463
+ try {
464
+ node_fs_1.default.mkdirSync(lockDir);
465
+ }
466
+ catch {
467
+ const waited = waitForDependencyCache(emitDir, metaPath, stealAfterMs);
468
+ if (waited !== null) {
469
+ return waited;
470
+ }
471
+ node_fs_1.default.rmSync(lockDir, { force: true, recursive: true });
472
+ continue;
473
+ }
474
+ try {
475
+ const reuse = readDependencyCache(emitDir, metaPath);
476
+ return reuse ?? build();
477
+ }
478
+ finally {
479
+ node_fs_1.default.rmSync(lockDir, { force: true, recursive: true });
480
+ }
481
+ }
482
+ }
483
+ /** Poll for a concurrent builder's completion, up to `timeoutMs`. */
484
+ function waitForDependencyCache(emitDir, metaPath, timeoutMs) {
485
+ const startedAt = Date.now();
486
+ for (;;) {
487
+ const reuse = readDependencyCache(emitDir, metaPath);
488
+ if (reuse !== null) {
489
+ return reuse;
490
+ }
491
+ if (Date.now() - startedAt > timeoutMs) {
492
+ return null;
493
+ }
494
+ sleepSync(50);
495
+ }
496
+ }
497
+ /** Block the current (synchronous) thread for `ms` without busy-spinning. */
498
+ function sleepSync(ms) {
499
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
500
+ }
501
+ /** Compile a dependency project to `emitDir` and write its completion marker. */
502
+ function buildDependency(tsconfig, emitDir, metaPath) {
503
+ const project = (0, readProjectConfig_1.readProjectConfig)({ cwd: node_path_1.default.dirname(tsconfig), tsconfig });
504
+ node_fs_1.default.rmSync(emitDir, { force: true, recursive: true });
505
+ const result = (0, runBuild_1.runBuild)({
506
+ cwd: project.root,
507
+ emit: true,
508
+ forceListEmittedFiles: true,
509
+ outDir: emitDir,
510
+ // Honour the dependency's own transform plugins: a source-shipping package
511
+ // can itself depend on a transform (e.g. a fixture whose values are built
512
+ // with `typia.createRandom`), and its runtime behaviour is wrong without it.
513
+ // `runBuild` runs on this main thread, so its plugin resolution works the
514
+ // same as the entry build's.
515
+ quiet: true,
516
+ // Emit only: the entry project's up-front check is the type gate. A
517
+ // dependency build pulls its own transitive sources into the program and
518
+ // would otherwise fail on type diagnostics that belong to those packages
519
+ // under their own (laxer) config — e.g. unused-type-parameter warnings in a
520
+ // transitively imported library. We still want the type-aware emit (for
521
+ // type-only elision), just not the error gate.
522
+ skipDiagnosticsCheck: true,
523
+ tsconfig,
524
+ });
525
+ // Success is "the project wrote JavaScript", not "the build reported a file
526
+ // list": a native transform host (typia, @ttsc/banner, …) emits without
527
+ // printing the `--listEmittedFiles` lines, so `result.emittedFiles` is empty
528
+ // even on a clean build. A genuinely empty output directory is the real
529
+ // failure; the caller then falls back to type-stripping the one file.
530
+ if (!emittedAnything(emitDir)) {
531
+ throw new Error([
532
+ `ttsx: dependency build produced no output for ${tsconfig}`,
533
+ result.stderr || result.stdout,
534
+ ]
535
+ .filter((line) => line.trim().length !== 0)
536
+ .join("\n"));
537
+ }
538
+ const rootDir = typeof project.compilerOptions.rootDir === "string"
539
+ ? project.compilerOptions.rootDir
540
+ : project.root;
541
+ const moduleOption = typeof project.compilerOptions.module === "string"
542
+ ? project.compilerOptions.module
543
+ : undefined;
544
+ node_fs_1.default.writeFileSync(metaPath, JSON.stringify({ moduleOption, rootDir }), "utf8");
545
+ return { emitDir, emittedFiles: undefined, moduleOption, rootDir };
546
+ }
547
+ function dependencyCacheRoot() {
548
+ const m = manifest();
549
+ return m !== null && m.depCacheDir.length !== 0
550
+ ? m.depCacheDir
551
+ : node_path_1.default.join(node_os_1.default.tmpdir(), "ttsx-dep");
552
+ }
553
+ /** Owning-tsconfig cache keyed by directory, mirroring `packageTypeCache`. */
554
+ const tsconfigCache = new Map();
555
+ /**
556
+ * The nearest `tsconfig.json` at or above `file`'s directory, or `null`. The
557
+ * walk stops at a `node_modules` boundary: a tsconfig above `node_modules`
558
+ * belongs to the consumer, not to the published dependency inside it, so a
559
+ * dependency that ships no tsconfig of its own has no owning project and is
560
+ * type-stripped instead. A pnpm-symlinked workspace package is unaffected
561
+ * because `file` is already its real path (outside `node_modules`).
562
+ *
563
+ * The walk is memoised per directory (the whole walked chain shares one
564
+ * answer), so the thousands of files a fanned-out test corpus imports from the
565
+ * same handful of projects do not each re-stat the same parent directories.
566
+ */
567
+ function nearestTsconfig(file) {
568
+ let directory = node_path_1.default.dirname(file);
569
+ const chain = [];
570
+ for (;;) {
571
+ const cached = tsconfigCache.get(directory);
572
+ if (cached !== undefined) {
573
+ return rememberTsconfig(chain, cached);
574
+ }
575
+ if (node_path_1.default.basename(directory) === "node_modules") {
576
+ return rememberTsconfig(chain, null);
577
+ }
578
+ chain.push(directory);
579
+ const candidate = node_path_1.default.join(directory, "tsconfig.json");
580
+ if (isFile(candidate)) {
581
+ return rememberTsconfig(chain, candidate);
582
+ }
583
+ const parent = node_path_1.default.dirname(directory);
584
+ if (parent === directory) {
585
+ return rememberTsconfig(chain, null);
586
+ }
587
+ directory = parent;
588
+ }
589
+ }
590
+ function rememberTsconfig(directories, result) {
591
+ for (const directory of directories) {
592
+ tsconfigCache.set(directory, result);
593
+ }
594
+ return result;
595
+ }
596
+ function readFileOrNull(file) {
597
+ if (file === null) {
598
+ return null;
599
+ }
600
+ try {
601
+ return node_fs_1.default.readFileSync(file, "utf8");
602
+ }
603
+ catch {
604
+ return null;
605
+ }
606
+ }
607
+ function realPath(target) {
608
+ try {
609
+ return node_fs_1.default.realpathSync(target);
610
+ }
611
+ catch {
612
+ return target;
613
+ }
614
+ }
615
+ /**
616
+ * Map the JavaScript extension a relative `specifier` carries to the TypeScript
617
+ * source extensions tsgo would have emitted it from. Running from source, a
618
+ * `"./x.js"` import (whether authored or rewritten from `"./x.ts"` by
619
+ * `--rewriteRelativeImportExtensions`) has no `.js` on disk — only `./x.ts`.
620
+ */
621
+ const JS_TO_TS_EXTENSIONS = new Map([
622
+ [".js", [".ts", ".tsx"]],
623
+ [".jsx", [".tsx"]],
624
+ [".mjs", [".mts"]],
625
+ [".cjs", [".cts"]],
626
+ ]);
627
+ /**
628
+ * Rescue a `specifier` that Node's resolver rejected: map a JavaScript
629
+ * extension back to its TypeScript source, or probe candidate extensions /
630
+ * directory indexes for an extensionless form. Returns a `file:` URL for the
631
+ * first match, or `null` when nothing matches.
632
+ *
633
+ * Handles two shapes:
634
+ *
635
+ * - A relative specifier (`./x`) resolved against a `file:` parent — a normal
636
+ * `import`/`require` inside a served module;
637
+ * - An already-absolute specifier with no parent — the main entry of a
638
+ * `child_process.fork(__dirname + "/servant.js")`. fork's main module reaches
639
+ * the resolve hook as an absolute `.js` path with `parentURL` undefined, and
640
+ * run-from-source ships only the `.ts`, so without this the child dies with
641
+ * `Cannot find module servant.js` and a tgrid master waits on it forever.
642
+ */
643
+ function probeRescuableSpecifier(specifier, parentURL) {
644
+ // A `?query` / `#hash` suffix is part of module identity, not the path; strip
645
+ // it before resolving and re-attach it to the resolved URL so a loader keying
646
+ // on the suffix (and `import.meta.url`) sees it preserved.
647
+ const suffixStart = specifier.search(/[?#]/);
648
+ const suffix = suffixStart === -1 ? "" : specifier.slice(suffixStart);
649
+ const pathname = suffixStart === -1 ? specifier : specifier.slice(0, suffixStart);
650
+ let base;
651
+ if (isRelativeSpecifier(specifier)) {
652
+ if (parentURL === undefined || !parentURL.startsWith("file:")) {
653
+ return null;
654
+ }
655
+ const parentDir = node_path_1.default.dirname((0, node_url_1.fileURLToPath)(parentURL));
656
+ base = node_path_1.default.resolve(parentDir, pathname);
657
+ }
658
+ else if (node_path_1.default.isAbsolute(pathname)) {
659
+ base = pathname;
660
+ }
661
+ else {
662
+ return null;
663
+ }
664
+ const withSuffix = (candidate) => (0, node_url_1.pathToFileURL)(candidate).href + suffix;
665
+ const jsExtension = node_path_1.default.extname(base).toLowerCase();
666
+ const tsExtensions = JS_TO_TS_EXTENSIONS.get(jsExtension);
667
+ if (tsExtensions !== undefined) {
668
+ const stem = base.slice(0, base.length - jsExtension.length);
669
+ for (const extension of tsExtensions) {
670
+ const candidate = stem + extension;
671
+ if (isFile(candidate)) {
672
+ return withSuffix(candidate);
673
+ }
674
+ }
675
+ return null;
676
+ }
677
+ if (hasConcreteExtension(pathname)) {
678
+ return null;
679
+ }
680
+ for (const extension of RESOLVABLE_EXTENSIONS) {
681
+ const candidate = base + extension;
682
+ if (isFile(candidate)) {
683
+ return withSuffix(candidate);
684
+ }
685
+ }
686
+ for (const extension of RESOLVABLE_EXTENSIONS) {
687
+ const candidate = node_path_1.default.join(base, `index${extension}`);
688
+ if (isFile(candidate)) {
689
+ return withSuffix(candidate);
690
+ }
691
+ }
692
+ return null;
693
+ }
694
+ /**
695
+ * Decide the module format the way Node and tsgo do — from configuration, never
696
+ * by sniffing the emitted text. The file extension is authoritative
697
+ * (`.mts`/`.mjs` → module, `.cts`/`.cjs` → commonjs); otherwise the owning
698
+ * tsconfig's `module` option decides: a fixed CommonJS / ES target maps
699
+ * directly, while the `node*`/`preserve` family (and a file with no owning
700
+ * tsconfig) defers to the nearest `package.json` `type`, exactly as tsgo chose
701
+ * when it emitted.
702
+ */
703
+ function moduleFormat(filename, moduleOption) {
704
+ if (filename.endsWith(".mts") || filename.endsWith(".mjs")) {
705
+ return "module";
706
+ }
707
+ if (filename.endsWith(".cts") || filename.endsWith(".cjs")) {
708
+ return "commonjs";
709
+ }
710
+ const option = (moduleOption ?? "").toLowerCase();
711
+ if (option === "commonjs" || option === "node10" || option === "none") {
712
+ return "commonjs";
713
+ }
714
+ if (option === "" ||
715
+ option === "node16" ||
716
+ option === "node18" ||
717
+ option === "nodenext" ||
718
+ option === "preserve") {
719
+ return nearestPackageType(filename);
720
+ }
721
+ // Every remaining `module` target (es2015 … esnext, system, amd, umd) emits
722
+ // ECMAScript modules.
723
+ return "module";
173
724
  }
174
725
  /** Package-type cache keyed by directory, mirroring Node's own lookup walk. */
175
726
  const packageTypeCache = new Map();
@@ -201,12 +752,12 @@ function rememberPackageType(directories, type) {
201
752
  }
202
753
  /** Read a directory's `package.json` `type`, or `null` when absent/invalid. */
203
754
  function readPackageType(directory) {
204
- const manifest = node_path_1.default.join(directory, "package.json");
205
- if (!isFile(manifest)) {
755
+ const manifestPath = node_path_1.default.join(directory, "package.json");
756
+ if (!isFile(manifestPath)) {
206
757
  return null;
207
758
  }
208
759
  try {
209
- const parsed = JSON.parse(node_fs_1.default.readFileSync(manifest, "utf8"));
760
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(manifestPath, "utf8"));
210
761
  return parsed.type === "module" ? "module" : "commonjs";
211
762
  }
212
763
  catch {
@@ -226,26 +777,6 @@ function hasConcreteExtension(specifier) {
226
777
  function isTypeScriptSource(filename) {
227
778
  return TYPESCRIPT_EXTENSIONS.some((extension) => filename.endsWith(extension));
228
779
  }
229
- /**
230
- * True when `filename` lives inside a real `node_modules` package. A
231
- * `node_modules/.cache` segment does not count: `ttsx` mirrors the project into
232
- * `<root>/node_modules/.cache/ttsc/ttsx/.../fs/...`, so the user's own sources
233
- * appear under that cache prefix at runtime. Treating them as dependencies
234
- * would type-strip a project file (which `stripTypeScriptTypes` cannot do as
235
- * faithfully as the compile gate, e.g. it cannot elide type-only imports) and
236
- * steal it from a host loader. A genuine dependency still has a deeper
237
- * `node_modules/<pkg>` segment, so it is detected; `.cache` (never a package
238
- * name) is the only thing skipped.
239
- */
240
- function isUnderNodeModules(filename) {
241
- const segments = filename.split(node_path_1.default.sep);
242
- for (let i = 0; i < segments.length - 1; i += 1) {
243
- if (segments[i] === "node_modules" && segments[i + 1] !== ".cache") {
244
- return true;
245
- }
246
- }
247
- return false;
248
- }
249
780
  function isFile(candidate) {
250
781
  try {
251
782
  return node_fs_1.default.statSync(candidate).isFile();
@@ -254,4 +785,26 @@ function isFile(candidate) {
254
785
  return false;
255
786
  }
256
787
  }
788
+ /** True when `directory` holds at least one emitted JavaScript file (any depth). */
789
+ function emittedAnything(directory) {
790
+ let entries;
791
+ try {
792
+ entries = node_fs_1.default.readdirSync(directory, { withFileTypes: true });
793
+ }
794
+ catch {
795
+ return false;
796
+ }
797
+ for (const entry of entries) {
798
+ const full = node_path_1.default.join(directory, entry.name);
799
+ if (entry.isDirectory()) {
800
+ if (emittedAnything(full)) {
801
+ return true;
802
+ }
803
+ }
804
+ else if (entry.isFile() && /\.(?:[cm]?js)$/i.test(entry.name)) {
805
+ return true;
806
+ }
807
+ }
808
+ return false;
809
+ }
257
810
  //# sourceMappingURL=runtimeHooks.js.map