Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/factory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ new TsPrinter().print(node);

Companion helpers: `addSyntheticTrailingComment`, `get`/`setSyntheticLeadingComments`, `get`/`setSyntheticTrailingComments`.

Synthetic comments are side-band data: they do not add properties to the node, so comments can be attached to frozen nodes. Compatible copies loaded in the same JavaScript realm share the versioned weak stores, including a CommonJS `require()` and an ES module `import()` of one installation or multiple installed copies. If a hardened realm prevents adding the registry to its global object before the first copy loads, each module falls back to private weak stores: comments and frozen nodes still work within that copy, but copies cannot exchange comments. A copy from an older release that predates this registry likewise cannot see comments written by a newer copy (or expose its private comments to one); upgrade every copy that exchanges nodes.

## Coverage

The factory and printer cover the constructs most used for code generation: identifiers, literals, the common expressions, types (keyword / reference / union / intersection / array / tuple / type-literal / function / operator / ...), statements, classes & interfaces, enums, functions & arrow functions, and import / export declarations. Coverage is easy to extend — add the node under `src/ast/`, a builder under `src/factory/`, and a `case` to the printer.
Expand Down
60 changes: 54 additions & 6 deletions packages/factory/src/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
*
* The legacy TypeScript compiler stores synthesized comments on a side-band
* `node.emitNode` slot rather than on the node itself; this module reproduces
* that behaviour with a {@link WeakMap} so the {@link import("./ast").Node}
* interfaces stay free of printer-only metadata. {@link TsPrinter} consults
* these stores while emitting and renders the comments verbatim — a leading
* comment is printed before the node, a trailing comment after it.
* that behaviour with realm-shared {@link WeakMap}s so the
* {@link import("./ast").Node} interfaces stay free of printer-only metadata and
* frozen nodes remain valid targets. {@link TsPrinter} consults these stores
* while emitting and renders the comments verbatim — a leading comment is
* printed before the node, a trailing comment after it.
*
* ```typescript
* import factory, {
Expand Down Expand Up @@ -57,8 +58,55 @@ export interface SynthesizedComment {
hasLeadingNewLine?: boolean;
}

const leadingStore = new WeakMap<object, SynthesizedComment[]>();
const trailingStore = new WeakMap<object, SynthesizedComment[]>();
interface SyntheticCommentStores {
leading: WeakMap<object, SynthesizedComment[]>;
trailing: WeakMap<object, SynthesizedComment[]>;
}

/**
* One versioned registry per JavaScript realm.
*
* A package can be loaded through both its CommonJS and ESM entry points, and
* package managers may install multiple physical copies. Module-local WeakMaps
* make those copies silently lose each other's comments even though factory
* nodes are otherwise structural. A well-known, versioned key gives every
* compatible copy the same side-band stores without writing metadata to the
* node, so `Object.freeze(node)` keeps working as it did before.
*
* The version belongs to the stored value shape. A future incompatible shape
* must use another key rather than reinterpret an older registry.
*/
const SYNTHETIC_COMMENT_STORES = Symbol.for(
"@ttsc/factory.syntheticComments.v1",
);

const createCommentStores = (): SyntheticCommentStores => ({
leading: new WeakMap<object, SynthesizedComment[]>(),
trailing: new WeakMap<object, SynthesizedComment[]>(),
});

const localCommentStores = createCommentStores();

const commentStores = (): SyntheticCommentStores => {
const existing: unknown = Reflect.get(globalThis, SYNTHETIC_COMMENT_STORES);
if (existing !== undefined) return existing as SyntheticCommentStores;
try {
Object.defineProperty(globalThis, SYNTHETIC_COMMENT_STORES, {
configurable: false,
enumerable: false,
value: localCommentStores,
writable: false,
});
return localCommentStores;
} catch {
// Hardened realms can prohibit new global properties. Preserve the
// historical single-module behaviour there instead of making package load
// fail; cross-copy sharing necessarily requires a realm-owned rendezvous.
return localCommentStores;
}
};

const { leading: leadingStore, trailing: trailingStore } = commentStores();

const append = (
store: WeakMap<object, SynthesizedComment[]>,
Expand Down
67 changes: 22 additions & 45 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";

import {
CONFIG_EVALUATOR_PROCESS_OPTIONS,
CONFIG_EVALUATOR_STATUS_FD,
configEvaluatorBoundaryEnvironment,
configEvaluatorProcessFailure,
} from "./internal/configEvaluatorFailure";
import type { ITtscLintPlugin, ITtscLintPluginConfig } from "./structures";

export * from "./defaultFormat";
Expand Down Expand Up @@ -1741,41 +1747,30 @@ function evaluateTtsxConfigPlugins(
if (process.env.TTSC_TSGO_BINARY) {
args.unshift("--binary", process.env.TTSC_TSGO_BINARY);
}
const env = nodeConfigLoaderEnv(configPath);
const env = {
...nodeConfigLoaderEnv(configPath),
...configEvaluatorBoundaryEnvironment(),
};
const command = ttsxThroughNodeIfNeeded(ttsxBinary);
const result = spawnSync(command.binary, [...command.prefix, ...args], {
cwd: tempDir,
env,
encoding: "utf8",
maxBuffer: 1024 * 1024 * 16,
stdio: ["ignore", "pipe", "pipe"],
// 60s cap so a runaway top-level await / infinite loop in the
// user's lint config can't hang the entire ttsc invocation.
timeout: 60_000,
...CONFIG_EVALUATOR_PROCESS_OPTIONS,
stdio: [
"ignore",
"pipe",
"pipe",
...Array.from(
{ length: CONFIG_EVALUATOR_STATUS_FD - 2 },
() => "pipe" as const,
),
],
windowsHide: true,
});
forwardConfigEvaluatorStreams(result.stdout, result.stderr);
if (result.error) {
throw new Error(
`@ttsc/lint: failed to spawn ttsx for ${configPath}: ${result.error.message}`,
);
}
if (result.signal) {
throw new Error(
`@ttsc/lint: ttsx evaluation of ${configPath} was killed by signal ${result.signal} ` +
`(likely the 60s timeout). Simplify the config or move heavy work out of top-level.`,
);
}
if (result.status !== 0) {
// The evaluator already said why on its own stderr. Reporting only the
// exit code discards that and leaves the caller — a test, a CI log, a
// user with a typo in one contributor — holding a number.
const reason = configEvaluatorFailureReason(result.stderr);
throw new Error(
`@ttsc/lint: lint config ${configPath} evaluation failed with exit code ${String(result.status)}` +
(reason === "" ? "" : "\n" + reason),
);
}
const processFailure = configEvaluatorProcessFailure(result, configPath);
if (processFailure) throw processFailure;
let payload: {
dependencies?: ConfigDependencyFingerprint[];
entries?: ConfigPluginEntry[];
Expand Down Expand Up @@ -1850,24 +1845,6 @@ function evaluateTtsxConfigPlugins(
}
}

/**
* What the evaluator said before it gave up, trimmed to the part that explains
* it.
*
* The build banner and progress lines are the same on success, so they say
* nothing about the failure; the tail is where the thrown reason lands. Kept to
* a few lines because this is appended to an exception message, not a log.
*/
function configEvaluatorFailureReason(stderr: string | undefined): string {
const lines = (stderr ?? "")
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter((line) => line.trim() !== "");
return lines.slice(-CONFIG_EVALUATOR_REASON_LINES).join("\n");
}

const CONFIG_EVALUATOR_REASON_LINES = 5;

function forwardConfigEvaluatorStreams(
stdout: string | null | undefined,
stderr: string | null | undefined,
Expand Down
99 changes: 99 additions & 0 deletions packages/lint/src/internal/configEvaluatorFailure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
export const CONFIG_EVALUATOR_MAX_BUFFER = 16 * 1024 * 1024;
export const CONFIG_EVALUATOR_TIMEOUT_MS = 60_000;
export const CONFIG_EVALUATOR_TEARDOWN_GRACE_MS = 5_000;
export const CONFIG_EVALUATOR_STATUS_FD = 3;
export const CONFIG_EVALUATOR_PROCESS_OPTIONS = Object.freeze({
killSignal: "SIGKILL" as const,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 (merge-blocking at this commit): SIGKILL makes this particular spawnSync return, but this process is only the ttsx wrapper. runPreparedEntry creates the Node process that actually executes the config/descriptor, and killing the wrapper does not kill that nested runtime on POSIX. The semantic deadline and output cap must be applied to the runtime child itself, with its cause propagated back to this parent.

maxBuffer: CONFIG_EVALUATOR_MAX_BUFFER,
timeout: CONFIG_EVALUATOR_TIMEOUT_MS + CONFIG_EVALUATOR_TEARDOWN_GRACE_MS,
});

interface ConfigEvaluatorProcessResult {
error?: Error;
output?: readonly (string | null)[] | null;
signal: NodeJS.Signals | null;
status: number | null;
stderr: string | null | undefined;
}

/**
* Classify the ways the isolated lint-config evaluator can stop.
*
* Node reports both timeout and max-buffer termination with the configured
* signal, so the process error code must take precedence over the signal. The
* evaluator uses `SIGKILL`: Node's synchronous process API otherwise keeps
* waiting when a POSIX child handles the default `SIGTERM` without exiting. A
* bare signal is an external termination and a non-zero status is an evaluator
* failure whose stderr tail contains the useful user-config diagnostic.
*/
export function configEvaluatorProcessFailure(
result: ConfigEvaluatorProcessResult,
configPath: string,
): Error | undefined {
const code = (result.error as NodeJS.ErrnoException | undefined)?.code;
const nestedCode = result.output?.[CONFIG_EVALUATOR_STATUS_FD]?.trim() ?? "";
if (code === "ETIMEDOUT" || nestedCode === "ETIMEDOUT") {
return new Error(
`@ttsc/lint: ttsx evaluation of ${configPath} timed out after ` +
`${CONFIG_EVALUATOR_TIMEOUT_MS / 1_000} seconds. ` +
"Simplify the config or move heavy work out of top-level.",
);
}
if (code === "ENOBUFS" || nestedCode === "ENOBUFS") {
return new Error(
`@ttsc/lint: ttsx evaluation of ${configPath} exceeded the ` +
`${CONFIG_EVALUATOR_MAX_BUFFER / (1024 * 1024)} MiB output limit. ` +
"Reduce console output from the config and its dependencies.",
);
}
if (result.error) {
return new Error(
`@ttsc/lint: failed to spawn ttsx for ${configPath}: ${result.error.message}`,
);
}
if (result.signal) {
return new Error(
`@ttsc/lint: ttsx evaluation of ${configPath} was killed by signal ${result.signal}.`,
);
}
if (result.status !== 0) {
const reason = configEvaluatorFailureReason(result.stderr);
return new Error(
`@ttsc/lint: lint config ${configPath} evaluation failed with exit code ${String(result.status)}` +
(reason === "" ? "" : "\n" + reason),
);
}
return undefined;
}

/**
* Pass the semantic deadline and private status pipe through the `ttsx` wrapper
* to the runtime child that actually executes the config.
*/
export function configEvaluatorBoundaryEnvironment(
now: number = Date.now(),
): NodeJS.ProcessEnv {
return {
TTSC_TTSX_EVALUATOR_DEADLINE_MS: String(now + CONFIG_EVALUATOR_TIMEOUT_MS),
TTSC_TTSX_EVALUATOR_MAX_BUFFER_BYTES: String(CONFIG_EVALUATOR_MAX_BUFFER),
TTSC_TTSX_EVALUATOR_STATUS_FD: String(CONFIG_EVALUATOR_STATUS_FD),
};
}

/**
* Return the useful tail of evaluator stderr without turning an exception into
* an unbounded duplicate of the already-forwarded child stream.
*/
function configEvaluatorFailureReason(
stderr: string | null | undefined,
): string {
const bounded = (stderr ?? "").slice(-CONFIG_EVALUATOR_REASON_MAX_CHARS);
const lines = bounded
.split(/\r?\n/)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: limiting the number of lines does not limit one giant line. spawnSync can capture up to 16 MiB, and this exception can duplicate nearly all of it. Slice to a small character suffix before selecting the final nonempty lines. Resolved by bb2690de6 with a 100,000-character regression case.

.map((line) => line.trimEnd())
.filter((line) => line.trim() !== "");
return lines.slice(-CONFIG_EVALUATOR_REASON_LINES).join("\n");
}

const CONFIG_EVALUATOR_REASON_LINES = 5;
const CONFIG_EVALUATOR_REASON_MAX_CHARS = 8 * 1024;
2 changes: 2 additions & 0 deletions packages/playground/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ const service = createWorkerCompiler({

The typia pack itself is built by the site (typically with a `pack-typia-sources.cjs`-style script that bundles `typia/`, `@typia/utils`, and `@typia/interface` into a flat JSON map). See the ttsc website's [`build/pack-typia-sources.cjs`](https://github.com/samchon/ttsc/blob/master/website/build/pack-typia-sources.cjs) for the reference implementation.

Source-pack loads have a 30-second default deadline covering both the fetch and JSON body read. Calls for one URL share a single in-flight request; any caller's `signal` or deadline cancels that shared attempt, and a rejected attempt is evicted so the next compiler request retries the mount without starting another WASM runtime. Override the policy with `createTypiaSourcePackMount({ url, signal, timeoutMs })`.

## Runtime npm dependency installer

When the user types `import {v4} from "uuid"`, the shell auto-fetches `uuid` (and its transitive deps) from the npm registry, unpacks the tgz in the browser, and mounts the files into the wasm MemFS, no proxy server needed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,27 +85,51 @@ export function createWorkerCompilerService(
compilerOptions: extraCompilerOptions,
});

// Cache the boot promise across calls. Pre-runtime failures clear the cache
// so the next call can retry. A post-go.run failure stays cached because the
// old runtime cannot be stopped and must never receive a replacement Ready
// bridge in the same Worker. The UI retry resets the compiler client, which
// terminates this Worker and creates a safe replacement.
let boot: Promise<IBootResult> | null = null;
function getBoot(): Promise<IBootResult> {
if (boot) return boot;
boot = (async () => {
const result = await deps.bootTtsc({
// Cache the runtime separately from the optional source-pack mount. A mount
// runs after Go is ready, so retrying a failed mount must reuse that runtime
// instead of calling bootTtsc again in the same Worker.
//
// Pre-runtime failures clear the runtime cache so the next call can retry. A
// post-go.run failure stays cached because the old runtime cannot be stopped
// and must never receive a replacement Ready bridge in the same Worker. The
// UI retry resets the compiler client, which terminates this Worker and
// creates a safe replacement.
let runtimeBoot: Promise<IBootResult> | null = null;
function getRuntimeBoot(): Promise<IBootResult> {
if (runtimeBoot) return runtimeBoot;
let attempt!: Promise<IBootResult>;
attempt = (async () =>
deps.bootTtsc({
wasmUrl: options.wasmUrl,
wasmExecUrl: options.wasmExecUrl,
apiName: options.apiName,
});
}))().catch((err) => {
if (
!(err instanceof BootTtscWorkerTerminationError) &&
runtimeBoot === attempt
) {
runtimeBoot = null;
}
throw err;
});
runtimeBoot = attempt;
return attempt;
}

let boot: Promise<IBootResult> | null = null;
function getBoot(): Promise<IBootResult> {
if (boot) return boot;
let attempt!: Promise<IBootResult>;
attempt = (async () => {
const result = await getRuntimeBoot();
if (typiaPlugin?.mount) await typiaPlugin.mount(result.host, workDir);
return result;
})().catch((err) => {
if (!(err instanceof BootTtscWorkerTerminationError)) boot = null;
if (boot === attempt) boot = null;
throw err;
});
return boot;
boot = attempt;
return attempt;
}

// Serialize every MemFS-mutating call. tgrid queues incoming messages, but
Expand Down
Loading
Loading