ttsc
Version:
General-purpose TypeScript-Go compiler, runtime, plugin host, and LSP host.
293 lines • 13.3 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.runTtsx = runTtsx;
const node_child_process_1 = require("node:child_process");
const node_fs_1 = __importDefault(require("node:fs"));
const node_path_1 = __importDefault(require("node:path"));
const resolveTsgo_1 = require("../../compiler/internal/resolveTsgo");
const parser_1 = require("../../flags/parser");
const schema_1 = require("../../flags/schema");
const getCompilerVersionText_1 = require("./getCompilerVersionText");
const prepareExecution_1 = require("./prepareExecution");
const resolveCacheDir_1 = require("./resolveCacheDir");
const runtimeHooks_1 = require("./runtimeHooks");
/**
* CLI entry point for `ttsx`. Type-checks the owning project via tsgo, emits
* JavaScript to a PID-isolated temp directory, rewrites ESM specifiers when
* needed, and executes the compiled entry with the current Node.js runtime.
*
* @param argv - Command-line arguments (defaults to `process.argv.slice(2)`).
* @returns The child-process exit code, or `2` on a ttsx-level error.
*/
function runTtsx(argv = process.argv.slice(2)) {
try {
return run(argv);
}
catch (error) {
process.stderr.write(`${formatError(error)}\n`);
return 2;
}
}
function run(argv) {
const parsed = parseCLI(argv);
if (parsed === "help") {
printHelp();
return 0;
}
if (parsed === "version") {
process.stdout.write(`${(0, getCompilerVersionText_1.getCompilerVersionText)().replace(/^ttsc\b/, "ttsx")}\n`);
return 0;
}
// Refuse an unsupported Node.js before type-checking and spawning the child:
// the child inherits this process's Node version, so an early diagnostic here
// pre-empts both the Node 18 `--disable-warning` rejection and the Node 20
// missing-`registerHooks` TypeError with one actionable message. `--help` and
// `--version` are handled above so they still print on any Node.
const nodeSupport = (0, runtimeHooks_1.checkNodeRuntimeSupport)(process.versions.node);
if (nodeSupport !== null) {
process.stderr.write(`ttsx: ${nodeSupport}\n`);
return 2;
}
const cwd = node_path_1.default.resolve(parsed.cwd ?? process.cwd());
const entry = node_path_1.default.resolve(cwd, parsed.entry);
if (!node_fs_1.default.existsSync(entry)) {
process.stderr.write(`ttsx: entry not found: ${entry}\n`);
return 2;
}
const prepared = (0, prepareExecution_1.prepareExecution)(entry, {
binary: parsed.binary,
cacheDir: (0, resolveCacheDir_1.resolveCacheDir)(cwd, parsed.cacheDir),
checkers: parsed.checkers,
cwd,
passthrough: parsed.tsgoFlags,
// `--no-plugins` builds the entry's owning project with plugin
// discovery and loading disabled. ttsc's own config loaders use it
// when they evaluate a `*.config.ts` through ttsx: that build only
// needs to type-check and run the config file, so loading the host
// project's transform/check plugins (`@nestia/core`, `typia`, …)
// would be both wasteful and wrong — those plugins impose project
// requirements (e.g. `strict` mode) the ephemeral config-loader
// tsconfig deliberately does not satisfy.
plugins: parsed.noPlugins ? false : undefined,
project: parsed.project,
singleThreaded: parsed.singleThreaded,
});
return runPreparedEntry(parsed, prepared, cwd, entry);
}
function formatError(error) {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function parseCLI(argv) {
// ttsx accepts ttsc-style flags plus its own `--no-plugins` / `--require`.
// The shared schema engine recognises both; the engine returns positional
// tokens (entry file + flag values that aren't `.ts`) and a passthrough
// list mirroring the pre-schema behaviour.
//
// The legacy uppercase `-P` spelling ttsx has always accepted needs no
// rewrite: the engine resolves a token to the flag the compiler resolves it
// to, so `-P` and `-P=<file>` reach `--tsconfig` by the same rule that makes
// `-p` reach it. A textual pre-rewrite here would be a second rule for a job
// the engine owns.
//
// Terminal flags (--help / --version) short-circuit before parsing so
// ttsx prints help text even when the entry file is missing. Resolved
// through the schema so every spelling the compiler accepts (`--HELP`,
// `-Version`) reaches the same branch.
for (const token of argv) {
const flag = (0, schema_1.resolveFlagSpec)(token)?.name;
if (flag === "--help")
return "help";
if (flag === "--version")
return "version";
}
const result = (0, parser_1.parseFlags)({
argv,
errorPrefix: "ttsx:",
forwardAfterFirstPositional: true,
honorDoubleDashSeparator: true,
// Only a TypeScript-extensioned bare token is the entry; every other bare
// token before it (e.g. the `es2020` in `--target es2020 entry.ts`) is a
// forwarded flag value. Classifying values via the predicate keeps them in
// `passthrough` in order AND stops a pre-entry value from being mistaken for
// the first positional sentinel — which previously flipped the parser into
// tail mode and pushed the real entry into `tail`, failing with
// "entry file is required".
isPositional: looksLikeEntryFile,
subcommand: "ttsx",
});
const entry = result.positional.find(looksLikeEntryFile);
if (entry === undefined) {
throw new Error("ttsx: entry file is required");
}
// With `forwardAfterFirstPositional: true` and `isPositional:
// looksLikeEntryFile`, the parser reports `result.positional` as just the
// entry, `result.passthrough` as the tsgo-forwarded flags (and their
// in-order space values) arriving BEFORE the entry, and `result.tail` as
// every token AFTER the entry — the user program's argv (e.g. the `generate
// --input src/input` tail of `ttsx typia.ts generate --input src/input`),
// which MUST NOT reach tsgo.
const postEntryArgs = [...result.tail];
// `--require` is declared `repeatable`, so the engine records every accepted
// value in argv order and the launcher reads the list straight off the parse
// result.
//
// This replaces a second, hand-written scan over raw argv that re-derived the
// pre-entry boundary with `looksLikeEntryFile`. Applied to raw tokens that
// predicate cannot tell an entry from a `--require` value carrying a
// TypeScript extension, nor from an inline `--require=<x>.ts` token, so the
// scan stopped before the tokens it existed to collect and preloads were
// dropped silently. The engine already owns that boundary:
// `forwardAfterFirstPositional` routes every post-entry token to
// `result.tail` without parsing it, so `ttsx entry.ts -r preload.cjs` still
// forwards the pair to the program instead of preloading it.
const preload = (0, parser_1.getStringList)(result, "--require");
return {
binary: (0, parser_1.getString)(result, "--binary"),
cacheDir: (0, parser_1.getString)(result, "--cache-dir"),
checkers: (0, parser_1.getNumber)(result, "--checkers"),
cwd: (0, parser_1.getString)(result, "--cwd"),
entry,
noPlugins: (0, parser_1.getBoolean)(result, "--no-plugins") === true,
passthrough: postEntryArgs,
preload,
project: (0, parser_1.getString)(result, "--tsconfig"),
singleThreaded: (0, parser_1.getBoolean)(result, "--singleThreaded") === true,
tsgoFlags: [...result.passthrough],
};
}
/**
* Report whether a bare CLI token is the TypeScript entry file rather than a
* forwarded flag's value. ttsx runs a TypeScript entrypoint, so only a token
* with a TypeScript source extension is treated as the entry.
*/
function looksLikeEntryFile(token) {
return [".ts", ".tsx", ".mts", ".cts"].some((ext) => token.endsWith(ext));
}
function printHelp() {
process.stdout.write([
"ttsx — TypeScript runner provided by ttsc.",
"",
"Usage:",
" ttsx [options] <entry.ts> [-- <argv...>]",
"",
"Options:",
" -P, --project <file> Use an explicit tsconfig.json",
" --cwd <dir> Resolve entry/project relative to this directory",
" --cache-dir <dir> Override the runner and source-plugin cache root",
" --binary <path> Use an explicit tsgo binary",
" --no-plugins Build the project without ttsc plugins",
" -r, --require <module> Preload a module before the entrypoint",
" --singleThreaded Run TypeScript-Go single-threaded (one checker)",
" --checkers <n> Type-checker pool size (default: TypeScript-Go's)",
" -h, --help Show this help",
" -v, --version Print the runner version",
"",
" Any other flag before the entry is forwarded to tsgo, so options like",
" --strict apply to the type-check (e.g. ttsx --strict src/index.ts).",
"",
"Examples:",
" ttsx src/index.ts",
" ttsx --project tsconfig.json src/index.ts -- --port 3000",
].join("\n"));
process.stdout.write("\n");
}
/**
* Append a Node flag to an existing `NODE_OPTIONS` value (or start one). Used
* to propagate the runtime-hook installer into every child process the program
* spawns, so workers launched as `node worker.ts` inherit the source loader.
*/
function appendNodeOption(existing, option) {
return existing && existing.trim().length !== 0
? `${existing} ${option}`
: option;
}
function resolvePreload(cwd, preload) {
if (node_path_1.default.isAbsolute(preload) || isRelativeSpecifier(preload)) {
return node_path_1.default.resolve(cwd, preload);
}
return preload;
}
function isRelativeSpecifier(specifier) {
return (specifier === "." ||
specifier === ".." ||
specifier.startsWith("./") ||
specifier.startsWith("../") ||
specifier.startsWith(".\\") ||
specifier.startsWith("..\\"));
}
/**
* Run the TypeScript entry from source in a child Node process whose runtime
* module hooks serve the already-built entry project and build raw `.ts`
* dependencies on demand.
*
* The child is `node [-r preload...] registerRuntimeHooks.js <source-entry>
* <argv...>` (the bootstrap, run as the main module — not `--import`, so a
* CommonJS `require` chain reaches the hooks). A runtime manifest pins the
* entry project's emit for the hooks; `TTSC_TSGO_BINARY` lets dependency builds
* find tsgo without re-resolving it from inside the hook.
*/
function runPreparedEntry(parsed, execution, cwd, sourceEntry) {
try {
const depCacheDir = node_path_1.default.join(execution.cleanupDir, "deps");
const manifestPath = node_path_1.default.join(execution.cleanupDir, "runtime-manifest.json");
node_fs_1.default.mkdirSync(execution.cleanupDir, { recursive: true });
node_fs_1.default.writeFileSync(manifestPath, JSON.stringify({
depCacheDir,
emitDir: execution.emitDir,
emittedFiles: execution.emittedFiles,
moduleOptions: execution.moduleOptions,
projectRoot: execution.projectRoot,
rootDir: execution.rootDir,
}), "utf8");
const tsgo = (0, resolveTsgo_1.resolveTsgo)({
binary: parsed.binary,
cwd: execution.projectRoot,
}).binary;
const bootstrap = node_path_1.default.join(__dirname, "registerRuntimeHooks.js");
const args = [
"--disable-warning=ExperimentalWarning",
...parsed.preload.flatMap((preload) => [
"-r",
resolvePreload(cwd, preload),
]),
bootstrap,
sourceEntry,
...parsed.passthrough,
];
const runtimeEnv = {
...process.env,
NODE_OPTIONS: appendNodeOption(process.env.NODE_OPTIONS, `--require ${JSON.stringify(node_path_1.default.join(__dirname, "runtimeHookPreload.js"))}`),
TTSC_TSGO_BINARY: process.env.TTSC_TSGO_BINARY ?? tsgo,
TTSX_RUNTIME_MANIFEST: manifestPath,
};
const result = (0, node_child_process_1.spawnSync)(process.execPath, args, {
cwd,
env: runtimeEnv,
stdio: "inherit",
windowsHide: true,
});
if (result.error) {
process.stderr.write(`${result.error.message}\n`);
return 1;
}
return result.status ?? 1;
}
finally {
removeRuntimeOutput(execution.cleanupDir);
}
}
function removeRuntimeOutput(directory) {
try {
node_fs_1.default.rmSync(directory, { force: true, recursive: true });
}
catch {
// Best effort: cleanup must not replace the child process exit status.
}
}
//# sourceMappingURL=runTtsx.js.map