@ttsc/strip 0.23.0 → 0.25.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 (2) hide show
  1. package/driver/config.go +155 -53
  2. package/package.json +1 -1
package/driver/config.go CHANGED
@@ -10,18 +10,11 @@ import (
10
10
  "path/filepath"
11
11
  "runtime"
12
12
  "strings"
13
- "time"
14
13
 
15
14
  "github.com/samchon/ttsc/packages/ttsc/driver"
16
15
  "github.com/samchon/ttsc/packages/ttsc/driver/windowsjunction"
17
16
  )
18
17
 
19
- // configLoaderTimeout caps every `ttsx`/`node -e` subprocess that evaluates a
20
- // user-supplied strip config. Mirrors the lint package budget: 60 s is generous
21
- // for cold ttsx starts on CI runners and tight enough to keep user-visible
22
- // feedback under a minute.
23
- const configLoaderTimeout = 60 * time.Second
24
-
25
18
  // stripConfigFilenames is the ordered list of candidate filenames that
26
19
  // findStripConfigFile checks in each directory during upward discovery.
27
20
  var stripConfigFilenames = []string{
@@ -207,7 +200,14 @@ const { pathToFileURL } = require("node:url");
207
200
  process.stdout.write(JSON.stringify(value));
208
201
  })().catch((error) => {
209
202
  process.stderr.write(error && error.stack ? error.stack : String(error));
210
- process.exit(1);
203
+ // The stack above is for the reader. This is for the caller: the parent reads
204
+ // stdout as the payload channel either way, so a failure reason travels as
205
+ // data rather than as text scraped back out of a captured stream. The exit
206
+ // code is set before the write so a callback that never fires still fails the
207
+ // load, and the write's completion is what triggers the exit, because
208
+ // process.exit abandons a pending pipe write.
209
+ process.exitCode = 1;
210
+ process.stdout.write(JSON.stringify({ __ttscLoaderError: error && error.message ? String(error.message) : String(error) }), () => process.exit(1));
211
211
  });
212
212
  `
213
213
 
@@ -219,21 +219,22 @@ func loadStripScriptConfigFile(location string) (any, error) {
219
219
  if node == "" {
220
220
  node = "node"
221
221
  }
222
- ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
222
+ ctx, cancel := context.WithCancel(context.Background())
223
223
  defer cancel()
224
224
  cmd := exec.CommandContext(ctx, node, "-e", stripScriptLoaderSource, location)
225
225
  cmd.Env = stripNodeConfigLoaderEnv(location)
226
+ // The child's stderr is human output and goes straight to this process's
227
+ // stderr as it is written. Collecting it only to replay it afterwards is what
228
+ // made a long evaluation print nothing at all, and what would make a loud one
229
+ // grow this process's memory without bound.
230
+ cmd.Stderr = os.Stderr
226
231
  output, err := cmd.Output()
227
232
  if err != nil {
228
- if ctx.Err() == context.DeadlineExceeded {
229
- return nil, fmt.Errorf("@ttsc/strip: load config file %s: timed out after %s", location, configLoaderTimeout)
230
- }
231
- stderr := ""
232
- if exit, ok := err.(*exec.ExitError); ok {
233
- stderr = strings.TrimSpace(string(exit.Stderr))
234
- }
235
- if stderr != "" {
236
- return nil, fmt.Errorf("@ttsc/strip: load config file %s: %s", location, stderr)
233
+ // The loader's stack already reached this process's stderr as it ran.
234
+ // What it could not put there is a reason a caller can act on, so that
235
+ // arrives through the payload channel instead.
236
+ if reason := loaderFailureReason(output); reason != "" {
237
+ return nil, fmt.Errorf("@ttsc/strip: load config file %s: %s", location, reason)
237
238
  }
238
239
  return nil, fmt.Errorf("@ttsc/strip: load config file %s: %w", location, err)
239
240
  }
@@ -252,37 +253,55 @@ func stripTypeScriptLoaderSource(importLiteral string) string {
252
253
  return fmt.Sprintf(`import * as importedConfig from %s;
253
254
 
254
255
  declare const process: {
255
- stdout: { write(value: string): void };
256
+ exitCode?: number;
257
+ stdout: { write(value: string, callback?: () => void): void };
256
258
  stderr: { write(value: string): void };
257
259
  exit(code?: number): never;
258
260
  };
259
261
 
260
- try {
261
- let current: unknown = importedConfig;
262
- for (let i = 0; i < 8; i++) {
263
- if (current !== null && typeof current === "object" && Object.prototype.hasOwnProperty.call(current as Record<string, unknown>, "default")) {
264
- current = (current as Record<string, unknown>).default;
265
- continue;
262
+ // Wrapped rather than written as a top-level await: the loader tsconfig's
263
+ // "module" follows the config's own package, and TS1378 rejects top-level await
264
+ // under a CommonJS module option however this .mts file emits. The body's own
265
+ // catch is the only failure path it ends the process so there is nothing
266
+ // left for a trailing handler to settle.
267
+ (async () => {
268
+ try {
269
+ let current: unknown = importedConfig;
270
+ for (let i = 0; i < 8; i++) {
271
+ if (current !== null && typeof current === "object" && Object.prototype.hasOwnProperty.call(current as Record<string, unknown>, "default")) {
272
+ current = (current as Record<string, unknown>).default;
273
+ continue;
274
+ }
275
+ break;
266
276
  }
267
- break;
268
- }
269
- if (typeof current === "function") {
270
- current = await (current as () => unknown | Promise<unknown>)();
271
- }
272
- if (current === null || typeof current !== "object" || Array.isArray(current)) {
273
- throw new Error("strip config file must export an object");
274
- }
275
- process.stdout.write(JSON.stringify(current));
276
- } catch (error) {
277
- process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
278
- process.exit(1);
279
- }
277
+ if (typeof current === "function") {
278
+ current = await (current as () => unknown | Promise<unknown>)();
279
+ }
280
+ if (current === null || typeof current !== "object" || Array.isArray(current)) {
281
+ throw new Error("strip config file must export an object");
282
+ }
283
+ process.stdout.write(JSON.stringify(current));
284
+ } catch (error) {
285
+ process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
286
+ // The stack above is for the reader. This is for the caller: the parent
287
+ // reads stdout as the payload channel either way, so a failure reason
288
+ // travels as data rather than as text scraped back out of a captured
289
+ // stream. The exit code is set before the write so a callback that never
290
+ // fires still fails the load, and the write's completion is what triggers
291
+ // the exit, because process.exit abandons a pending pipe write.
292
+ process.exitCode = 1;
293
+ process.stdout.write(
294
+ JSON.stringify({ __ttscLoaderError: error instanceof Error ? error.message : String(error) }),
295
+ () => process.exit(1),
296
+ );
297
+ }
298
+ })();
280
299
  `, importLiteral)
281
300
  }
282
301
 
283
302
  // loadStripTypeScriptConfigFile evaluates a .ts/.cts/.mts config file by writing
284
303
  // an ephemeral loader script and tsconfig into a temp directory, symlinking the
285
- // nearest node_modules, then running ttsx with a configLoaderTimeout deadline.
304
+ // nearest node_modules, then running ttsx.
286
305
  //
287
306
  // The ttsx build runs with `--no-plugins`: the loader only needs to
288
307
  // type-check and execute the strip config file, so loading the host
@@ -327,21 +346,22 @@ func loadStripTypeScriptConfigFile(location string) (any, error) {
327
346
  }
328
347
  args = append(args, loader)
329
348
 
330
- ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
349
+ ctx, cancel := context.WithCancel(context.Background())
331
350
  defer cancel()
332
351
  cmd := stripTtsxCommandContext(ctx, args...)
333
352
  cmd.Env = stripNodeConfigLoaderEnv(location)
353
+ // The child's stderr is human output and goes straight to this process's
354
+ // stderr as it is written. Collecting it only to replay it afterwards is what
355
+ // made a long evaluation print nothing at all, and what would make a loud one
356
+ // grow this process's memory without bound.
357
+ cmd.Stderr = os.Stderr
334
358
  output, err := cmd.Output()
335
359
  if err != nil {
336
- if ctx.Err() == context.DeadlineExceeded {
337
- return nil, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: timed out after %s", location, configLoaderTimeout)
338
- }
339
- stderr := ""
340
- if exit, ok := err.(*exec.ExitError); ok {
341
- stderr = strings.TrimSpace(string(exit.Stderr))
342
- }
343
- if stderr != "" {
344
- return nil, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: %s", location, stderr)
360
+ // The loader's stack already reached this process's stderr as it ran.
361
+ // What it could not put there is a reason a caller can act on, so that
362
+ // arrives through the payload channel instead.
363
+ if reason := loaderFailureReason(output); reason != "" {
364
+ return nil, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: %s", location, reason)
345
365
  }
346
366
  return nil, fmt.Errorf("@ttsc/strip: load TypeScript config file %s: %w", location, err)
347
367
  }
@@ -357,10 +377,16 @@ func loadStripTypeScriptConfigFile(location string) (any, error) {
357
377
  func stripTypeScriptLoaderTsconfig(loader, location, outDir string) string {
358
378
  content := map[string]any{
359
379
  "compilerOptions": map[string]any{
360
- "allowImportingTsExtensions": true,
361
- "allowJs": true,
362
- "checkJs": false,
363
- "module": "ESNext",
380
+ "allowImportingTsExtensions": true,
381
+ "allowJs": true,
382
+ "checkJs": false,
383
+ // The config is a Node module, so Node's rule decides its format: the
384
+ // nearest package.json "type" above it. Hardcoding one answer ran every
385
+ // ambiguous `.ts` config as ESM and broke __dirname in an ordinary
386
+ // CommonJS package (#1069). moduleResolution stays "bundler", which tsgo
387
+ // accepts for both kinds, so extensionless relative imports keep
388
+ // resolving either way.
389
+ "module": stripConfigModuleOption(location),
364
390
  "moduleResolution": "bundler",
365
391
  "noImplicitAny": false,
366
392
  "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
@@ -369,6 +395,12 @@ func stripTypeScriptLoaderTsconfig(loader, location, outDir string) string {
369
395
  "skipLibCheck": true,
370
396
  "strict": false,
371
397
  "target": "ES2022",
398
+ // TypeScript 7 includes no ambient type package unless "types" asks for
399
+ // it, and this Program extends nothing, so without the wildcard a config
400
+ // could not name a single Node global (#1069). The loader directory links
401
+ // the config's nearest node_modules, so the default typeRoots walk finds
402
+ // exactly what the project installed.
403
+ "types": []string{"*"},
372
404
  },
373
405
  "files": []string{
374
406
  filepath.ToSlash(loader),
@@ -382,6 +414,56 @@ func stripTypeScriptLoaderTsconfig(loader, location, outDir string) string {
382
414
  return string(body)
383
415
  }
384
416
 
417
+ // stripConfigModuleOption returns the loader tsconfig's "module" for a config
418
+ // file: the module kind Node itself would give that file.
419
+ //
420
+ // An explicit .cts/.cjs or .mts/.mjs extension already decides the emit format
421
+ // on its own, so those keep the ES-module setting and let the extension win —
422
+ // the same precedence tsgo applies. Everything ambiguous walks up for the
423
+ // nearest package.json "type", exactly as Node does when it loads the file.
424
+ func stripConfigModuleOption(location string) string {
425
+ switch strings.ToLower(filepath.Ext(location)) {
426
+ case ".ts", ".tsx", ".js":
427
+ if stripNearestPackageType(location) == "commonjs" {
428
+ return "CommonJS"
429
+ }
430
+ }
431
+ return "ESNext"
432
+ }
433
+
434
+ // stripNearestPackageType mirrors Node's package-scope lookup for the nearest
435
+ // package.json above location: the walk stops at the FIRST manifest it finds,
436
+ // and a manifest declaring no "type" means CommonJS rather than a reason to
437
+ // keep climbing. Reaching the filesystem root without any manifest also means
438
+ // CommonJS. The location is made absolute first, so a relative config path
439
+ // cannot end the walk at "." after a single step.
440
+ func stripNearestPackageType(location string) string {
441
+ absolute, err := filepath.Abs(location)
442
+ if err != nil {
443
+ absolute = location
444
+ }
445
+ dir := filepath.Dir(absolute)
446
+ for {
447
+ raw, err := os.ReadFile(filepath.Join(dir, "package.json"))
448
+ if err == nil {
449
+ var manifest struct {
450
+ Type string `json:"type"`
451
+ }
452
+ // A manifest that does not parse still bounds the package scope; Node
453
+ // refuses to look past it, and CommonJS is the format it defaults to.
454
+ if json.Unmarshal(raw, &manifest) == nil && manifest.Type == "module" {
455
+ return "module"
456
+ }
457
+ return "commonjs"
458
+ }
459
+ parent := filepath.Dir(dir)
460
+ if parent == dir {
461
+ return "commonjs"
462
+ }
463
+ dir = parent
464
+ }
465
+ }
466
+
385
467
  // stripLoaderRootDir returns the widest rootDir that still contains the
386
468
  // loader tsconfig's inputs: the volume root of the loader temp dir (`C:/` on
387
469
  // Windows, `/` elsewhere). A literal "/" is not an ancestor of drive-letter
@@ -571,3 +653,23 @@ func stripSetEnv(env []string, key, value string) []string {
571
653
  }
572
654
  return append(env, prefix+value)
573
655
  }
656
+
657
+ // loaderFailureReason reads the failure envelope a config loader writes to its
658
+ // payload channel when it stops on an error it can name.
659
+ //
660
+ // The loader's stack goes to this process's stderr as it runs, which is where a
661
+ // reader wants it. But the *reason* — "config file must export an object with a
662
+ // non-empty text string" — is a fact about the user's config, and a caller
663
+ // deserves it in the error rather than having to go find it in the log. So it
664
+ // travels as data through the same stdout the payload uses, and only a
665
+ // well-formed envelope is honoured: anything else leaves the process status to
666
+ // speak for itself.
667
+ func loaderFailureReason(output []byte) string {
668
+ var envelope struct {
669
+ Message string `json:"__ttscLoaderError"`
670
+ }
671
+ if json.Unmarshal(output, &envelope) != nil {
672
+ return ""
673
+ }
674
+ return strings.TrimSpace(envelope.Message)
675
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/strip",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "First-party ttsc plugin that removes configured calls and statements from emitted JavaScript.",
5
5
  "main": "src/index.cjs",
6
6
  "types": "src/index.d.ts",