@ttsc/playground 0.18.4 → 0.19.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 (43) hide show
  1. package/README.md +1 -1
  2. package/lib/src/compiler/createWorkerCompiler.js +7 -191
  3. package/lib/src/compiler/createWorkerCompiler.js.map +1 -1
  4. package/lib/src/compiler/internal/createWorkerCompilerService.d.ts +18 -0
  5. package/lib/src/compiler/internal/createWorkerCompilerService.js +272 -0
  6. package/lib/src/compiler/internal/createWorkerCompilerService.js.map +1 -0
  7. package/lib/src/npm/collectExternalPackageNames.js +253 -9
  8. package/lib/src/npm/collectExternalPackageNames.js.map +1 -1
  9. package/lib/src/react/ConsoleViewer.js +17 -17
  10. package/lib/src/react/ConsoleViewer.js.map +1 -1
  11. package/lib/src/react/DependencyProgressModal.js +1 -1
  12. package/lib/src/react/DependencyProgressModal.js.map +1 -1
  13. package/lib/src/react/DiagnosticsPanel.js +2 -2
  14. package/lib/src/react/DiagnosticsPanel.js.map +1 -1
  15. package/lib/src/react/ExamplePicker.js +2 -2
  16. package/lib/src/react/ExamplePicker.js.map +1 -1
  17. package/lib/src/react/LintPane.js +3 -3
  18. package/lib/src/react/LintPane.js.map +1 -1
  19. package/lib/src/react/OptionsPanel.js +1 -1
  20. package/lib/src/react/OptionsPanel.js.map +1 -1
  21. package/lib/src/react/PlaygroundShell.js +14 -13
  22. package/lib/src/react/PlaygroundShell.js.map +1 -1
  23. package/lib/src/react/ResultViewer.d.ts +2 -2
  24. package/lib/src/react/ResultViewer.js +3 -3
  25. package/lib/src/react/ResultViewer.js.map +1 -1
  26. package/lib/src/react/SourceEditor.js +1 -1
  27. package/lib/src/react/SourceEditor.js.map +1 -1
  28. package/lib/src/sandbox/createSandboxRequire.js +34 -9
  29. package/lib/src/sandbox/createSandboxRequire.js.map +1 -1
  30. package/package.json +3 -3
  31. package/src/compiler/createWorkerCompiler.ts +8 -236
  32. package/src/compiler/internal/createWorkerCompilerService.ts +358 -0
  33. package/src/npm/collectExternalPackageNames.ts +262 -9
  34. package/src/react/ConsoleViewer.tsx +18 -18
  35. package/src/react/DependencyProgressModal.tsx +11 -11
  36. package/src/react/DiagnosticsPanel.tsx +11 -11
  37. package/src/react/ExamplePicker.tsx +7 -7
  38. package/src/react/LintPane.tsx +5 -5
  39. package/src/react/OptionsPanel.tsx +10 -10
  40. package/src/react/PlaygroundShell.tsx +64 -74
  41. package/src/react/ResultViewer.tsx +4 -4
  42. package/src/react/SourceEditor.tsx +2 -3
  43. package/src/sandbox/createSandboxRequire.ts +32 -9
@@ -5,29 +5,16 @@
5
5
  // the site's chosen module shape, registers the typia/lint plugin verbs (or
6
6
  // the site-provided overrides), and serializes every MemFS-mutating call onto
7
7
  // a single chain so concurrent compiles never corrupt each other.
8
- import {
9
- type IBootResult,
10
- type ITtscCompileResult,
11
- bootTtsc,
12
- parseResult,
13
- } from "@ttsc/wasm";
8
+ //
9
+ // The pipeline logic lives in `createWorkerCompilerService`, which takes the
10
+ // `@ttsc/wasm` boot / result-parsing functions as injected dependencies. This
11
+ // wrapper is the only place that imports them at runtime, so the service can be
12
+ // tested against a fake `IBootResult` without building or booting WASM.
13
+ import { bootTtsc, parseResult } from "@ttsc/wasm";
14
14
 
15
15
  import type { ICompilerService } from "../structures/ICompilerService";
16
16
  import type { ICreateWorkerCompilerOptions } from "../structures/ICreateWorkerCompilerOptions";
17
- import type { ITransformOptions } from "../structures/ITransformOptions";
18
- import { DEFAULT_ENTRY_FILE } from "./DEFAULT_ENTRY_FILE";
19
- import { DEFAULT_LINT_PLUGIN_NAME } from "./DEFAULT_LINT_PLUGIN_NAME";
20
- import { DEFAULT_TSCONFIG_PATH } from "./DEFAULT_TSCONFIG_PATH";
21
- import { DEFAULT_TYPIA_PLUGIN_NAME } from "./DEFAULT_TYPIA_PLUGIN_NAME";
22
- import { DEFAULT_WORK_DIR } from "./DEFAULT_WORK_DIR";
23
- import { buildTsconfigJSON } from "./buildTsconfigJSON";
24
- import { installDependenciesIntoMemFS } from "./installDependenciesIntoMemFS";
25
- import { joinUnder } from "./internal/joinUnder";
26
- import { parseLintDiagnostics } from "./internal/parseLintDiagnostics";
27
- import { safeParseTypiaTransform } from "./internal/safeParseTypiaTransform";
28
- import { mapDiagnostic } from "./mapDiagnostic";
29
- import { normalizeError } from "./normalizeError";
30
- import { pickEmittedJS } from "./pickEmittedJS";
17
+ import { createWorkerCompilerService } from "./internal/createWorkerCompilerService";
31
18
 
32
19
  /**
33
20
  * Build an `ICompilerService` ready to register with tgrid's `WorkerServer`.
@@ -54,220 +41,5 @@ import { pickEmittedJS } from "./pickEmittedJS";
54
41
  export function createWorkerCompiler(
55
42
  options: ICreateWorkerCompilerOptions,
56
43
  ): ICompilerService {
57
- const workDir = options.workDir ?? DEFAULT_WORK_DIR;
58
- const tsconfigPath = options.tsconfigPath ?? DEFAULT_TSCONFIG_PATH;
59
- const entryFile = options.entryFile ?? DEFAULT_ENTRY_FILE;
60
-
61
- const typiaPlugin =
62
- options.typiaPlugin === false ? null : (options.typiaPlugin ?? {});
63
- const typiaPluginName = typiaPlugin?.name ?? DEFAULT_TYPIA_PLUGIN_NAME;
64
- const typiaTransformModule =
65
- typiaPlugin?.transformModule ?? "typia/lib/transform";
66
-
67
- const lintPlugin =
68
- options.lintPlugin === false ? null : (options.lintPlugin ?? {});
69
- const lintPluginName = lintPlugin?.name ?? DEFAULT_LINT_PLUGIN_NAME;
70
-
71
- // tsconfig variants. ESM for the "Compiled JS" preview lane, CommonJS for
72
- // the bundle/Execute lane (whose `new Function` driver expects CJS).
73
- const tsconfigPlugins = typiaPlugin
74
- ? [{ transform: typiaTransformModule }]
75
- : [];
76
- const extraCompilerOptions = {
77
- ...(options.extraCompilerOptions ?? {}),
78
- ...(tsconfigPlugins.length > 0 ? { plugins: tsconfigPlugins } : {}),
79
- };
80
- const tsconfigESM = buildTsconfigJSON({
81
- module: "ESNext",
82
- compilerOptions: extraCompilerOptions,
83
- });
84
- const tsconfigCJS = buildTsconfigJSON({
85
- module: "CommonJS",
86
- compilerOptions: extraCompilerOptions,
87
- });
88
-
89
- // Cache the boot promise across calls. If the boot rejects we clear the
90
- // cache so the next call retries — otherwise every later compile/bundle/
91
- // lint would replay the same rejection forever (page reload required).
92
- // Mirrors the createCompilerClient (UI-side) pattern.
93
- let boot: Promise<IBootResult> | null = null;
94
- function getBoot(): Promise<IBootResult> {
95
- if (boot) return boot;
96
- boot = (async () => {
97
- const result = await bootTtsc({
98
- wasmUrl: options.wasmUrl,
99
- wasmExecUrl: options.wasmExecUrl,
100
- apiName: options.apiName,
101
- });
102
- if (typiaPlugin?.mount) await typiaPlugin.mount(result.host, workDir);
103
- return result;
104
- })().catch((err) => {
105
- boot = null;
106
- throw err;
107
- });
108
- return boot;
109
- }
110
-
111
- // Serialize every MemFS-mutating call. tgrid queues incoming messages, but
112
- // the wasm-side host runs them concurrently — a fast keystroke could
113
- // interleave a bundle's tsconfig rewrite with a lint pass and corrupt
114
- // either result. The chain keeps compile/bundle/lint linear.
115
- let busy: Promise<unknown> = Promise.resolve();
116
- const enqueue = <T>(fn: () => Promise<T>): Promise<T> => {
117
- const next = busy.then(fn, fn);
118
- busy = next.catch(() => {});
119
- return next;
120
- };
121
-
122
- const projectFiles = (
123
- source: string,
124
- tsconfigText: string,
125
- ): Record<string, string> => ({
126
- [`${workDir}/${tsconfigPath}`]: tsconfigText,
127
- [`${workDir}/${entryFile}`]: source,
128
- });
129
-
130
- const writeProject = (
131
- host: IBootResult["host"],
132
- files: Record<string, string>,
133
- ): void => {
134
- for (const [path, text] of Object.entries(files)) {
135
- host.writeFile(path, text);
136
- }
137
- };
138
-
139
- // Typia transform — run the registered typia plugin's `transform` verb,
140
- // parse its JSON stdout, and write the rewritten TS back into MemFS so the
141
- // subsequent `api.build` sees the post-transform text.
142
- const applyTypiaTransform = async (
143
- api: IBootResult["api"],
144
- host: IBootResult["host"],
145
- ): Promise<void> => {
146
- if (!typiaPlugin) return;
147
- try {
148
- const raw = await api.plugin({
149
- name: typiaPluginName,
150
- command: "transform",
151
- cwd: workDir,
152
- tsconfig: tsconfigPath,
153
- output: "ts",
154
- });
155
- if (!raw.stdout) return;
156
- const transformed = safeParseTypiaTransform(raw.stdout);
157
- if (!transformed) return;
158
- for (const [rel, text] of Object.entries(transformed.typescript)) {
159
- host.writeFile(joinUnder(workDir, rel), text);
160
- }
161
- } catch {
162
- // Swallow plugin spawn / JSON parse failures so the user still sees the
163
- // tsgo compile diagnostics from the subsequent build call. Real
164
- // user-facing errors (e.g. typia rule violations) flow through the
165
- // wasm-side diagnostics path.
166
- }
167
- };
168
-
169
- const runBuildPipeline = async (
170
- source: string,
171
- runTypia: boolean,
172
- tsconfigText: string,
173
- ): Promise<ICompilerService.IResult> => {
174
- try {
175
- const { api, host } = await getBoot();
176
- writeProject(host, projectFiles(source, tsconfigText));
177
- if (runTypia) await applyTypiaTransform(api, host);
178
- const raw = await api.build({
179
- cwd: workDir,
180
- tsconfig: tsconfigPath,
181
- });
182
- if (raw.code !== 0 && !raw.result) {
183
- return {
184
- type: "error",
185
- target: "javascript",
186
- value: {
187
- message:
188
- raw.stderr || "ttsc: build failed without a result payload",
189
- },
190
- };
191
- }
192
- const parsed = parseResult<ITtscCompileResult>(raw);
193
- if (!parsed) {
194
- return {
195
- type: "error",
196
- target: "javascript",
197
- value: { message: "ttsc: result JSON could not be parsed" },
198
- };
199
- }
200
- const diagnostics = (parsed.diagnostics ?? []).map((d) =>
201
- mapDiagnostic(d, source),
202
- );
203
- const js = pickEmittedJS(parsed.output ?? {}, entryFile);
204
- const errors = diagnostics.filter((d) => d.severity === "error");
205
- if (errors.length > 0) {
206
- return {
207
- type: "failure",
208
- target: "javascript",
209
- value: js ?? "",
210
- diagnostics,
211
- };
212
- }
213
- return { type: "success", target: "javascript", value: js ?? "" };
214
- } catch (error) {
215
- return {
216
- type: "error",
217
- target: "javascript",
218
- value: normalizeError(error),
219
- };
220
- }
221
- };
222
-
223
- // Lint pipeline. The lint plugin emits tsgo-style pretty diagnostics on
224
- // stderr; we parse those lines back into IDiagnostic so the UI can render
225
- // them in the Lint tab.
226
- const runLintPipeline = async (
227
- source: string,
228
- ): Promise<ICompilerService.ILintResult> => {
229
- if (!lintPlugin) return { diagnostics: [] };
230
- try {
231
- const { api, host } = await getBoot();
232
- writeProject(host, projectFiles(source, tsconfigESM));
233
- const raw = await api.plugin({
234
- name: lintPluginName,
235
- command: "check",
236
- cwd: workDir,
237
- tsconfig: tsconfigPath,
238
- });
239
- return { diagnostics: parseLintDiagnostics(raw.stderr, source) };
240
- } catch {
241
- return { diagnostics: [] };
242
- }
243
- };
244
-
245
- return {
246
- installDependencies: (props) =>
247
- enqueue(async () => {
248
- const { host } = await getBoot();
249
- return installDependenciesIntoMemFS(host, workDir, props);
250
- }),
251
- compile: (props) =>
252
- enqueue(() =>
253
- runBuildPipeline(
254
- props.source,
255
- shouldRunTypia(props.options),
256
- tsconfigESM,
257
- ),
258
- ),
259
- bundle: (props) =>
260
- enqueue(() =>
261
- runBuildPipeline(
262
- props.source,
263
- shouldRunTypia(props.options),
264
- tsconfigCJS,
265
- ),
266
- ),
267
- lint: (props) => enqueue(() => runLintPipeline(props.source)),
268
- };
269
- }
270
-
271
- function shouldRunTypia(options?: ITransformOptions): boolean {
272
- return options?.typia !== false;
44
+ return createWorkerCompilerService({ bootTtsc, parseResult }, options);
273
45
  }
@@ -0,0 +1,358 @@
1
+ // Worker-side playground compiler core.
2
+ //
3
+ // This is the dependency-injected implementation behind the public
4
+ // `createWorkerCompiler`. It takes the `@ttsc/wasm` boot + result-parsing
5
+ // functions as `deps` instead of importing them at runtime, so the whole
6
+ // service — including plugin success/failure interpretation — can be exercised
7
+ // against a fake `IBootResult` without building or booting WASM. The public
8
+ // wrapper supplies the real `bootTtsc` / `parseResult`.
9
+ import type {
10
+ IBootResult,
11
+ IBootTtscOptions,
12
+ ITtscCompileResult,
13
+ ITtscResult,
14
+ } from "@ttsc/wasm";
15
+
16
+ import type { ICompilerService } from "../../structures/ICompilerService";
17
+ import type { ICreateWorkerCompilerOptions } from "../../structures/ICreateWorkerCompilerOptions";
18
+ import type { ITransformOptions } from "../../structures/ITransformOptions";
19
+ import { DEFAULT_ENTRY_FILE } from "../DEFAULT_ENTRY_FILE";
20
+ import { DEFAULT_LINT_PLUGIN_NAME } from "../DEFAULT_LINT_PLUGIN_NAME";
21
+ import { DEFAULT_TSCONFIG_PATH } from "../DEFAULT_TSCONFIG_PATH";
22
+ import { DEFAULT_TYPIA_PLUGIN_NAME } from "../DEFAULT_TYPIA_PLUGIN_NAME";
23
+ import { DEFAULT_WORK_DIR } from "../DEFAULT_WORK_DIR";
24
+ import { buildTsconfigJSON } from "../buildTsconfigJSON";
25
+ import { installDependenciesIntoMemFS } from "../installDependenciesIntoMemFS";
26
+ import { mapDiagnostic } from "../mapDiagnostic";
27
+ import { normalizeError } from "../normalizeError";
28
+ import { pickEmittedJS } from "../pickEmittedJS";
29
+ import { joinUnder } from "./joinUnder";
30
+ import { parseLintDiagnostics } from "./parseLintDiagnostics";
31
+ import { safeParseTypiaTransform } from "./safeParseTypiaTransform";
32
+
33
+ /**
34
+ * Runtime `@ttsc/wasm` collaborators the worker compiler needs. The public
35
+ * `createWorkerCompiler` binds the real implementations; tests bind fakes that
36
+ * resolve a synthetic `IBootResult` so the plugin-envelope handling can be
37
+ * verified without a real WASM boot.
38
+ */
39
+ export interface IWorkerCompilerDeps {
40
+ bootTtsc: (options: IBootTtscOptions) => Promise<IBootResult>;
41
+ parseResult: <T>(result: ITtscResult) => T | null;
42
+ }
43
+
44
+ /** Outcome of a transform-verb plugin call before the build proceeds. */
45
+ type ITransformOutcome = { ok: true } | { ok: false; message: string };
46
+
47
+ /**
48
+ * Dependency-injected worker `ICompilerService` factory. See
49
+ * `createWorkerCompiler` for the public entry documentation.
50
+ */
51
+ export function createWorkerCompilerService(
52
+ deps: IWorkerCompilerDeps,
53
+ options: ICreateWorkerCompilerOptions,
54
+ ): ICompilerService {
55
+ const workDir = options.workDir ?? DEFAULT_WORK_DIR;
56
+ const tsconfigPath = options.tsconfigPath ?? DEFAULT_TSCONFIG_PATH;
57
+ const entryFile = options.entryFile ?? DEFAULT_ENTRY_FILE;
58
+
59
+ const typiaPlugin =
60
+ options.typiaPlugin === false ? null : (options.typiaPlugin ?? {});
61
+ const typiaPluginName = typiaPlugin?.name ?? DEFAULT_TYPIA_PLUGIN_NAME;
62
+ const typiaTransformModule =
63
+ typiaPlugin?.transformModule ?? "typia/lib/transform";
64
+
65
+ const lintPlugin =
66
+ options.lintPlugin === false ? null : (options.lintPlugin ?? {});
67
+ const lintPluginName = lintPlugin?.name ?? DEFAULT_LINT_PLUGIN_NAME;
68
+
69
+ // tsconfig variants. ESM for the "Compiled JS" preview lane, CommonJS for
70
+ // the bundle/Execute lane (whose `new Function` driver expects CJS).
71
+ const tsconfigPlugins = typiaPlugin
72
+ ? [{ transform: typiaTransformModule }]
73
+ : [];
74
+ const extraCompilerOptions = {
75
+ ...(options.extraCompilerOptions ?? {}),
76
+ ...(tsconfigPlugins.length > 0 ? { plugins: tsconfigPlugins } : {}),
77
+ };
78
+ const tsconfigESM = buildTsconfigJSON({
79
+ module: "ESNext",
80
+ compilerOptions: extraCompilerOptions,
81
+ });
82
+ const tsconfigCJS = buildTsconfigJSON({
83
+ module: "CommonJS",
84
+ compilerOptions: extraCompilerOptions,
85
+ });
86
+
87
+ // Cache the boot promise across calls. If the boot rejects we clear the
88
+ // cache so the next call retries — otherwise every later compile/bundle/
89
+ // lint would replay the same rejection forever (page reload required).
90
+ // Mirrors the createCompilerClient (UI-side) pattern.
91
+ let boot: Promise<IBootResult> | null = null;
92
+ function getBoot(): Promise<IBootResult> {
93
+ if (boot) return boot;
94
+ boot = (async () => {
95
+ const result = await deps.bootTtsc({
96
+ wasmUrl: options.wasmUrl,
97
+ wasmExecUrl: options.wasmExecUrl,
98
+ apiName: options.apiName,
99
+ });
100
+ if (typiaPlugin?.mount) await typiaPlugin.mount(result.host, workDir);
101
+ return result;
102
+ })().catch((err) => {
103
+ boot = null;
104
+ throw err;
105
+ });
106
+ return boot;
107
+ }
108
+
109
+ // Serialize every MemFS-mutating call. tgrid queues incoming messages, but
110
+ // the wasm-side host runs them concurrently — a fast keystroke could
111
+ // interleave a bundle's tsconfig rewrite with a lint pass and corrupt
112
+ // either result. The chain keeps compile/bundle/lint linear.
113
+ let busy: Promise<unknown> = Promise.resolve();
114
+ const enqueue = <T>(fn: () => Promise<T>): Promise<T> => {
115
+ const next = busy.then(fn, fn);
116
+ busy = next.catch(() => {});
117
+ return next;
118
+ };
119
+
120
+ const projectFiles = (
121
+ source: string,
122
+ tsconfigText: string,
123
+ ): Record<string, string> => ({
124
+ [`${workDir}/${tsconfigPath}`]: tsconfigText,
125
+ [`${workDir}/${entryFile}`]: source,
126
+ });
127
+
128
+ const writeProject = (
129
+ host: IBootResult["host"],
130
+ files: Record<string, string>,
131
+ ): void => {
132
+ for (const [path, text] of Object.entries(files)) {
133
+ host.writeFile(path, text);
134
+ }
135
+ };
136
+
137
+ // Typia transform — run the registered typia plugin's `transform` verb,
138
+ // parse its JSON stdout, and write the rewritten TS back into MemFS so the
139
+ // subsequent `api.build` sees the post-transform text.
140
+ //
141
+ // A configured transform that does not complete successfully is a failure,
142
+ // NOT a silent fall-through to compiling the original source: a nonzero
143
+ // process envelope, a rejected call, or output that cannot be parsed all
144
+ // return `{ ok: false }` so `runBuildPipeline` surfaces the failure instead
145
+ // of emitting untransformed JavaScript.
146
+ const applyTypiaTransform = async (
147
+ api: IBootResult["api"],
148
+ host: IBootResult["host"],
149
+ ): Promise<ITransformOutcome> => {
150
+ if (!typiaPlugin) return { ok: true };
151
+ let raw: ITtscResult;
152
+ try {
153
+ raw = await api.plugin({
154
+ name: typiaPluginName,
155
+ command: "transform",
156
+ cwd: workDir,
157
+ tsconfig: tsconfigPath,
158
+ output: "ts",
159
+ });
160
+ } catch (error) {
161
+ return { ok: false, message: messageOf(error) };
162
+ }
163
+ if (raw.code !== 0) {
164
+ return {
165
+ ok: false,
166
+ message: pluginFailureMessage(raw, typiaPluginName, "transform"),
167
+ };
168
+ }
169
+ // Success with no payload: nothing to rewrite, let the build run as-is.
170
+ if (!raw.stdout) return { ok: true };
171
+ const transformed = safeParseTypiaTransform(raw.stdout);
172
+ if (!transformed) {
173
+ return {
174
+ ok: false,
175
+ message: `ttsc: ${typiaPluginName} transform produced unparseable output`,
176
+ };
177
+ }
178
+ for (const [rel, text] of Object.entries(transformed.typescript)) {
179
+ host.writeFile(joinUnder(workDir, rel), text);
180
+ }
181
+ return { ok: true };
182
+ };
183
+
184
+ const runBuildPipeline = async (
185
+ source: string,
186
+ runTypia: boolean,
187
+ tsconfigText: string,
188
+ ): Promise<ICompilerService.IResult> => {
189
+ try {
190
+ const { api, host } = await getBoot();
191
+ writeProject(host, projectFiles(source, tsconfigText));
192
+ if (runTypia) {
193
+ const outcome = await applyTypiaTransform(api, host);
194
+ if (!outcome.ok) {
195
+ return {
196
+ type: "error",
197
+ target: "javascript",
198
+ value: { message: outcome.message },
199
+ };
200
+ }
201
+ }
202
+ const raw = await api.build({
203
+ cwd: workDir,
204
+ tsconfig: tsconfigPath,
205
+ });
206
+ if (raw.code !== 0 && !raw.result) {
207
+ return {
208
+ type: "error",
209
+ target: "javascript",
210
+ value: {
211
+ message:
212
+ raw.stderr || "ttsc: build failed without a result payload",
213
+ },
214
+ };
215
+ }
216
+ const parsed = deps.parseResult<ITtscCompileResult>(raw);
217
+ if (!parsed) {
218
+ return {
219
+ type: "error",
220
+ target: "javascript",
221
+ value: { message: "ttsc: result JSON could not be parsed" },
222
+ };
223
+ }
224
+ const diagnostics = (parsed.diagnostics ?? []).map((d) =>
225
+ mapDiagnostic(d, source),
226
+ );
227
+ const js = pickEmittedJS(parsed.output ?? {}, entryFile);
228
+ const errors = diagnostics.filter((d) => d.severity === "error");
229
+ if (errors.length > 0) {
230
+ return {
231
+ type: "failure",
232
+ target: "javascript",
233
+ value: js ?? "",
234
+ diagnostics,
235
+ };
236
+ }
237
+ return { type: "success", target: "javascript", value: js ?? "" };
238
+ } catch (error) {
239
+ return {
240
+ type: "error",
241
+ target: "javascript",
242
+ value: normalizeError(error),
243
+ };
244
+ }
245
+ };
246
+
247
+ // Lint pipeline. The lint plugin emits tsgo-style pretty diagnostics on
248
+ // stderr; we parse those lines back into IDiagnostic so the UI can render
249
+ // them in the Lint tab.
250
+ //
251
+ // A finished lint run reports its findings the same way whether the process
252
+ // exited 0 or nonzero (a linter exits nonzero when it finds violations), so
253
+ // parsed diagnostics are always returned as-is. But a plugin that failed to
254
+ // run — a rejected call, or a nonzero exit whose stderr yields no diagnostic
255
+ // — must NOT collapse into an empty (clean) result; it surfaces a single
256
+ // error diagnostic so the failure stays visible.
257
+ const runLintPipeline = async (
258
+ source: string,
259
+ ): Promise<ICompilerService.ILintResult> => {
260
+ if (!lintPlugin) return { diagnostics: [] };
261
+ let raw: ITtscResult;
262
+ try {
263
+ const { api, host } = await getBoot();
264
+ writeProject(host, projectFiles(source, tsconfigESM));
265
+ raw = await api.plugin({
266
+ name: lintPluginName,
267
+ command: "check",
268
+ cwd: workDir,
269
+ tsconfig: tsconfigPath,
270
+ });
271
+ } catch (error) {
272
+ return { diagnostics: [pluginFailureDiagnostic(messageOf(error))] };
273
+ }
274
+ const diagnostics = parseLintDiagnostics(raw.stderr, source);
275
+ if (diagnostics.length === 0 && raw.code !== 0) {
276
+ return {
277
+ diagnostics: [
278
+ pluginFailureDiagnostic(
279
+ pluginFailureMessage(raw, lintPluginName, "check"),
280
+ ),
281
+ ],
282
+ };
283
+ }
284
+ return { diagnostics };
285
+ };
286
+
287
+ return {
288
+ installDependencies: (props) =>
289
+ enqueue(async () => {
290
+ const { host } = await getBoot();
291
+ return installDependenciesIntoMemFS(host, workDir, props);
292
+ }),
293
+ compile: (props) =>
294
+ enqueue(() =>
295
+ runBuildPipeline(
296
+ props.source,
297
+ shouldRunTypia(props.options),
298
+ tsconfigESM,
299
+ ),
300
+ ),
301
+ bundle: (props) =>
302
+ enqueue(() =>
303
+ runBuildPipeline(
304
+ props.source,
305
+ shouldRunTypia(props.options),
306
+ tsconfigCJS,
307
+ ),
308
+ ),
309
+ lint: (props) => enqueue(() => runLintPipeline(props.source)),
310
+ };
311
+ }
312
+
313
+ function shouldRunTypia(options?: ITransformOptions): boolean {
314
+ return options?.typia !== false;
315
+ }
316
+
317
+ /** Human-readable message for a nonzero plugin process envelope. */
318
+ function pluginFailureMessage(
319
+ raw: ITtscResult,
320
+ name: string,
321
+ verb: string,
322
+ ): string {
323
+ return (
324
+ raw.stderr.trim() ||
325
+ raw.stdout.trim() ||
326
+ `ttsc: ${name} ${verb} plugin failed (exit code ${raw.code})`
327
+ );
328
+ }
329
+
330
+ /** Message of a rejected plugin call. */
331
+ function messageOf(error: unknown): string {
332
+ if (error instanceof Error) return error.message;
333
+ if (
334
+ error &&
335
+ typeof error === "object" &&
336
+ "message" in (error as Record<string, unknown>)
337
+ )
338
+ return String((error as { message: unknown }).message);
339
+ return String(error);
340
+ }
341
+
342
+ /**
343
+ * A synthetic error diagnostic representing a lint plugin that failed to run.
344
+ * Anchored at the start of the source so it renders like any other finding and
345
+ * keeps the lint result non-empty (never a false "clean" report).
346
+ */
347
+ function pluginFailureDiagnostic(
348
+ message: string,
349
+ ): ICompilerService.IDiagnostic {
350
+ return {
351
+ line: 1,
352
+ column: 1,
353
+ length: 1,
354
+ severity: "error",
355
+ message,
356
+ code: "PLUGIN",
357
+ };
358
+ }