@openrewrite/rewrite 8.93.0 → 8.93.2

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 (37) hide show
  1. package/dist/javascript/index.d.ts +1 -0
  2. package/dist/javascript/index.d.ts.map +1 -1
  3. package/dist/javascript/index.js +1 -0
  4. package/dist/javascript/index.js.map +1 -1
  5. package/dist/javascript/node-runtime.d.ts +63 -0
  6. package/dist/javascript/node-runtime.d.ts.map +1 -0
  7. package/dist/javascript/node-runtime.js +196 -0
  8. package/dist/javascript/node-runtime.js.map +1 -0
  9. package/dist/javascript/package-json-parser.d.ts +13 -0
  10. package/dist/javascript/package-json-parser.d.ts.map +1 -1
  11. package/dist/javascript/package-json-parser.js +62 -22
  12. package/dist/javascript/package-json-parser.js.map +1 -1
  13. package/dist/javascript/package-manager.d.ts +15 -0
  14. package/dist/javascript/package-manager.d.ts.map +1 -1
  15. package/dist/javascript/package-manager.js +38 -0
  16. package/dist/javascript/package-manager.js.map +1 -1
  17. package/dist/javascript/parser.d.ts +2 -0
  18. package/dist/javascript/parser.d.ts.map +1 -1
  19. package/dist/javascript/parser.js +57 -40
  20. package/dist/javascript/parser.js.map +1 -1
  21. package/dist/rewrite-javascript-version.txt +1 -1
  22. package/dist/rpc/message-encoder.d.ts +18 -7
  23. package/dist/rpc/message-encoder.d.ts.map +1 -1
  24. package/dist/rpc/message-encoder.js +42 -18
  25. package/dist/rpc/message-encoder.js.map +1 -1
  26. package/dist/rpc/request/dependency-types.d.ts +6 -5
  27. package/dist/rpc/request/dependency-types.d.ts.map +1 -1
  28. package/dist/rpc/request/dependency-types.js +13 -12
  29. package/dist/rpc/request/dependency-types.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/javascript/index.ts +1 -0
  32. package/src/javascript/node-runtime.ts +213 -0
  33. package/src/javascript/package-json-parser.ts +75 -32
  34. package/src/javascript/package-manager.ts +48 -0
  35. package/src/javascript/parser.ts +86 -45
  36. package/src/rpc/message-encoder.ts +41 -18
  37. package/src/rpc/request/dependency-types.ts +13 -11
@@ -24,11 +24,12 @@ import {
24
24
  PackageManager,
25
25
  readNpmrcConfigs
26
26
  } from "./node-resolution-result";
27
+ import {createNodeRuntimeMarker, NodeRuntime} from "./node-runtime";
27
28
  import * as fs from "fs";
28
29
  import * as fsp from "fs/promises";
29
30
  import * as path from "path";
30
31
  import * as YAML from "yaml";
31
- import {getLockFileDetectionConfig, runList} from "./package-manager";
32
+ import {getLockFileDetectionConfig, getLockFileName, runList, runVersion} from "./package-manager";
32
33
  import picomatch from "picomatch";
33
34
  import {DEFAULT_DIR_EXCLUSIONS, walkDirs} from "../path-utils";
34
35
 
@@ -76,6 +77,9 @@ export class PackageJsonParser extends Parser {
76
77
  private readonly jsonParser: JsonParser;
77
78
  private readonly skipDependencyResolution: boolean;
78
79
 
80
+ /** The NodeRuntime of each project root, by package manager and root directory, for the parser's lifetime */
81
+ private readonly runtimes = new Map<string, NodeRuntime>();
82
+
79
83
  /** Fields to copy from package.json that contain dependency maps */
80
84
  private static readonly DEPENDENCY_FIELDS = [
81
85
  'dependencies',
@@ -166,18 +170,50 @@ export class PackageJsonParser extends Parser {
166
170
  markersByDir.set(dir, await this.createMarker(input, dir));
167
171
  }
168
172
  const marker = markersByDir.get(dir)!;
173
+ const runtime = this.nodeRuntime(dir, marker?.packageManager);
174
+
175
+ // Attach the markers to the JSON document: every one gets the NodeRuntime of its project
176
+ yield {
177
+ ...jsonDoc,
178
+ markers: {
179
+ ...jsonDoc.markers,
180
+ markers: [...jsonDoc.markers.markers, ...(marker ? [marker] : []), runtime]
181
+ }
182
+ };
183
+ }
184
+ }
169
185
 
170
- // Attach the marker to the JSON document
171
- if (marker) {
172
- yield {
173
- ...jsonDoc,
174
- markers: {
175
- ...jsonDoc.markers,
176
- markers: [...jsonDoc.markers.markers, marker]
177
- }
178
- };
179
- } else {
180
- yield jsonDoc;
186
+ /**
187
+ * The NodeRuntime of the project whose package.json is in `dir`, one instance per project root: the directory of
188
+ * the lock the parser found `packageManager` by, else `dir`. That package manager's `--version` is asked there,
189
+ * once per package manager and root directory; without one, as when dependency resolution is skipped, none is.
190
+ */
191
+ private nodeRuntime(dir: string, packageManager?: PackageManager): NodeRuntime {
192
+ const absoluteDir = path.resolve(this.relativeTo ?? '', dir);
193
+ const root = packageManager === undefined ? absoluteDir : this.projectRoot(absoluteDir, packageManager);
194
+ const key = `${packageManager ?? ''}:${root}`;
195
+ let runtime = this.runtimes.get(key);
196
+ if (!runtime) {
197
+ runtime = createNodeRuntimeMarker(packageManager,
198
+ packageManager === undefined ? undefined : runVersion(packageManager, root));
199
+ this.runtimes.set(key, runtime);
200
+ }
201
+ return runtime;
202
+ }
203
+
204
+ /**
205
+ * The nearest directory from `dir` up to relativeTo that holds `packageManager`'s lock, as the lock file walk
206
+ * finds it; `dir` where none does.
207
+ */
208
+ private projectRoot(dir: string, packageManager: PackageManager): string {
209
+ const lockFile = getLockFileName(packageManager);
210
+ const stop = this.relativeTo ? path.resolve(this.relativeTo) : undefined;
211
+ for (let current = dir; ; current = path.dirname(current)) {
212
+ if (fs.existsSync(path.join(current, lockFile))) {
213
+ return current;
214
+ }
215
+ if (current === stop || path.dirname(current) === current) {
216
+ return dir;
181
217
  }
182
218
  }
183
219
  }
@@ -343,6 +379,9 @@ export class PackageJsonParser extends Parser {
343
379
  continue;
344
380
  }
345
381
 
382
+ // a pattern without wildcards names the directory itself
383
+ candidates.push(absoluteBaseDir);
384
+
346
385
  // Use walkDirs with appropriate depth limit
347
386
  const dirs = await walkDirs(absoluteBaseDir, {
348
387
  maxDepth: hasRecursive ? undefined : 0,
@@ -353,7 +392,7 @@ export class PackageJsonParser extends Parser {
353
392
  }
354
393
  }
355
394
 
356
- return candidates;
395
+ return [...new Set(candidates)];
357
396
  }
358
397
 
359
398
  /**
@@ -429,29 +468,41 @@ export class PackageJsonParser extends Parser {
429
468
  ): Promise<{ content: PackageLockContent; packageManager: PackageManager } | undefined> {
430
469
  // Normalize paths for comparison
431
470
  const normalizedRoot = rootDir ? path.resolve(rootDir) : undefined;
432
- let currentDir = path.resolve(startDir);
433
471
 
434
- // Walk up the directory tree looking for a lock file
472
+ // Each directory from startDir up, stopping at rootDir (or the filesystem root)
473
+ const dirs: string[] = [];
474
+ let currentDir = path.resolve(startDir);
435
475
  while (true) {
436
- const result = await this.tryReadLockFile(currentDir);
437
- if (result) {
438
- return result;
439
- }
440
-
441
- // If we've reached rootDir, stop walking (don't go above it)
476
+ dirs.push(currentDir);
442
477
  if (normalizedRoot && currentDir === normalizedRoot) {
443
478
  break;
444
479
  }
445
-
446
- // Check if we've reached the filesystem root
447
480
  const parentDir = path.dirname(currentDir);
448
481
  if (parentDir === currentDir) {
449
482
  break;
450
483
  }
451
-
452
484
  currentDir = parentDir;
453
485
  }
454
486
 
487
+ // A lock file names the package manager, wherever it is on the way up. An installed
488
+ // workspace member has node_modules of its own and no lock: its workspace's lock, above it,
489
+ // says which package manager installed them.
490
+ for (const dir of dirs) {
491
+ const result = await this.tryReadLockFile(dir);
492
+ if (result) {
493
+ return result;
494
+ }
495
+ }
496
+
497
+ // Fallback: node_modules without a lock file anywhere above (e.g., symlinked from another
498
+ // workspace). Walk them for resolved dependency information, and assume npm.
499
+ for (const dir of dirs) {
500
+ const parsed = await this.walkNodeModules(dir);
501
+ if (parsed) {
502
+ return { content: parsed, packageManager: PackageManager.Npm };
503
+ }
504
+ }
505
+
455
506
  return undefined;
456
507
  }
457
508
 
@@ -504,14 +555,6 @@ export class PackageJsonParser extends Parser {
504
555
  }
505
556
  }
506
557
 
507
- // Fallback: if node_modules exists but no lock file was found (e.g., symlinked from another workspace),
508
- // walk node_modules to get resolved dependency information
509
- const parsed = await this.walkNodeModules(dir);
510
- if (parsed) {
511
- // Assume npm as the default package manager when only node_modules exists
512
- return { content: parsed, packageManager: PackageManager.Npm };
513
- }
514
-
515
558
  return undefined;
516
559
  }
517
560
 
@@ -52,6 +52,9 @@ interface PackageManagerConfig {
52
52
 
53
53
  /** Command to list dependencies in JSON format (if supported) */
54
54
  listCommand?: string[];
55
+
56
+ /** Command that prints the release of the package manager that runs where it is run */
57
+ versionCommand: string[];
55
58
  }
56
59
 
57
60
  /**
@@ -69,6 +72,7 @@ const PACKAGE_MANAGER_CONFIGS: Record<PackageManager, PackageManagerConfig> = {
69
72
  installLockOnlyCommand: ['npm', 'install', '--package-lock-only', '--ignore-scripts', '--legacy-peer-deps'],
70
73
  installCommand: ['npm', 'install', '--ignore-scripts', '--legacy-peer-deps'],
71
74
  listCommand: ['npm', 'list', '--json', '--all'],
75
+ versionCommand: ['npm', '--version'],
72
76
  },
73
77
  [PackageManager.YarnClassic]: {
74
78
  lockFile: 'yarn.lock',
@@ -76,6 +80,7 @@ const PACKAGE_MANAGER_CONFIGS: Record<PackageManager, PackageManagerConfig> = {
76
80
  installLockOnlyCommand: ['yarn', 'install', '--ignore-scripts'],
77
81
  installCommand: ['yarn', 'install', '--ignore-scripts'],
78
82
  listCommand: ['yarn', 'list', '--json'],
83
+ versionCommand: ['yarn', '--version'],
79
84
  },
80
85
  [PackageManager.YarnBerry]: {
81
86
  lockFile: 'yarn.lock',
@@ -83,6 +88,7 @@ const PACKAGE_MANAGER_CONFIGS: Record<PackageManager, PackageManagerConfig> = {
83
88
  installLockOnlyCommand: ['yarn', 'install', '--mode', 'skip-build'],
84
89
  installCommand: ['yarn', 'install', '--mode', 'skip-build'],
85
90
  listCommand: ['yarn', 'info', '--all', '--json'],
91
+ versionCommand: ['yarn', '--version'],
86
92
  },
87
93
  [PackageManager.Pnpm]: {
88
94
  lockFile: 'pnpm-lock.yaml',
@@ -92,12 +98,14 @@ const PACKAGE_MANAGER_CONFIGS: Record<PackageManager, PackageManagerConfig> = {
92
98
  installLockOnlyCommand: ['pnpm', 'install', '--lockfile-only', '--ignore-scripts', '--no-strict-peer-dependencies'],
93
99
  installCommand: ['pnpm', 'install', '--ignore-scripts', '--no-strict-peer-dependencies'],
94
100
  listCommand: ['pnpm', 'list', '--json', '--depth=Infinity'],
101
+ versionCommand: ['pnpm', '--version'],
95
102
  },
96
103
  [PackageManager.Bun]: {
97
104
  lockFile: 'bun.lock',
98
105
  // Bun doesn't have a lock-only mode; --ignore-scripts prevents lifecycle scripts
99
106
  installLockOnlyCommand: ['bun', 'install', '--ignore-scripts'],
100
107
  installCommand: ['bun', 'install', '--ignore-scripts'],
108
+ versionCommand: ['bun', '--version'],
101
109
  },
102
110
  };
103
111
 
@@ -310,6 +318,46 @@ export function runList(pm: PackageManager, cwd: string, timeout: number = 30000
310
318
  return result.stdout;
311
319
  }
312
320
 
321
+ /**
322
+ * A release as a package manager's `--version` prints it: `major.minor.patch`, with a prerelease or build or both.
323
+ */
324
+ const RELEASE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
325
+
326
+ /**
327
+ * What the package manager's `--version` answers, run in `cwd`: in the project's root directory, the release that
328
+ * would install the project on this machine, since Yarn and pnpm decide there which release runs (`yarnPath`,
329
+ * corepack, pnpm's own switching).
330
+ *
331
+ * It runs as the parser's other package manager commands do, with `COREPACK_ENABLE_DOWNLOAD_PROMPT=0`, no input and
332
+ * a short timeout, so it never prompts and never throws.
333
+ *
334
+ * @param pm The package manager to ask
335
+ * @param cwd The project's root directory
336
+ * @param timeout Timeout in milliseconds
337
+ * @returns The trimmed answer, or undefined when the package manager is not installed, fails, or answers something
338
+ * that is no version
339
+ */
340
+ export function runVersion(pm: PackageManager, cwd: string, timeout: number = 10000): string | undefined {
341
+ const [cmd, ...args] = PACKAGE_MANAGER_CONFIGS[pm].versionCommand;
342
+ try {
343
+ const result = spawnSync(getPlatformCommand(cmd), args, {
344
+ cwd,
345
+ encoding: 'utf-8',
346
+ stdio: ['ignore', 'pipe', 'pipe'],
347
+ timeout,
348
+ env: {...process.env, COREPACK_ENABLE_DOWNLOAD_PROMPT: '0'},
349
+ ...(os.platform() === 'win32' ? { shell: true } : {})
350
+ });
351
+ if (result.error || result.status !== 0 || typeof result.stdout !== 'string') {
352
+ return undefined;
353
+ }
354
+ const answer = result.stdout.trim();
355
+ return RELEASE.test(answer) ? answer : undefined;
356
+ } catch {
357
+ return undefined;
358
+ }
359
+ }
360
+
313
361
  /**
314
362
  * Result of running install in a temporary directory.
315
363
  */
@@ -89,6 +89,25 @@ type WithInvalidSyntaxSlots<T> = T & {
89
89
  modifiers?: ts.NodeArray<ts.ModifierLike>;
90
90
  };
91
91
 
92
+ interface CompiledProject {
93
+ readonly program: ts.Program;
94
+ readonly typeMapping: JavaScriptTypeMapping;
95
+ }
96
+
97
+ /**
98
+ * The inputs of one parse that a single tsconfig governs, or that none does.
99
+ */
100
+ interface Project {
101
+ /** Path of the governing config file, empty where there is none. */
102
+ readonly key: string;
103
+ readonly options: ts.CompilerOptions;
104
+ readonly rootNames: SourcePath[];
105
+ /** How many of the inputs are still to be mapped. */
106
+ unmapped: number;
107
+ /** Present from the first input mapped until the last. */
108
+ compiled?: CompiledProject;
109
+ }
110
+
92
111
  export class JavaScriptParser extends Parser {
93
112
 
94
113
  private readonly tsConfig: TsConfigResolver;
@@ -216,8 +235,8 @@ export class JavaScriptParser extends Parser {
216
235
 
217
236
  override async* parse(...inputs: ParserInput[]): AsyncGenerator<SourceFile> {
218
237
  const inputFiles = new Map<SourcePath, ParserInput>();
219
- const projectOf = new Map<SourcePath, string>();
220
- const projects = new Map<string, { options: ts.CompilerOptions, rootNames: SourcePath[] }>();
238
+ const projectOf = new Map<SourcePath, Project>();
239
+ const projects = new Map<string, Project>();
221
240
 
222
241
  // Populate inputFiles map and remove from cache if necessary
223
242
  for (const input of inputs) {
@@ -236,12 +255,13 @@ export class JavaScriptParser extends Parser {
236
255
  for (const normalizedSourcePath of inputFiles.keys()) {
237
256
  const {configFilePath, options} = this.tsConfig.forFile(normalizedSourcePath);
238
257
  const projectKey = configFilePath ?? "";
239
- projectOf.set(normalizedSourcePath, projectKey);
240
258
  let project = projects.get(projectKey);
241
259
  if (!project) {
242
- projects.set(projectKey, project = {options, rootNames: []});
260
+ projects.set(projectKey, project = {key: projectKey, options, rootNames: [], unmapped: 0});
243
261
  }
244
262
  project.rootNames.push(normalizedSourcePath);
263
+ project.unmapped++;
264
+ projectOf.set(normalizedSourcePath, project);
245
265
  }
246
266
 
247
267
  // Module resolution probes a directory before the files in it, so a directory holding
@@ -253,53 +273,74 @@ export class JavaScriptParser extends Parser {
253
273
  }
254
274
  }
255
275
 
256
- const compiled = new Map<string, { program: ts.Program, typeMapping: JavaScriptTypeMapping }>();
257
- for (const [projectKey, {options, rootNames}] of projects) {
258
- // TypeScript carries the previous program's bound files forward when the root paths match,
259
- // so a parser held across parses of one path costs a fraction of a freshly built one.
260
- const program = ts.createProgram(rootNames, options,
261
- this.createCompilerHost(options, inputFiles, inputDirectories), this.oldPrograms.get(projectKey));
262
- this.oldPrograms.set(projectKey, program);
263
-
264
- // A JavaScriptTypeMapping deduplicates by `ts.Type.id`, which is only unique within
265
- // the TypeChecker that issued it, so each program needs its own.
266
- compiled.set(projectKey, {program, typeMapping: new JavaScriptTypeMapping(program.getTypeChecker(), this.relativeTo)});
267
- }
276
+ // Each project's program holds its own copy of the standard library and of every dependency
277
+ // it reaches, a multiple of the sources it is built for. A repository of many projects cannot
278
+ // afford them all at once, so only one that is alone in its parse is kept for the next.
279
+ const carryForward = projects.size === 1;
268
280
 
269
281
  for (const [sourcePath, input] of inputFiles) {
270
- const {program, typeMapping} = compiled.get(projectOf.get(sourcePath)!)!;
271
- const filePath = parserInputFile(input);
272
- const sourceFile = program.getSourceFile(filePath);
273
- if (!sourceFile) {
274
- yield this.error(input, new Error('Parser returned undefined'));
275
- continue;
276
- }
282
+ // Mapped by a call rather than inline: what a generator holds at a `yield` stays reachable
283
+ // until it is resumed, and here that would be the program of a project already finished.
284
+ yield await this.parseInput(input, projectOf.get(sourcePath)!, carryForward, inputFiles, inputDirectories);
285
+ }
286
+ }
277
287
 
278
- if (hasFlowAnnotation(sourceFile)) {
279
- yield this.error(input, new FlowSyntaxNotSupportedError("Flow syntax not supported"));
280
- continue;
281
- }
288
+ private async parseInput(input: ParserInput, project: Project, carryForward: boolean,
289
+ inputFiles: Map<SourcePath, ParserInput>,
290
+ inputDirectories: Set<string>): Promise<SourceFile> {
291
+ // Built when the first of a project's inputs comes up and let go with its last, so that the
292
+ // programs alive at any one time are those of the projects still being worked through.
293
+ const {program, typeMapping} = project.compiled ??=
294
+ this.compile(project, carryForward, inputFiles, inputDirectories);
295
+ if (--project.unmapped === 0) {
296
+ project.compiled = undefined;
297
+ }
282
298
 
283
- const syntaxErrors = checkSyntaxErrors(program, sourceFile);
284
- if (syntaxErrors.length > 0) {
285
- let errors = syntaxErrors.map(e => `${e[0]} [${e[1]}]`).join('; ');
286
- yield this.error(input, new SyntaxError(`Compiler error(s): ${errors}`));
287
- continue;
288
- }
299
+ const filePath = parserInputFile(input);
300
+ const sourceFile = program.getSourceFile(filePath);
301
+ if (!sourceFile) {
302
+ return this.error(input, new Error('Parser returned undefined'));
303
+ }
289
304
 
290
- try {
291
- yield await this.requirePrintEqualsInput(produce(
292
- new JavaScriptParserVisitor(sourceFile, this.relativePath(input), typeMapping)
293
- .visit(sourceFile) as SourceFile,
294
- draft => {
295
- if (this.styles) {
296
- draft.markers = this.styles.reduce((m, s) => replaceMarkerByKind(m, s), draft.markers);
297
- }
298
- }), input, escapeLoneSurrogates(sourceFile.getFullText()));
299
- } catch (error) {
300
- yield this.error(input, error instanceof Error ? error : new Error('Parser threw unknown error: ' + error));
301
- }
305
+ if (hasFlowAnnotation(sourceFile)) {
306
+ return this.error(input, new FlowSyntaxNotSupportedError("Flow syntax not supported"));
307
+ }
308
+
309
+ const syntaxErrors = checkSyntaxErrors(program, sourceFile);
310
+ if (syntaxErrors.length > 0) {
311
+ let errors = syntaxErrors.map(e => `${e[0]} [${e[1]}]`).join('; ');
312
+ return this.error(input, new SyntaxError(`Compiler error(s): ${errors}`));
313
+ }
314
+
315
+ try {
316
+ return await this.requirePrintEqualsInput(produce(
317
+ new JavaScriptParserVisitor(sourceFile, this.relativePath(input), typeMapping)
318
+ .visit(sourceFile) as SourceFile,
319
+ draft => {
320
+ if (this.styles) {
321
+ draft.markers = this.styles.reduce((m, s) => replaceMarkerByKind(m, s), draft.markers);
322
+ }
323
+ }), input, escapeLoneSurrogates(sourceFile.getFullText()));
324
+ } catch (error) {
325
+ return this.error(input, error instanceof Error ? error : new Error('Parser threw unknown error: ' + error));
326
+ }
327
+ }
328
+
329
+ private compile(project: Project, carryForward: boolean, inputFiles: Map<SourcePath, ParserInput>,
330
+ inputDirectories: Set<string>): CompiledProject {
331
+ // TypeScript carries the previous program's bound files forward when the root paths match,
332
+ // so a parser held across parses of one path costs a fraction of a freshly built one.
333
+ const program = ts.createProgram(project.rootNames, project.options,
334
+ this.createCompilerHost(project.options, inputFiles, inputDirectories), this.oldPrograms.get(project.key));
335
+ if (carryForward) {
336
+ this.oldPrograms.set(project.key, program);
337
+ } else {
338
+ this.oldPrograms.delete(project.key);
302
339
  }
340
+
341
+ // A JavaScriptTypeMapping deduplicates by `ts.Type.id`, which is only unique within
342
+ // the TypeChecker that issued it, so each program needs its own.
343
+ return {program, typeMapping: new JavaScriptTypeMapping(program.getTypeChecker(), this.relativeTo)};
303
344
  }
304
345
 
305
346
  private createCompilerHost(compilerOptions: ts.CompilerOptions, inputFiles: Map<SourcePath, ParserInput>,
@@ -91,31 +91,54 @@ export function* jsonFragments(value: unknown): Generator<string> {
91
91
  yield JSON.stringify(value) as string;
92
92
  }
93
93
 
94
+ /**
95
+ * Streams {@link jsonFragments} into Buffers so that no more than one flush window is a JS string
96
+ * at a time, raising the outbound ceiling from V8's String.kMaxLength to Node's Buffer.MAX_LENGTH.
97
+ * Content-Length still needs the whole body up front, so this is a ceiling raise and not true
98
+ * streaming.
99
+ *
100
+ * Exported because {@link chunkedJsonEncoder} reaches it only above the cap: a test of the walk has
101
+ * to call it directly.
102
+ */
103
+ export function encodeFragmented(msg: unknown, charset: BufferEncoding): Buffer {
104
+ const buffers: Buffer[] = [];
105
+ let pending = "";
106
+ for (const fragment of jsonFragments(msg)) {
107
+ pending += fragment;
108
+ if (pending.length >= FLUSH_THRESHOLD) {
109
+ buffers.push(Buffer.from(pending, charset));
110
+ pending = "";
111
+ }
112
+ }
113
+ if (pending.length > 0) {
114
+ buffers.push(Buffer.from(pending, charset));
115
+ }
116
+ return Buffer.concat(buffers);
117
+ }
118
+
94
119
  /**
95
120
  * A drop-in replacement for vscode-jsonrpc's default `application/json` content-type encoder that
96
- * serializes a message to UTF-8 bytes without ever materializing the whole document as a single JS
97
- * string. The default encoder does {@code Buffer.from(JSON.stringify(msg))}; a large enough
98
- * PrepareRecipe response (a deep recipe tree) overflows V8's single-string limit and throws
99
- * "RangeError: Invalid string length", hanging the RPC call. Streaming the
100
- * fragments into a Buffer raises the ceiling to Node's Buffer.MAX_LENGTH (multiple GB) while
101
- * producing byte-identical output. (True unbounded streaming needs chunked framing — Content-Length
102
- * still requires the whole body up front — so this is a ceiling raise, not a removal.)
121
+ * keeps a message serializable past V8's single-string limit. The default encoder does {@code
122
+ * Buffer.from(JSON.stringify(msg))}; a large enough PrepareRecipe response (a deep recipe tree)
123
+ * overflows that limit and throws "RangeError: Invalid string length", hanging the RPC call.
124
+ *
125
+ * Below the limit it takes the default's own single-string path: {@link encodeFragmented} would
126
+ * assemble exactly that string anyway, at a yield per scalar, key and structural token to get
127
+ * there — ~5x slower on a thousand-element batch. Only a message that cannot be one string pays
128
+ * for the walk, as {@link chunkedJsonDecoder} does inbound.
103
129
  */
104
130
  export const chunkedJsonEncoder: JsonRpcContentTypeEncoder = {
105
131
  name: "application/json",
106
132
  encode(msg: Message, options: { charset: BufferEncoding }): Promise<Uint8Array> {
107
- const buffers: Buffer[] = [];
108
- let pending = "";
109
- for (const fragment of jsonFragments(msg)) {
110
- pending += fragment;
111
- if (pending.length >= FLUSH_THRESHOLD) {
112
- buffers.push(Buffer.from(pending, options.charset));
113
- pending = "";
133
+ try {
134
+ return Promise.resolve(Buffer.from(JSON.stringify(msg), options.charset));
135
+ } catch (err) {
136
+ // A cycle or a bigint is a TypeError the walk cannot serialize either — and would meet
137
+ // by recursing until the stack ended. Only the string limit is worth a second attempt.
138
+ if (!(err instanceof RangeError)) {
139
+ return Promise.reject(err);
114
140
  }
115
141
  }
116
- if (pending.length > 0) {
117
- buffers.push(Buffer.from(pending, options.charset));
118
- }
119
- return Promise.resolve(Buffer.concat(buffers));
142
+ return Promise.resolve(encodeFragmented(msg, options.charset));
120
143
  }
121
144
  };
@@ -29,14 +29,15 @@ type ExportedTypes = typeof import("../../javascript/package-exported-types.js")
29
29
 
30
30
  /**
31
31
  * Handle a DependencyTypes request: resolve one dependency, named by its npm coordinate, against
32
- * the most recently parsed project's node_modules, enumerate its public types, and stream them
33
- * back terminated by END_OF_OBJECT. Built once, returned in {@code batchSize} slices across the
34
- * client's repeated pulls. A {@code null} version names a TypeScript runtime declaration file
35
- * (e.g. {@code lib.dom}) instead of an installed package.
32
+ * the node_modules of {@code path} or else of the most recently parsed project, enumerate its
33
+ * public types, and stream them back terminated by END_OF_OBJECT. Built once, returned in
34
+ * {@code batchSize} slices across the client's repeated pulls. A {@code null} version names a
35
+ * TypeScript runtime declaration file (e.g. {@code lib.dom}) instead of an installed package.
36
36
  */
37
37
  export class DependencyTypes {
38
38
  constructor(private readonly name: string,
39
- private readonly version?: string | null) {
39
+ private readonly version?: string | null,
40
+ private readonly path?: string | null) {
40
41
  }
41
42
 
42
43
  static handle(connection: rpc.MessageConnection, batchSize: number, metricsCsv?: string): void {
@@ -51,15 +52,17 @@ export class DependencyTypes {
51
52
  metricsCsv,
52
53
  (context) => async (request) => {
53
54
  const version = request.version ?? null;
55
+ const projectDir = request.path ?? null;
54
56
  context.target = version ? `${request.name}@${version}` : request.name;
55
57
 
56
- const key = JSON.stringify([request.name, version]);
58
+ const key = JSON.stringify([request.name, version, projectDir]);
57
59
  if (!pending.has(key)) {
58
60
  // Dynamic import to break the circular dependency with the javascript module.
59
61
  const {exportedTypes} = await import("../../javascript/package-exported-types.js");
60
- const types = version !== null
61
- ? packageTypes(request.name, exportedTypes)
62
- : exportedTypes([runtimeLibFile(request.name)], []);
62
+ const types = version === null
63
+ ? exportedTypes([runtimeLibFile(request.name)], [])
64
+ : packageTypes(request.name, exportedTypes,
65
+ projectDir === null ? projectNodeModules() : path.join(projectDir, "node_modules"));
63
66
 
64
67
  const q = new RpcSendQueue(new ReferenceMap(), JAVA_TYPE_CLASS, false);
65
68
  const sender = new JavaSender();
@@ -98,8 +101,7 @@ function projectNodeModules(): string {
98
101
  }
99
102
 
100
103
  /** The installed package's own types, falling back to its DefinitelyTyped package when it ships none. */
101
- function packageTypes(name: string, exportedTypes: ExportedTypes): Type.FullyQualified[] {
102
- const nodeModules = projectNodeModules();
104
+ function packageTypes(name: string, exportedTypes: ExportedTypes, nodeModules: string): Type.FullyQualified[] {
103
105
  const pkgDir = path.join(nodeModules, ...name.split("/"));
104
106
  if (!fs.existsSync(pkgDir)) {
105
107
  throw new Error(`Package '${name}' is not installed under ${nodeModules}`);