ttsc 0.14.2 → 0.15.0
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/driver/emit_plugin.go +131 -0
- package/driver/plugins.go +37 -0
- package/go.mod +0 -3
- package/go.sum +0 -4
- package/lib/compiler/internal/resolveEmittedJavaScript.js +25 -4
- package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
- package/lib/launcher/internal/prepareExecution.d.ts +4 -1
- package/lib/launcher/internal/prepareExecution.js +13 -17
- package/lib/launcher/internal/prepareExecution.js.map +1 -1
- package/lib/launcher/internal/registerRuntimeHooks.js +43 -8
- package/lib/launcher/internal/registerRuntimeHooks.js.map +1 -1
- package/lib/launcher/internal/runTtsx.js +46 -386
- package/lib/launcher/internal/runTtsx.js.map +1 -1
- package/lib/launcher/internal/runtimeHookPreload.d.ts +1 -0
- package/lib/launcher/internal/runtimeHookPreload.js +16 -0
- package/lib/launcher/internal/runtimeHookPreload.js.map +1 -0
- package/lib/launcher/internal/runtimeHooks.d.ts +16 -38
- package/lib/launcher/internal/runtimeHooks.js +672 -119
- package/lib/launcher/internal/runtimeHooks.js.map +1 -1
- package/lib/plugin/internal/buildSourcePlugin.js +120 -16
- package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
- package/lib/plugin/internal/loadProjectPlugins.js +15 -4
- package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
- package/package.json +8 -8
- package/shim/ast/parent.go +31 -0
- package/shim/compiler/emitassemble.go +30 -0
- package/shim/compiler/emithook.go +22 -0
- package/shim/compiler/go.mod +9 -0
- package/shim/compiler/go.sum +22 -0
- package/shim/core/modulekind.go +10 -0
- package/shim/printer/emithost.go +24 -0
- package/shim/printer/go.mod +9 -0
- package/shim/printer/go.sum +22 -0
- package/shim/transformers/go.mod +14 -0
- package/shim/transformers/go.sum +20 -0
- package/shim/transformers/shim.go +15 -0
- package/src/compiler/internal/resolveEmittedJavaScript.ts +35 -4
- package/src/launcher/internal/prepareExecution.ts +18 -21
- package/src/launcher/internal/registerRuntimeHooks.ts +53 -8
- package/src/launcher/internal/runTtsx.ts +61 -453
- package/src/launcher/internal/runtimeHookPreload.ts +14 -0
- package/src/launcher/internal/runtimeHooks.ts +794 -136
- package/src/plugin/internal/buildSourcePlugin.ts +147 -16
- 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
|
|
8
|
-
* `ttsx` spawns
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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?:
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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?:
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
) =>
|
|
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
|
-
|
|
127
|
+
let installed = false;
|
|
67
128
|
|
|
68
129
|
/**
|
|
69
|
-
*
|
|
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
|
-
*
|
|
72
|
-
* `
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
|
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
|
-
):
|
|
201
|
+
): ResolveResult {
|
|
83
202
|
try {
|
|
84
|
-
return
|
|
203
|
+
return nextResolve(specifier, context);
|
|
85
204
|
} catch (error) {
|
|
86
|
-
const rescued =
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
):
|
|
107
|
-
|
|
108
|
-
if (filename === null) {
|
|
226
|
+
): LoadResult {
|
|
227
|
+
if (!url.startsWith("file:")) {
|
|
109
228
|
return nextLoad(url, context);
|
|
110
229
|
}
|
|
111
|
-
const
|
|
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,
|
|
236
|
+
format: moduleFormat(filename, moduleOption),
|
|
114
237
|
shortCircuit: true,
|
|
115
238
|
source,
|
|
116
239
|
};
|
|
117
240
|
}
|
|
118
241
|
|
|
119
242
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
): string
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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
|
|
161
|
-
|
|
432
|
+
function serveEntryEmit(real: string): string | null {
|
|
433
|
+
const m = manifest();
|
|
434
|
+
if (m === null) {
|
|
162
435
|
return null;
|
|
163
436
|
}
|
|
164
|
-
|
|
165
|
-
if (!isTypeScriptSource(filename) || !isUnderNodeModules(filename)) {
|
|
437
|
+
if (!isWithin(real, m.rootDir)) {
|
|
166
438
|
return null;
|
|
167
439
|
}
|
|
168
|
-
|
|
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
|
-
*
|
|
173
|
-
* (
|
|
174
|
-
*
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
514
|
+
function ensureProjectBuilt(tsconfig: string): BuiltProject {
|
|
515
|
+
const cached = builtProjects.get(tsconfig);
|
|
516
|
+
if (cached !== undefined) {
|
|
517
|
+
return cached;
|
|
197
518
|
}
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
258
|
-
if (!isFile(
|
|
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(
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|