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