ttsc 0.14.1 → 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.
Files changed (45) hide show
  1. package/driver/emit_plugin.go +131 -0
  2. package/driver/plugins.go +37 -0
  3. package/go.mod +0 -3
  4. package/go.sum +0 -4
  5. package/lib/compiler/internal/resolveEmittedJavaScript.js +25 -4
  6. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  7. package/lib/launcher/internal/prepareExecution.d.ts +4 -1
  8. package/lib/launcher/internal/prepareExecution.js +13 -17
  9. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  10. package/lib/launcher/internal/registerRuntimeHooks.d.ts +1 -0
  11. package/lib/launcher/internal/registerRuntimeHooks.js +54 -0
  12. package/lib/launcher/internal/registerRuntimeHooks.js.map +1 -0
  13. package/lib/launcher/internal/runTtsx.js +49 -370
  14. package/lib/launcher/internal/runTtsx.js.map +1 -1
  15. package/lib/launcher/internal/runtimeHookPreload.d.ts +1 -0
  16. package/lib/launcher/internal/runtimeHookPreload.js +16 -0
  17. package/lib/launcher/internal/runtimeHookPreload.js.map +1 -0
  18. package/lib/launcher/internal/runtimeHooks.d.ts +21 -0
  19. package/lib/launcher/internal/runtimeHooks.js +810 -0
  20. package/lib/launcher/internal/runtimeHooks.js.map +1 -0
  21. package/lib/plugin/internal/buildSourcePlugin.js +120 -16
  22. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  23. package/lib/plugin/internal/loadProjectPlugins.js +15 -4
  24. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  25. package/package.json +9 -9
  26. package/shim/ast/parent.go +31 -0
  27. package/shim/compiler/emitassemble.go +30 -0
  28. package/shim/compiler/emithook.go +22 -0
  29. package/shim/compiler/go.mod +9 -0
  30. package/shim/compiler/go.sum +22 -0
  31. package/shim/core/modulekind.go +10 -0
  32. package/shim/printer/emithost.go +24 -0
  33. package/shim/printer/go.mod +9 -0
  34. package/shim/printer/go.sum +22 -0
  35. package/shim/transformers/go.mod +14 -0
  36. package/shim/transformers/go.sum +20 -0
  37. package/shim/transformers/shim.go +15 -0
  38. package/src/compiler/internal/resolveEmittedJavaScript.ts +35 -4
  39. package/src/launcher/internal/prepareExecution.ts +18 -21
  40. package/src/launcher/internal/registerRuntimeHooks.ts +59 -0
  41. package/src/launcher/internal/runTtsx.ts +65 -437
  42. package/src/launcher/internal/runtimeHookPreload.ts +14 -0
  43. package/src/launcher/internal/runtimeHooks.ts +976 -0
  44. package/src/plugin/internal/buildSourcePlugin.ts +147 -16
  45. package/src/plugin/internal/loadProjectPlugins.ts +16 -4
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
 
5
+ import { resolveTsgo } from "../../compiler/internal/resolveTsgo";
5
6
  import {
6
7
  getBoolean,
7
8
  getNumber,
@@ -69,7 +70,7 @@ function run(argv: readonly string[]): number {
69
70
  project: parsed.project,
70
71
  singleThreaded: parsed.singleThreaded,
71
72
  });
72
- return runPreparedEntry(parsed, prepared, cwd);
73
+ return runPreparedEntry(parsed, prepared, cwd, entry);
73
74
  }
74
75
 
75
76
  function formatError(error: unknown): string {
@@ -213,6 +214,20 @@ function printHelp(): void {
213
214
  process.stdout.write("\n");
214
215
  }
215
216
 
217
+ /**
218
+ * Append a Node flag to an existing `NODE_OPTIONS` value (or start one). Used
219
+ * to propagate the runtime-hook installer into every child process the program
220
+ * spawns, so workers launched as `node worker.ts` inherit the source loader.
221
+ */
222
+ function appendNodeOption(
223
+ existing: string | undefined,
224
+ option: string,
225
+ ): string {
226
+ return existing && existing.trim().length !== 0
227
+ ? `${existing} ${option}`
228
+ : option;
229
+ }
230
+
216
231
  function resolvePreload(cwd: string, preload: string): string {
217
232
  if (path.isAbsolute(preload) || isRelativeSpecifier(preload)) {
218
233
  return path.resolve(cwd, preload);
@@ -231,33 +246,71 @@ function isRelativeSpecifier(specifier: string): boolean {
231
246
  );
232
247
  }
233
248
 
249
+ /**
250
+ * Run the TypeScript entry from source in a child Node process whose runtime
251
+ * module hooks serve the already-built entry project and build raw `.ts`
252
+ * dependencies on demand.
253
+ *
254
+ * The child is `node [-r preload...] registerRuntimeHooks.js <source-entry>
255
+ * <argv...>` (the bootstrap, run as the main module — not `--import`, so a
256
+ * CommonJS `require` chain reaches the hooks). A runtime manifest pins the
257
+ * entry project's emit for the hooks; `TTSC_TSGO_BINARY` lets dependency builds
258
+ * find tsgo without re-resolving it from inside the hook.
259
+ */
234
260
  function runPreparedEntry(
235
261
  parsed: Exclude<ReturnType<typeof parseCLI>, "help" | "version">,
236
262
  execution: ReturnType<typeof prepareExecution>,
237
263
  cwd: string,
264
+ sourceEntry: string,
238
265
  ): number {
239
266
  try {
240
- fs.mkdirSync(execution.emitDir, { recursive: true });
241
- if (execution.moduleKind === "esm") {
242
- rewriteEsmSpecifiers(execution.emitDir);
243
- fs.writeFileSync(
244
- path.join(execution.emitDir, "package.json"),
245
- JSON.stringify({ type: "module" }),
246
- "utf8",
247
- );
248
- }
267
+ const depCacheDir = path.join(execution.cleanupDir, "deps");
268
+ const manifestPath = path.join(
269
+ execution.cleanupDir,
270
+ "runtime-manifest.json",
271
+ );
272
+ fs.mkdirSync(execution.cleanupDir, { recursive: true });
273
+ fs.writeFileSync(
274
+ manifestPath,
275
+ JSON.stringify({
276
+ depCacheDir,
277
+ emitDir: execution.emitDir,
278
+ emittedFiles: execution.emittedFiles,
279
+ moduleOption: execution.moduleOption,
280
+ projectRoot: execution.projectRoot,
281
+ rootDir: execution.rootDir,
282
+ }),
283
+ "utf8",
284
+ );
285
+
286
+ const tsgo = resolveTsgo({
287
+ binary: parsed.binary,
288
+ cwd: execution.projectRoot,
289
+ }).binary;
290
+
291
+ const bootstrap = path.join(__dirname, "registerRuntimeHooks.js");
249
292
  const args = [
293
+ "--disable-warning=ExperimentalWarning",
250
294
  ...parsed.preload.flatMap((preload) => [
251
295
  "-r",
252
296
  resolvePreload(cwd, preload),
253
297
  ]),
254
- execution.entryFile,
298
+ bootstrap,
299
+ sourceEntry,
255
300
  ...parsed.passthrough,
256
301
  ];
257
302
  const result = spawnSync(process.execPath, args, {
258
303
  cwd,
304
+ env: {
305
+ ...process.env,
306
+ NODE_OPTIONS: appendNodeOption(
307
+ process.env.NODE_OPTIONS,
308
+ `--require ${JSON.stringify(path.join(__dirname, "runtimeHookPreload.js"))}`,
309
+ ),
310
+ TTSC_TSGO_BINARY: process.env.TTSC_TSGO_BINARY ?? tsgo,
311
+ TTSX_RUNTIME_MANIFEST: manifestPath,
312
+ },
259
313
  stdio: "inherit",
260
- env: process.env,
261
314
  windowsHide: true,
262
315
  });
263
316
  if (result.error) {
@@ -277,428 +330,3 @@ function removeRuntimeOutput(directory: string): void {
277
330
  // Best effort: cleanup must not replace the child process exit status.
278
331
  }
279
332
  }
280
-
281
- /**
282
- * Walk every `.js`/`.mjs`/`.cjs` file in `root` and rewrite bare relative
283
- * specifiers (e.g. `"./foo"`) to include the resolved file extension so Node
284
- * can load them with `--input-type=module`. Files that did not change are not
285
- * written back.
286
- */
287
- function rewriteEsmSpecifiers(root: string): void {
288
- const stack = [root];
289
- while (stack.length !== 0) {
290
- const current = stack.pop()!;
291
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
292
- const next = path.join(current, entry.name);
293
- if (entry.isDirectory()) {
294
- stack.push(next);
295
- continue;
296
- }
297
- if (!entry.isFile() || !isJavaScriptOutput(next)) {
298
- continue;
299
- }
300
- const before = fs.readFileSync(next, "utf8");
301
- const after = rewriteEsmSpecifiersInText(next, before);
302
- if (after !== before) {
303
- fs.writeFileSync(next, after, "utf8");
304
- }
305
- }
306
- }
307
- }
308
-
309
- function rewriteEsmSpecifiersInText(fromFile: string, input: string): string {
310
- const replacements: Array<{ start: number; end: number; text: string }> = [];
311
- scanEsmSpecifiers(fromFile, input, 0, input.length, replacements);
312
- if (replacements.length === 0) {
313
- return input;
314
- }
315
- let output = "";
316
- let last = 0;
317
- for (const replacement of replacements) {
318
- output += input.slice(last, replacement.start);
319
- output += replacement.text;
320
- last = replacement.end;
321
- }
322
- return output + input.slice(last);
323
- }
324
-
325
- function scanEsmSpecifiers(
326
- fromFile: string,
327
- input: string,
328
- start: number,
329
- end: number,
330
- replacements: Array<{ start: number; end: number; text: string }>,
331
- ): void {
332
- let i = start;
333
- while (i < end) {
334
- if (input[i] === "`") {
335
- i = scanTemplateExpressions(fromFile, input, i, replacements);
336
- continue;
337
- }
338
- const skipped = skipNonCode(input, i);
339
- if (skipped !== i) {
340
- i = skipped;
341
- continue;
342
- }
343
- if (isKeywordAt(input, i, "import")) {
344
- const next = skipWhitespace(input, i + "import".length);
345
- if (input[next] === "(") {
346
- rewriteDynamicImportSpecifier(fromFile, input, next, replacements);
347
- } else if (isQuote(input[next])) {
348
- rewriteStringSpecifier(fromFile, input, next, replacements);
349
- } else {
350
- rewriteFromSpecifier(fromFile, input, next, replacements);
351
- }
352
- i += "import".length;
353
- continue;
354
- }
355
- if (isKeywordAt(input, i, "export")) {
356
- rewriteFromSpecifier(fromFile, input, i + "export".length, replacements);
357
- i += "export".length;
358
- continue;
359
- }
360
- i += 1;
361
- }
362
- }
363
-
364
- function rewriteDynamicImportSpecifier(
365
- fromFile: string,
366
- input: string,
367
- openParen: number,
368
- replacements: Array<{ start: number; end: number; text: string }>,
369
- ): void {
370
- const specifier = skipWhitespace(input, openParen + 1);
371
- if (isQuote(input[specifier])) {
372
- rewriteStringSpecifier(fromFile, input, specifier, replacements);
373
- }
374
- }
375
-
376
- function rewriteFromSpecifier(
377
- fromFile: string,
378
- input: string,
379
- start: number,
380
- replacements: Array<{ start: number; end: number; text: string }>,
381
- ): void {
382
- let i = start;
383
- let depth = 0;
384
- while (i < input.length) {
385
- const skipped = skipNonCode(input, i);
386
- if (skipped !== i) {
387
- i = skipped;
388
- continue;
389
- }
390
- const current = input[i]!;
391
- if (depth === 0 && current === ";") {
392
- return;
393
- }
394
- if (current === "{" || current === "[" || current === "(") {
395
- depth += 1;
396
- i += 1;
397
- continue;
398
- }
399
- if (current === "}" || current === "]" || current === ")") {
400
- depth = Math.max(0, depth - 1);
401
- i += 1;
402
- continue;
403
- }
404
- if (depth === 0 && isKeywordAt(input, i, "from")) {
405
- const specifier = skipWhitespace(input, i + "from".length);
406
- if (isQuote(input[specifier])) {
407
- rewriteStringSpecifier(fromFile, input, specifier, replacements);
408
- }
409
- return;
410
- }
411
- i += 1;
412
- }
413
- }
414
-
415
- function rewriteStringSpecifier(
416
- fromFile: string,
417
- input: string,
418
- quoteIndex: number,
419
- replacements: Array<{ start: number; end: number; text: string }>,
420
- ): void {
421
- const literal = readSimpleStringLiteral(input, quoteIndex);
422
- if (literal === null) {
423
- return;
424
- }
425
- const next = withResolvableExtension(fromFile, literal.value);
426
- if (next === literal.value) {
427
- return;
428
- }
429
- replacements.push({
430
- start: literal.start,
431
- end: literal.end,
432
- text: next,
433
- });
434
- }
435
-
436
- function scanTemplateExpressions(
437
- fromFile: string,
438
- input: string,
439
- start: number,
440
- replacements: Array<{ start: number; end: number; text: string }>,
441
- ): number {
442
- for (let i = start + 1; i < input.length; i += 1) {
443
- const current = input[i]!;
444
- if (current === "\\") {
445
- i += 1;
446
- continue;
447
- }
448
- if (current === "`") {
449
- return i + 1;
450
- }
451
- if (current === "$" && input[i + 1] === "{") {
452
- const expressionStart = i + 2;
453
- const expressionEnd = findTemplateExpressionEnd(input, expressionStart);
454
- if (expressionEnd === null) {
455
- return input.length;
456
- }
457
- scanEsmSpecifiers(
458
- fromFile,
459
- input,
460
- expressionStart,
461
- expressionEnd,
462
- replacements,
463
- );
464
- i = expressionEnd;
465
- }
466
- }
467
- return input.length;
468
- }
469
-
470
- function findTemplateExpressionEnd(
471
- input: string,
472
- expressionStart: number,
473
- ): number | null {
474
- let depth = 1;
475
- for (let i = expressionStart; i < input.length; i += 1) {
476
- const skipped = skipNonCode(input, i);
477
- if (skipped !== i) {
478
- i = skipped - 1;
479
- continue;
480
- }
481
- const current = input[i]!;
482
- if (current === "{") {
483
- depth += 1;
484
- continue;
485
- }
486
- if (current === "}") {
487
- depth -= 1;
488
- if (depth === 0) {
489
- return i;
490
- }
491
- }
492
- }
493
- return null;
494
- }
495
-
496
- /**
497
- * Append or fix a file extension on a relative specifier so that Node's ESM
498
- * loader can resolve it. The resolution order mirrors Node's own algorithm:
499
- * exact file match → directory index → `.js` fallback. Non-relative specifiers
500
- * and already-extensioned paths are returned unchanged.
501
- */
502
- function withResolvableExtension(fromFile: string, specifier: string): string {
503
- if (!specifier.startsWith(".")) {
504
- // Bare specifiers (packages, builtins) need no rewriting.
505
- return specifier;
506
- }
507
- const [pathname, suffix = ""] = splitSpecifierSuffix(specifier);
508
- if (/\.(?:[cm]?js|json|node)$/i.test(pathname)) {
509
- // Already has a concrete extension the loader understands.
510
- return specifier;
511
- }
512
- const fromDir = path.dirname(fromFile);
513
- // 1. Try the specifier as a file path with each JS extension.
514
- for (const extension of [".js", ".mjs", ".cjs"]) {
515
- if (fs.existsSync(path.resolve(fromDir, pathname + extension))) {
516
- return pathname + extension + suffix;
517
- }
518
- }
519
- // 2. Try interpreting it as a directory with an index file.
520
- const directory = pathname.replace(/\/+$/, "");
521
- for (const extension of [".js", ".mjs", ".cjs"]) {
522
- if (fs.existsSync(path.resolve(fromDir, directory, "index" + extension))) {
523
- return `${directory}/index${extension}${suffix}`;
524
- }
525
- }
526
- // 3. Last resort: append `.js` and let Node surface the error if it's wrong.
527
- return `${pathname}.js${suffix}`;
528
- }
529
-
530
- function splitSpecifierSuffix(specifier: string): [string, string?] {
531
- const index = specifier.search(/[?#]/);
532
- if (index === -1) {
533
- return [specifier];
534
- }
535
- return [specifier.slice(0, index), specifier.slice(index)];
536
- }
537
-
538
- function isJavaScriptOutput(filename: string): boolean {
539
- return /\.(?:[cm]?js)$/i.test(filename);
540
- }
541
-
542
- function skipNonCode(input: string, start: number): number {
543
- const current = input[start];
544
- const next = input[start + 1];
545
- if (current === '"' || current === "'") {
546
- return skipString(input, start);
547
- }
548
- if (current === "`") {
549
- return skipTemplate(input, start);
550
- }
551
- if (current === "/" && next === "/") {
552
- return skipLineComment(input, start);
553
- }
554
- if (current === "/" && next === "*") {
555
- return skipBlockComment(input, start);
556
- }
557
- if (current === "/" && looksLikeRegexStart(input, start)) {
558
- return skipRegex(input, start);
559
- }
560
- return start;
561
- }
562
-
563
- function skipString(input: string, start: number): number {
564
- const quote = input[start]!;
565
- for (let i = start + 1; i < input.length; i += 1) {
566
- const current = input[i]!;
567
- if (current === "\\") {
568
- i += 1;
569
- continue;
570
- }
571
- if (current === quote) {
572
- return i + 1;
573
- }
574
- }
575
- return input.length;
576
- }
577
-
578
- function skipTemplate(input: string, start: number): number {
579
- for (let i = start + 1; i < input.length; i += 1) {
580
- const current = input[i]!;
581
- if (current === "\\") {
582
- i += 1;
583
- continue;
584
- }
585
- if (current === "`") {
586
- return i + 1;
587
- }
588
- }
589
- return input.length;
590
- }
591
-
592
- function skipLineComment(input: string, start: number): number {
593
- const end = input.indexOf("\n", start + 2);
594
- return end === -1 ? input.length : end + 1;
595
- }
596
-
597
- function skipBlockComment(input: string, start: number): number {
598
- const end = input.indexOf("*/", start + 2);
599
- return end === -1 ? input.length : end + 2;
600
- }
601
-
602
- function skipRegex(input: string, start: number): number {
603
- let inClass = false;
604
- for (let i = start + 1; i < input.length; i += 1) {
605
- const current = input[i]!;
606
- if (current === "\\") {
607
- i += 1;
608
- continue;
609
- }
610
- if (current === "[") {
611
- inClass = true;
612
- continue;
613
- }
614
- if (current === "]") {
615
- inClass = false;
616
- continue;
617
- }
618
- if (current === "/" && !inClass) {
619
- i += 1;
620
- while (/[A-Za-z]/.test(input[i] ?? "")) {
621
- i += 1;
622
- }
623
- return i;
624
- }
625
- }
626
- return input.length;
627
- }
628
-
629
- /**
630
- * Determine whether the `/` at `start` opens a regex literal rather than a
631
- * division operator. This is a conservative approximation: check the preceding
632
- * non-whitespace token — if it is an operator character or a keyword that must
633
- * be followed by an expression, we assume regex.
634
- */
635
- function looksLikeRegexStart(input: string, start: number): boolean {
636
- let i = start - 1;
637
- while (i >= 0 && /\s/.test(input[i]!)) {
638
- i -= 1;
639
- }
640
- if (i < 0) {
641
- return true;
642
- }
643
- const previous = input[i]!;
644
- if ("([{=,:;!?&|+-*~^<>".includes(previous)) {
645
- return true;
646
- }
647
- const word = readPreviousWord(input, i);
648
- return /^(?:return|throw|case|delete|void|typeof|instanceof|in|of|yield|await)$/.test(
649
- word,
650
- );
651
- }
652
-
653
- function readPreviousWord(input: string, end: number): string {
654
- let start = end;
655
- while (start >= 0 && isIdentifierPart(input[start]!)) {
656
- start -= 1;
657
- }
658
- return input.slice(start + 1, end + 1);
659
- }
660
-
661
- function readSimpleStringLiteral(
662
- input: string,
663
- quoteIndex: number,
664
- ): { start: number; end: number; value: string } | null {
665
- const quote = input[quoteIndex]!;
666
- for (let i = quoteIndex + 1; i < input.length; i += 1) {
667
- const current = input[i]!;
668
- if (current === "\\") {
669
- return null;
670
- }
671
- if (current === quote) {
672
- return {
673
- start: quoteIndex + 1,
674
- end: i,
675
- value: input.slice(quoteIndex + 1, i),
676
- };
677
- }
678
- }
679
- return null;
680
- }
681
-
682
- function skipWhitespace(input: string, start: number): number {
683
- let i = start;
684
- while (i < input.length && /\s/.test(input[i]!)) {
685
- i += 1;
686
- }
687
- return i;
688
- }
689
-
690
- function isKeywordAt(input: string, start: number, keyword: string): boolean {
691
- return (
692
- input.startsWith(keyword, start) &&
693
- !isIdentifierPart(input[start - 1] ?? "") &&
694
- !isIdentifierPart(input[start + keyword.length] ?? "")
695
- );
696
- }
697
-
698
- function isIdentifierPart(value: string): boolean {
699
- return /[$\p{ID_Continue}]/u.test(value);
700
- }
701
-
702
- function isQuote(value: string | undefined): value is '"' | "'" {
703
- return value === '"' || value === "'";
704
- }
@@ -0,0 +1,14 @@
1
+ import { installRuntimeHooks } from "./runtimeHooks";
2
+
3
+ /**
4
+ * `--require` target that only installs the runtime module hooks. `ttsx` puts
5
+ * it on `NODE_OPTIONS` for the child process so every descendant the program
6
+ * spawns (e.g. a worker started with `node worker.ts`) inherits the same
7
+ * source-loading hooks, the way `ts-node` propagates through `--require
8
+ * ts-node/register`. The preload itself is CommonJS and only registers the
9
+ * hooks; ESM support comes from the `registerHooks` they install, not from how
10
+ * the preload is loaded. The entry process additionally goes through the
11
+ * bootstrap, which loads the entry itself; here we only register
12
+ * (idempotently).
13
+ */
14
+ installRuntimeHooks();