ttsc 0.16.3 → 0.16.6

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 (75) hide show
  1. package/README.md +96 -25
  2. package/cmd/graphbench/main.go +135 -0
  3. package/cmd/graphdump/main.go +60 -0
  4. package/cmd/ttscgraph/dump.go +69 -0
  5. package/cmd/ttscgraph/main.go +91 -0
  6. package/cmd/ttscgraph/run_dump_prints_the_graph_as_json_test.go +80 -0
  7. package/cmd/ttscgraph/run_help_version_and_bad_flags_exit_codes_test.go +75 -0
  8. package/cmd/utility-host/main.go +4 -1
  9. package/driver/overlay.go +64 -0
  10. package/driver/program.go +8 -1
  11. package/driver/session.go +85 -0
  12. package/internal/graph/build.go +330 -0
  13. package/internal/graph/build_records_a_node_per_top_level_declaration_test.go +96 -0
  14. package/internal/graph/decorators.go +120 -0
  15. package/internal/graph/decorators_capture_name_and_literal_arguments_test.go +98 -0
  16. package/internal/graph/diagnostics.go +23 -0
  17. package/internal/graph/dump.go +374 -0
  18. package/internal/graph/dump_evidence_skips_leading_trivia_test.go +92 -0
  19. package/internal/graph/dump_serializes_the_full_graph_test.go +146 -0
  20. package/internal/graph/edge_spans_point_to_resolved_usage_test.go +90 -0
  21. package/internal/graph/edges.go +581 -0
  22. package/internal/graph/edges_cover_class_level_references_test.go +93 -0
  23. package/internal/graph/enum_initializer_calls_are_edges_test.go +63 -0
  24. package/internal/graph/exports.go +60 -0
  25. package/internal/graph/file_diagnostics_match_tsgo_code_and_location_test.go +59 -0
  26. package/internal/graph/file_diagnostics_report_code_column_and_message_test.go +69 -0
  27. package/internal/graph/function_locals_do_not_collide_test.go +66 -0
  28. package/internal/graph/gitignore.go +51 -0
  29. package/internal/graph/graph.go +122 -0
  30. package/internal/graph/heritage_edges_cover_implements_and_interface_extends_test.go +76 -0
  31. package/internal/graph/heritage_edges_keep_extends_and_implements_to_same_base_test.go +61 -0
  32. package/internal/graph/heritage_edges_record_extends_versus_implements_origin_test.go +54 -0
  33. package/internal/graph/heritage_edges_traverse_barrels_and_stop_at_external_leaves_test.go +112 -0
  34. package/internal/graph/namespace_dotted_form_qualifies_deeply_test.go +60 -0
  35. package/internal/graph/namespace_members_become_qualified_nodes_and_edges_test.go +100 -0
  36. package/internal/graph/node_identity_is_position_invariant_test.go +84 -0
  37. package/internal/graph/node_modifiers_emit_union_strings_test.go +118 -0
  38. package/internal/graph/nodes_are_marked_exported_through_the_export_table_test.go +61 -0
  39. package/internal/graph/overload_implementation_replaces_signature_test.go +54 -0
  40. package/internal/graph/property_member_edges_remain_owner_visible_test.go +83 -0
  41. package/internal/graph/resolve.go +102 -0
  42. package/internal/graph/resolve_prefers_implementation_declaration_test.go +50 -0
  43. package/internal/graph/resolve_unwraps_barrel_reexport_to_real_declaration_test.go +230 -0
  44. package/internal/graph/type_ref_edges_cover_typeof_as_satisfies_test.go +79 -0
  45. package/internal/graph/type_ref_edges_resolve_named_types_across_files_test.go +52 -0
  46. package/internal/graph/value_access_edges_cover_properties_test.go +96 -0
  47. package/internal/graph/value_call_edges_attribute_function_assignments_to_members_test.go +59 -0
  48. package/internal/graph/value_call_edges_cover_jsx_components_test.go +82 -0
  49. package/internal/graph/value_call_edges_cover_methods_and_constructors_test.go +70 -0
  50. package/internal/graph/value_call_edges_cover_variable_bound_callables_test.go +46 -0
  51. package/internal/graph/value_call_edges_do_not_double_record_invoked_member_access_test.go +65 -0
  52. package/internal/graph/value_call_edges_record_construct_versus_call_origin_test.go +75 -0
  53. package/internal/graph/value_call_edges_resolve_across_files_and_dedupe_test.go +55 -0
  54. package/internal/graph/value_call_edges_skip_self_and_unresolved_test.go +74 -0
  55. package/lib/TtscService.d.ts +55 -0
  56. package/lib/TtscService.js +93 -0
  57. package/lib/TtscService.js.map +1 -0
  58. package/lib/compiler/internal/residentTransformProcess.d.ts +47 -0
  59. package/lib/compiler/internal/residentTransformProcess.js +187 -0
  60. package/lib/compiler/internal/residentTransformProcess.js.map +1 -0
  61. package/lib/compiler/internal/startResidentTransform.d.ts +26 -0
  62. package/lib/compiler/internal/startResidentTransform.js +98 -0
  63. package/lib/compiler/internal/startResidentTransform.js.map +1 -0
  64. package/lib/index.d.ts +1 -0
  65. package/lib/index.js +1 -0
  66. package/lib/index.js.map +1 -1
  67. package/package.json +8 -8
  68. package/shim/ast/shim.go +6 -0
  69. package/shim/checker/shim.go +22 -12
  70. package/src/TtscService.ts +94 -0
  71. package/src/compiler/internal/residentTransformProcess.ts +216 -0
  72. package/src/compiler/internal/startResidentTransform.ts +126 -0
  73. package/src/index.ts +1 -0
  74. package/utility/host.go +8 -2
  75. package/utility/serve.go +158 -0
@@ -0,0 +1,94 @@
1
+ import path from "node:path";
2
+
3
+ import type { ResidentTransformProcess } from "./compiler/internal/residentTransformProcess";
4
+ import { startResidentTransform } from "./compiler/internal/startResidentTransform";
5
+ import type { ITtscCompilerContext } from "./structures/ITtscCompilerContext";
6
+
7
+ /**
8
+ * Resident, incremental transform service for the `ttsc` TypeScript-Go
9
+ * pipeline.
10
+ *
11
+ * Where {@link TtscCompiler.transform} spawns a fresh process and recompiles the
12
+ * whole project on every call, `TtscService` keeps one long-lived host warm: it
13
+ * compiles the project once and then answers per-file transform requests from
14
+ * that warm program. A single service instance transforms many files (a watch
15
+ * server, an editor session, a codegen tool) while paying the project compile
16
+ * once instead of once per file. Sharing one host across separate worker
17
+ * processes (a Metro worker pool) is tracked in samchon/ttsc#255.
18
+ *
19
+ * The shape mirrors a legacy TypeScript `LanguageService`: construct it against
20
+ * a project context, ask it to transform individual files, and dispose it when
21
+ * done. Construction is synchronous (it launches the host); the host compiles
22
+ * in the background, so the first {@link transformFile} resolves once that
23
+ * compile lands.
24
+ *
25
+ * Resident mode runs through the linked-plugin shared host, so the project must
26
+ * declare at least one transform-stage plugin; the constructor throws
27
+ * otherwise. It does not run check-stage plugins (unlike
28
+ * {@link TtscCompiler.transform}); only the program's own type-checking gates an
29
+ * {@link updateFile}.
30
+ */
31
+ export class TtscService {
32
+ private readonly resident: ResidentTransformProcess;
33
+ private readonly projectRoot: string;
34
+
35
+ /**
36
+ * Create a service bound to the given project context and launch its resident
37
+ * host. The context is the same shape {@link TtscCompiler} accepts; it is not
38
+ * replaceable per call.
39
+ */
40
+ public constructor(context: ITtscCompilerContext = {}) {
41
+ const started = startResidentTransform({
42
+ ...context,
43
+ env: context.env ? { ...context.env } : undefined,
44
+ plugins: Array.isArray(context.plugins)
45
+ ? [...context.plugins]
46
+ : context.plugins,
47
+ });
48
+ this.resident = started.process;
49
+ this.projectRoot = started.projectRoot;
50
+ }
51
+
52
+ /**
53
+ * Return the transformed TypeScript source for one file, or `undefined` when
54
+ * the resident program does not contain it (for example a file excluded from
55
+ * the tsconfig). A relative `fileName` is resolved against the project root.
56
+ *
57
+ * Rejects when the resident host failed to compile the project; the rejection
58
+ * carries the host's diagnostics so callers can surface a real build error.
59
+ */
60
+ public async transformFile(fileName: string): Promise<string | undefined> {
61
+ const reply = await this.resident.request({
62
+ file: this.absolutePath(fileName),
63
+ });
64
+ return reply.found === true && typeof reply.typescript === "string"
65
+ ? reply.typescript
66
+ : undefined;
67
+ }
68
+
69
+ /**
70
+ * Apply new in-memory content for one file and re-transform the project, so a
71
+ * subsequent {@link transformFile} reflects the edit without restarting the
72
+ * host. Returns whether the re-transform succeeded; `false` means the edit
73
+ * did not compile and the previous transform is still in effect. A relative
74
+ * `fileName` is resolved against the project root.
75
+ */
76
+ public async updateFile(fileName: string, content: string): Promise<boolean> {
77
+ const reply = await this.resident.request({
78
+ content,
79
+ update: this.absolutePath(fileName),
80
+ });
81
+ return reply.updated === true;
82
+ }
83
+
84
+ /** Terminate the resident host and reject any in-flight requests. */
85
+ public dispose(): void {
86
+ this.resident.dispose();
87
+ }
88
+
89
+ private absolutePath(fileName: string): string {
90
+ return path.isAbsolute(fileName)
91
+ ? fileName
92
+ : path.resolve(this.projectRoot, fileName);
93
+ }
94
+ }
@@ -0,0 +1,216 @@
1
+ import { type ChildProcess, spawn } from "node:child_process";
2
+ import { type Interface, createInterface } from "node:readline";
3
+
4
+ /** Cap on retained stderr so a long-lived host cannot grow it without bound. */
5
+ const STDERR_TAIL_LIMIT = 64 * 1024;
6
+
7
+ /** Options for spawning the resident transform host. */
8
+ export interface ResidentTransformProcessOptions {
9
+ args: readonly string[];
10
+ binary: string;
11
+ cwd?: string;
12
+ env?: NodeJS.ProcessEnv;
13
+ }
14
+
15
+ interface PendingRequest {
16
+ reject: (reason: Error) => void;
17
+ resolve: (reply: Record<string, unknown>) => void;
18
+ }
19
+
20
+ /**
21
+ * Async client for the long-lived `utility-host serve` process.
22
+ *
23
+ * The host transforms the whole project once at startup, caches every file's
24
+ * transformed TypeScript, then answers newline-delimited requests. This class
25
+ * speaks that protocol: each {@link request} writes one JSON line and the host
26
+ * replies with one line, matched FIFO. A transform request (`{"file":...}`) is
27
+ * answered with `{"typescript":...,"found":...}`, and an update request
28
+ * (`{"update":...,"content":...}`) with `{"updated":...}`.
29
+ *
30
+ * One resident process answers every request from one service instead of
31
+ * spawning a fresh `transform` subprocess per call, so a single process pays
32
+ * the project compile once (samchon/ttsc#255).
33
+ */
34
+ export class ResidentTransformProcess {
35
+ private readonly child: ChildProcess;
36
+ private readonly reader: Interface;
37
+ private readonly pending: PendingRequest[] = [];
38
+ private stderr = "";
39
+ private failure: Error | undefined;
40
+
41
+ public constructor(options: ResidentTransformProcessOptions) {
42
+ // Default stdio is "pipe" for stdin/stdout/stderr, which is exactly what the
43
+ // line protocol needs; spelling it out as a string[] would not narrow to
44
+ // StdioOptions, so it is left implicit.
45
+ this.child = spawn(options.binary, [...options.args], {
46
+ cwd: options.cwd,
47
+ env: options.env,
48
+ windowsHide: true,
49
+ });
50
+ const stdout = this.child.stdout;
51
+ const stdin = this.child.stdin;
52
+ if (stdout === null || stdin === null) {
53
+ this.child.kill();
54
+ throw new Error("ttsc: resident transform host has no stdio pipes");
55
+ }
56
+ this.reader = createInterface({ input: stdout });
57
+ this.reader.on("line", (line) => this.onLine(line));
58
+ // Drive completion off stdout's end, not the process "exit" event: every
59
+ // buffered "line" is delivered before "close", so a reply that arrived just
60
+ // before the host exited still resolves its request instead of racing the
61
+ // exit-time rejection.
62
+ this.reader.on("close", () => this.onReaderClose());
63
+ this.child.stderr?.on("data", (chunk: Buffer | string) => {
64
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
65
+ // Keep only the tail: stderr is consulted for the crash message, and a
66
+ // long resident session can write diagnostics on every failed update.
67
+ this.stderr = (this.stderr + text).slice(-STDERR_TAIL_LIMIT);
68
+ });
69
+ this.child.on("error", (error) => this.fail(error));
70
+ // A dead host's pipes emit "error" (EPIPE on POSIX, often ECONNRESET on
71
+ // Windows). Without these listeners Node turns a pipe error into an uncaught
72
+ // exception that crashes the whole consumer process; route them through
73
+ // fail() (stderr is non-critical) so a pipe death fails in-flight requests
74
+ // instead, and also closes the window where stdin.destroyed has not flipped.
75
+ stdin.on("error", (error) => this.fail(error));
76
+ stdout.on("error", (error) => this.fail(error));
77
+ this.child.stderr?.on("error", () => {});
78
+ }
79
+
80
+ /**
81
+ * Send one request to the host and resolve with its parsed JSON reply. The
82
+ * host answers in FIFO order, so the caller interprets the reply shape by the
83
+ * payload it sent (a transform reply vs an update reply). Rejects when the
84
+ * host has already failed or exited, or if writing the request fails.
85
+ */
86
+ public request(
87
+ payload: Record<string, unknown>,
88
+ ): Promise<Record<string, unknown>> {
89
+ if (this.failure !== undefined) {
90
+ return Promise.reject(this.failure);
91
+ }
92
+ const stdin = this.child.stdin;
93
+ if (stdin === null || stdin.destroyed) {
94
+ return Promise.reject(
95
+ new Error("ttsc: resident transform host stdin is closed"),
96
+ );
97
+ }
98
+ return new Promise<Record<string, unknown>>((resolve, reject) => {
99
+ const pending: PendingRequest = { reject, resolve };
100
+ this.pending.push(pending);
101
+ stdin.write(`${JSON.stringify(payload)}\n`, (error) => {
102
+ if (error) {
103
+ this.removePending(pending);
104
+ reject(error);
105
+ }
106
+ });
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Terminate the resident process and reject any in-flight requests. Safe to
112
+ * call more than once.
113
+ */
114
+ public dispose(): void {
115
+ if (this.failure === undefined) {
116
+ // If the host already died, reject with its real exit error (stderr +
117
+ // exit code) rather than a bland "disposed" message.
118
+ this.failure =
119
+ this.child.exitCode !== null || this.child.signalCode !== null
120
+ ? this.exitError()
121
+ : new Error("ttsc: resident transform host disposed");
122
+ }
123
+ const stdin = this.child.stdin;
124
+ if (stdin !== null && !stdin.destroyed) {
125
+ stdin.end();
126
+ }
127
+ this.reader.close();
128
+ this.rejectAll(this.failure);
129
+ if (this.child.exitCode === null && this.child.signalCode === null) {
130
+ this.child.kill();
131
+ }
132
+ }
133
+
134
+ private onLine(line: string): void {
135
+ const trimmed = line.trim();
136
+ if (trimmed.length === 0) {
137
+ return;
138
+ }
139
+ const request = this.pending.shift();
140
+ if (request === undefined) {
141
+ // The host must emit exactly one reply per request, so an unmatched line
142
+ // is a protocol violation that would desync every later reply into the
143
+ // wrong request. Fail fast rather than silently returning wrong output.
144
+ // A line arriving after teardown (failure already set) is benign.
145
+ if (this.failure === undefined) {
146
+ this.fail(
147
+ new Error("ttsc: resident transform host sent an unsolicited reply"),
148
+ );
149
+ }
150
+ return;
151
+ }
152
+ request.resolve(parseReply(trimmed));
153
+ }
154
+
155
+ private onReaderClose(): void {
156
+ if (this.pending.length === 0) {
157
+ return;
158
+ }
159
+ this.rejectAll(this.failure ?? this.exitError());
160
+ }
161
+
162
+ private fail(error: Error): void {
163
+ this.failure ??= error;
164
+ this.rejectAll(this.failure);
165
+ }
166
+
167
+ private rejectAll(error: Error): void {
168
+ while (this.pending.length !== 0) {
169
+ this.pending.shift()!.reject(error);
170
+ }
171
+ }
172
+
173
+ private removePending(target: PendingRequest): void {
174
+ const index = this.pending.indexOf(target);
175
+ if (index !== -1) {
176
+ this.pending.splice(index, 1);
177
+ }
178
+ }
179
+
180
+ private exitError(): Error {
181
+ const detail = this.stderr.trim();
182
+ if (detail.length !== 0) {
183
+ return new Error(detail);
184
+ }
185
+ // Read the child's own exit fields (set synchronously by Node) so the
186
+ // message is accurate even before this instance's "exit" handler would run.
187
+ const code = this.child.exitCode;
188
+ const signalCode = this.child.signalCode;
189
+ const signal = signalCode === null ? "" : `, signal ${signalCode}`;
190
+ return new Error(
191
+ `ttsc: resident transform host exited (code ${code ?? "null"}${signal})`,
192
+ );
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Parse one reply line into a plain object. A malformed line degrades to an
198
+ * empty object rather than throwing, so one bad line never rejects a request
199
+ * the FIFO has already advanced past; the caller reads missing fields as
200
+ * absent.
201
+ */
202
+ function parseReply(line: string): Record<string, unknown> {
203
+ try {
204
+ const parsed = JSON.parse(line) as unknown;
205
+ if (
206
+ typeof parsed === "object" &&
207
+ parsed !== null &&
208
+ !Array.isArray(parsed)
209
+ ) {
210
+ return parsed as Record<string, unknown>;
211
+ }
212
+ } catch {
213
+ // A non-object or unparseable line is treated as an empty reply.
214
+ }
215
+ return {};
216
+ }
@@ -0,0 +1,126 @@
1
+ import path from "node:path";
2
+
3
+ import { loadProjectPlugins } from "../../plugin/internal/loadProjectPlugins";
4
+ import type { ITtscCompilerContext } from "../../structures/ITtscCompilerContext";
5
+ import type { ITtscLoadedNativePlugin } from "../../structures/internal/ITtscLoadedNativePlugin";
6
+ import { readProjectConfig } from "./project/readProjectConfig";
7
+ import { ResidentTransformProcess } from "./residentTransformProcess";
8
+ import { resolveBinary } from "./resolveBinary";
9
+ import { resolveTsgo } from "./resolveTsgo";
10
+ import {
11
+ assertSharedHostCompatibility,
12
+ linkedTransformPlugins,
13
+ selectSharedHostPlugin,
14
+ } from "./sharedHostHelpers";
15
+
16
+ /**
17
+ * A started resident transform host plus the project root its keys are relative
18
+ * to.
19
+ */
20
+ export interface StartedResidentTransform {
21
+ process: ResidentTransformProcess;
22
+ projectRoot: string;
23
+ }
24
+
25
+ /**
26
+ * Start a resident `serve` host for the configured project.
27
+ *
28
+ * Mirrors the plugin spawn of `transformProjectInMemory`, but launches the
29
+ * shared host's `serve` subcommand as one long-lived process instead of a
30
+ * per-call `transform` subprocess. The host compiles the whole project once at
31
+ * startup and then answers per-file requests, so one caller pays the project
32
+ * compile once and reuses it across its own per-file requests.
33
+ *
34
+ * Resident mode runs through the linked-plugin shared host
35
+ * (`cmd/utility-host`), which is the only binary that exposes `serve`. It
36
+ * therefore requires at least one transform-stage plugin; executable transform
37
+ * hosts that own their own process are not served and must use the per-call
38
+ * transform path. Check-stage plugins are not run by the resident host.
39
+ */
40
+ export function startResidentTransform(
41
+ context: ITtscCompilerContext,
42
+ ): StartedResidentTransform {
43
+ const cwd = path.resolve(context.cwd ?? process.cwd());
44
+ const project = readProjectConfig({
45
+ cwd,
46
+ projectRoot: context.projectRoot,
47
+ tsconfig: context.tsconfig,
48
+ });
49
+ const loaded = loadProjectPlugins({
50
+ binary: resolveBinary(context) ?? "",
51
+ cacheDir: context.cacheDir ?? context.env?.TTSC_CACHE_DIR,
52
+ cwd,
53
+ entries: context.plugins,
54
+ projectRoot: context.projectRoot,
55
+ tsconfig: project.path,
56
+ });
57
+ const transformers = loaded.nativePlugins.filter(
58
+ (plugin) => plugin.stage === "transform",
59
+ );
60
+ if (transformers.length === 0) {
61
+ throw new Error(
62
+ "ttsc: TtscService resident mode requires at least one transform-stage plugin; " +
63
+ "use TtscCompiler.transform for projects with only check-stage plugins or none",
64
+ );
65
+ }
66
+ assertSharedHostCompatibility(transformers, "source-to-source");
67
+
68
+ const host = selectSharedHostPlugin(transformers);
69
+ const tsgoBinary = resolveTsgo({ ...context, cwd: project.root }).binary;
70
+ const resident = new ResidentTransformProcess({
71
+ args: [
72
+ "serve",
73
+ `--tsconfig=${project.path}`,
74
+ `--plugins-json=${serializeNativePlugins(transformers)}`,
75
+ `--cwd=${project.root}`,
76
+ ],
77
+ binary: host.binary,
78
+ cwd: project.root,
79
+ env: residentEnv(context, tsgoBinary, loaded.nativePlugins),
80
+ });
81
+ return { process: resident, projectRoot: project.root };
82
+ }
83
+
84
+ /**
85
+ * Build the environment for the resident host spawn. Matches the per-call
86
+ * transform spawn: injects the Node, tsgo, and ttsx binaries so the host never
87
+ * searches PATH, and forwards linked transform plugins via
88
+ * `TTSC_LINKED_PLUGINS_JSON`.
89
+ */
90
+ function residentEnv(
91
+ context: ITtscCompilerContext,
92
+ tsgoBinary: string,
93
+ nativePlugins: readonly ITtscLoadedNativePlugin[],
94
+ ): NodeJS.ProcessEnv {
95
+ const env: NodeJS.ProcessEnv = {
96
+ ...process.env,
97
+ TTSC_NODE_BINARY: process.env.TTSC_NODE_BINARY ?? process.execPath,
98
+ TTSC_TSGO_BINARY: process.env.TTSC_TSGO_BINARY ?? tsgoBinary,
99
+ TTSC_TTSX_BINARY:
100
+ process.env.TTSC_TTSX_BINARY ??
101
+ path.join(__dirname, "..", "..", "launcher", "ttsx.js"),
102
+ ...context.env,
103
+ };
104
+ const linked = linkedTransformPlugins(nativePlugins);
105
+ if (linked.length !== 0) {
106
+ env.TTSC_LINKED_PLUGINS_JSON = serializeNativePlugins(linked);
107
+ }
108
+ return env;
109
+ }
110
+
111
+ /**
112
+ * Serialize the plugin list to the `--plugins-json` /
113
+ * `TTSC_LINKED_PLUGINS_JSON` shape the native host reads: only the fields it
114
+ * needs, to keep the arg short.
115
+ */
116
+ function serializeNativePlugins(
117
+ plugins: readonly ITtscLoadedNativePlugin[],
118
+ ): string {
119
+ return JSON.stringify(
120
+ plugins.map((plugin) => ({
121
+ config: plugin.config,
122
+ name: plugin.name,
123
+ stage: plugin.stage,
124
+ })),
125
+ );
126
+ }
package/src/index.ts CHANGED
@@ -8,4 +8,5 @@
8
8
  */
9
9
 
10
10
  export * from "./TtscCompiler";
11
+ export * from "./TtscService";
11
12
  export * from "./structures/index";
package/utility/host.go CHANGED
@@ -10,12 +10,13 @@ import (
10
10
 
11
11
  shimcompiler "github.com/microsoft/typescript-go/shim/compiler"
12
12
  shimprinter "github.com/microsoft/typescript-go/shim/printer"
13
+ "github.com/microsoft/typescript-go/shim/vfs"
13
14
 
14
15
  "github.com/samchon/ttsc/packages/ttsc/driver"
15
16
  )
16
17
 
17
- // hostOptions is the parsed form of the flags accepted by all three
18
- // subcommands (check, build, transform).
18
+ // hostOptions is the parsed form of the flags accepted by the subcommands
19
+ // (check, build, transform, serve).
19
20
  type hostOptions struct {
20
21
  cwd string
21
22
  emit bool
@@ -28,6 +29,10 @@ type hostOptions struct {
28
29
  singleThreaded bool
29
30
  checkers int
30
31
  tsgoArgs []string
32
+ // fs overrides the filesystem the program loads from. Only the resident serve
33
+ // host sets it (to an OverlayFS), so build/check/transform leave it nil and
34
+ // LoadProgram falls back to the default filesystem.
35
+ fs vfs.FS
31
36
  }
32
37
 
33
38
  // transformResult is the JSON envelope written to stdout by RunTransform.
@@ -251,6 +256,7 @@ func loadUtilityProgram(opts hostOptions) (*driver.Program, []driver.PluginEntry
251
256
  SingleThreaded: opts.singleThreaded,
252
257
  Checkers: opts.checkers,
253
258
  TsgoArgs: opts.tsgoArgs,
259
+ FS: opts.fs,
254
260
  })
255
261
  if err != nil {
256
262
  fmt.Fprintf(os.Stderr, "ttsc utility: %v\n", err)
@@ -0,0 +1,158 @@
1
+ package utility
2
+
3
+ import (
4
+ "bufio"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+
12
+ shimprinter "github.com/microsoft/typescript-go/shim/printer"
13
+
14
+ "github.com/samchon/ttsc/packages/ttsc/driver"
15
+ )
16
+
17
+ // serveRequest is one newline-delimited request the resident host reads from its
18
+ // input stream. It is either a transform request (File set) for the transformed
19
+ // TypeScript of a file, or an update request (Update set) that applies new
20
+ // in-memory content for a file and re-transforms the project.
21
+ type serveRequest struct {
22
+ Content string `json:"content"`
23
+ File string `json:"file"`
24
+ Update string `json:"update"`
25
+ }
26
+
27
+ // serveResponse is the reply to a transform request: the transformed TypeScript
28
+ // for the requested file and whether the resident program had it.
29
+ type serveResponse struct {
30
+ TypeScript string `json:"typescript"`
31
+ Found bool `json:"found"`
32
+ }
33
+
34
+ // serveUpdateResponse is the reply to an update request: whether re-transforming
35
+ // the project with the new content succeeded. A false reply leaves the previous
36
+ // transform in place (for example the edit introduced a type error); the host's
37
+ // diagnostics are written to stderr.
38
+ type serveUpdateResponse struct {
39
+ Updated bool `json:"updated"`
40
+ }
41
+
42
+ // RunServe is the resident transform host. It transforms the whole project once
43
+ // (the expensive compile plus linked-plugin pass) over an in-memory overlay,
44
+ // caches every file's transformed text, then answers newline-delimited requests
45
+ // read from in by writing one JSON reply per line to out, until in reaches EOF:
46
+ //
47
+ // - {"file":"<path>"} returns that file's transformed TypeScript.
48
+ // - {"update":"<path>","content":"<text>"} applies new content for the file
49
+ // and re-transforms, so subsequent transform requests reflect the edit.
50
+ //
51
+ // One resident process answers a request stream without recompiling the project
52
+ // per call and reflects edits without respawning the host. Sharing one host
53
+ // across separate worker processes (a Metro worker pool) is tracked in
54
+ // samchon/ttsc#255.
55
+ //
56
+ // in and out are explicit so the request loop is testable; the utility-host
57
+ // command wires them to os.Stdin and os.Stdout.
58
+ func RunServe(in io.Reader, out io.Writer, args []string) int {
59
+ opts, ok := parseHostOptions("serve", args)
60
+ if !ok {
61
+ return 2
62
+ }
63
+ overlay := driver.NewOverlayFS(driver.DefaultFS())
64
+ opts.fs = overlay
65
+ cache, ok := buildServeCache(opts)
66
+ if !ok {
67
+ return 2
68
+ }
69
+ encoder := json.NewEncoder(out)
70
+ // ReadString imposes no line-length limit. An update request carries a whole
71
+ // file's content on one line, so the line is as large as that content; a
72
+ // bufio.Scanner cap would be an arbitrary ceiling at which the resident host
73
+ // dies, and there is no such natural ceiling on source size.
74
+ reader := bufio.NewReader(in)
75
+ for {
76
+ raw, err := reader.ReadString('\n')
77
+ if line := strings.TrimSpace(raw); line != "" {
78
+ cache = handleServeLine(line, opts, overlay, cache, encoder)
79
+ }
80
+ if err != nil {
81
+ if err != io.EOF {
82
+ fmt.Fprintf(os.Stderr, "ttsc utility serve: read error: %v\n", err)
83
+ return 2
84
+ }
85
+ return 0
86
+ }
87
+ }
88
+ }
89
+
90
+ // handleServeLine answers one request line and returns the cache to use for the
91
+ // next request: the rebuilt cache after a successful update, the unchanged cache
92
+ // otherwise.
93
+ func handleServeLine(
94
+ line string,
95
+ opts hostOptions,
96
+ overlay *driver.OverlayFS,
97
+ cache map[string]string,
98
+ encoder *json.Encoder,
99
+ ) map[string]string {
100
+ var req serveRequest
101
+ if err := json.Unmarshal([]byte(line), &req); err != nil {
102
+ _ = encoder.Encode(serveResponse{})
103
+ return cache
104
+ }
105
+ if req.Update != "" {
106
+ abs := resolveServePath(opts.cwd, req.Update)
107
+ prev, had := overlay.Get(abs)
108
+ overlay.Set(abs, req.Content)
109
+ if rebuilt, ok := buildServeCache(opts); ok {
110
+ _ = encoder.Encode(serveUpdateResponse{Updated: true})
111
+ return rebuilt
112
+ }
113
+ // Roll the failed edit back so a file that does not compile does not poison
114
+ // every later rebuild; the previous transform stays in effect.
115
+ if had {
116
+ overlay.Set(abs, prev)
117
+ } else {
118
+ overlay.Unset(abs)
119
+ }
120
+ _ = encoder.Encode(serveUpdateResponse{Updated: false})
121
+ return cache
122
+ }
123
+ key := apiOutputKey(opts.cwd, resolveServePath(opts.cwd, req.File))
124
+ text, found := cache[key]
125
+ _ = encoder.Encode(serveResponse{TypeScript: text, Found: found})
126
+ return cache
127
+ }
128
+
129
+ // buildServeCache runs the whole-project transform once over the current overlay
130
+ // state and returns the transformed text keyed exactly like the transform
131
+ // subcommand's JSON envelope.
132
+ func buildServeCache(opts hostOptions) (map[string]string, bool) {
133
+ prog, _, ok := loadUtilityProgram(opts)
134
+ if !ok {
135
+ return nil, false
136
+ }
137
+ defer prog.Close()
138
+ if err := prog.ApplyLinkedPlugins(); err != nil {
139
+ fmt.Fprintln(os.Stderr, err)
140
+ return nil, false
141
+ }
142
+ printer := shimprinter.NewPrinter(shimprinter.PrinterOptions{}, shimprinter.PrintHandlers{}, nil)
143
+ cache := map[string]string{}
144
+ for _, file := range prog.SourceFiles() {
145
+ cache[apiOutputKey(opts.cwd, file.FileName())] = shimprinter.EmitSourceFile(printer, file)
146
+ }
147
+ return cache, true
148
+ }
149
+
150
+ // resolveServePath turns a request's file into an absolute path so apiOutputKey
151
+ // computes the same key buildServeCache stored, and so overlay overrides are
152
+ // keyed the way the program asks for them.
153
+ func resolveServePath(cwd, file string) string {
154
+ if filepath.IsAbs(file) {
155
+ return file
156
+ }
157
+ return filepath.Join(cwd, file)
158
+ }