@danypops/lector 0.1.6 → 0.1.8

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 (35) hide show
  1. package/README.md +9 -7
  2. package/package.json +5 -1
  3. package/src/adapters/fallback-code-intelligence-index.ts +73 -0
  4. package/src/adapters/find-source-files.ts +1 -1
  5. package/src/adapters/git-repo-fetcher.ts +154 -37
  6. package/src/adapters/lsp/json-rpc-stream.ts +52 -2
  7. package/src/adapters/lsp/language-server-process.ts +54 -10
  8. package/src/adapters/lsp/lsp-symbol-index.ts +133 -32
  9. package/src/adapters/normalize-npm-repository.ts +77 -0
  10. package/src/adapters/npm-lockfile-version-resolver.ts +519 -0
  11. package/src/adapters/npm-package-source-resolver.ts +322 -0
  12. package/src/adapters/npm-registry-client.ts +304 -0
  13. package/src/adapters/sqlite-symbol-graph.ts +47 -3
  14. package/src/adapters/tree-sitter/typescript-tree-sitter-symbol-index.ts +61 -11
  15. package/src/adapters/typescript-compiler-symbol-index.ts +129 -0
  16. package/src/cli.ts +87 -23
  17. package/src/daemon.ts +4 -0
  18. package/src/domain/find-workspace-symbols.ts +4 -3
  19. package/src/domain/installed-package-version.ts +66 -0
  20. package/src/domain/intelligence-provenance.ts +19 -0
  21. package/src/domain/language-server-descriptor.ts +18 -0
  22. package/src/domain/npm-package-metadata.ts +26 -0
  23. package/src/domain/package-source.ts +143 -0
  24. package/src/domain/repo-fetch-result.ts +33 -1
  25. package/src/domain/resolve-package-source.ts +204 -0
  26. package/src/domain/symbol-graph-generation.ts +3 -0
  27. package/src/domain/symbol-query.ts +16 -0
  28. package/src/domain/workspace-symbol.ts +8 -0
  29. package/src/index.ts +61 -4
  30. package/src/ports/installed-package-version-resolver-port.ts +5 -0
  31. package/src/ports/npm-registry-port.ts +5 -0
  32. package/src/ports/package-source-resolver-port.ts +5 -0
  33. package/src/ports/repo-fetcher-port.ts +2 -2
  34. package/src/ports/symbol-index-port.ts +4 -2
  35. package/src/service.ts +89 -25
@@ -0,0 +1,322 @@
1
+ import { readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { join, relative, resolve, sep } from "node:path";
3
+ import type { InstalledPackageVersionCandidate, InstalledPackageVersionOutcome } from "../domain/installed-package-version.ts";
4
+ import type { NpmPackageVersionMetadata } from "../domain/npm-package-metadata.ts";
5
+ import type { PackageSourceBounds, PackageSourceOutcome, PackageSourceRequest, VerifiedPackageSource } from "../domain/package-source.ts";
6
+ import { RepoFetchFailed, RepoFetchLimitExceeded, type RepoFetchResult } from "../domain/repo-fetch-result.ts";
7
+ import type { InstalledPackageVersionResolverPort } from "../ports/installed-package-version-resolver-port.ts";
8
+ import type { NpmRegistryPort } from "../ports/npm-registry-port.ts";
9
+ import type { PackageSourceResolverPort } from "../ports/package-source-resolver-port.ts";
10
+ import type { RepoFetcherPort } from "../ports/repo-fetcher-port.ts";
11
+ import { type NormalizedNpmRepository, normalizeNpmRepository, npmRepositoryReference } from "./normalize-npm-repository.ts";
12
+ import {
13
+ DEFAULT_NPM_REGISTRY,
14
+ NpmPackageNotFound,
15
+ NpmRegistryAuthenticationRequired,
16
+ NpmRegistryRequestFailed,
17
+ NpmRegistryResponseLimitExceeded,
18
+ NpmVersionNotFound,
19
+ } from "./npm-registry-client.ts";
20
+
21
+ const COMMIT_HASH = /^[0-9a-f]{40,64}$/i;
22
+
23
+ export interface NpmPackageSourceResolverOptions {
24
+ readonly versions: InstalledPackageVersionResolverPort;
25
+ readonly registry: NpmRegistryPort;
26
+ readonly repositories: RepoFetcherPort;
27
+ }
28
+
29
+ interface SourceIdentity {
30
+ readonly name: string | null;
31
+ readonly version: string | null;
32
+ }
33
+
34
+ function isRecord(value: unknown): value is Record<string, unknown> {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+
38
+ function textField(record: Record<string, unknown>, key: string): string | null {
39
+ const value = record[key];
40
+ return typeof value === "string" && value.length > 0 ? value : null;
41
+ }
42
+
43
+ function bounded(value: string, maxLength = 4096): string {
44
+ return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`;
45
+ }
46
+
47
+ function candidateSource(candidate: InstalledPackageVersionCandidate): string {
48
+ const first = candidate.evidence[0];
49
+ return first ? bounded(`${first.lockfile}:${first.locator}`) : "lockfile";
50
+ }
51
+
52
+ function installedOutcome(outcome: InstalledPackageVersionOutcome, bounds: PackageSourceBounds): PackageSourceOutcome | string {
53
+ switch (outcome.status) {
54
+ case "resolved":
55
+ return outcome.version;
56
+ case "ambiguous":
57
+ return {
58
+ status: "ambiguous",
59
+ code: "multiple-installed-versions",
60
+ candidates: outcome.candidates.slice(0, bounds.maxCandidates).map((candidate) => ({ version: candidate.version, source: candidateSource(candidate) })),
61
+ truncated: outcome.truncated || outcome.candidates.length > bounds.maxCandidates,
62
+ };
63
+ case "oversized":
64
+ return {
65
+ status: "oversized",
66
+ code: "manifest-limit-exceeded",
67
+ resource: outcome.resource,
68
+ limit: outcome.limit,
69
+ observed: null,
70
+ };
71
+ case "unavailable":
72
+ return {
73
+ status: "unavailable",
74
+ code:
75
+ outcome.code === "package-not-found" || outcome.code === "lockfile-not-found"
76
+ ? "package-not-found"
77
+ : outcome.code === "version-not-found"
78
+ ? "version-not-found"
79
+ : "unsupported-manifest",
80
+ };
81
+ }
82
+ }
83
+
84
+ function sourceNesting(text: string): number {
85
+ let depth = 0;
86
+ let maximum = 0;
87
+ let quoted = false;
88
+ let escaped = false;
89
+ for (const character of text) {
90
+ if (quoted) {
91
+ if (escaped) escaped = false;
92
+ else if (character === "\\") escaped = true;
93
+ else if (character === '"') quoted = false;
94
+ continue;
95
+ }
96
+ if (character === '"') quoted = true;
97
+ else if (character === "{" || character === "[") {
98
+ depth++;
99
+ maximum = Math.max(maximum, depth);
100
+ } else if (character === "}" || character === "]") depth = Math.max(0, depth - 1);
101
+ }
102
+ return maximum;
103
+ }
104
+
105
+ function sourceIdentity(repositoryRoot: string, directory: string | null, bounds: PackageSourceBounds): SourceIdentity | PackageSourceOutcome {
106
+ const root = realpathSync(repositoryRoot);
107
+ const packageRoot = realpathSync(directory === null ? root : resolve(root, directory));
108
+ const relativePath = relative(root, packageRoot);
109
+ if (relativePath === ".." || relativePath.startsWith(`..${sep}`)) return { status: "unavailable", code: "unverifiable-source" };
110
+ let manifestPath: string;
111
+ let size: number;
112
+ try {
113
+ manifestPath = realpathSync(join(packageRoot, "package.json"));
114
+ const manifestRelativePath = relative(root, manifestPath);
115
+ if (manifestRelativePath === ".." || manifestRelativePath.startsWith(`..${sep}`)) return { status: "unavailable", code: "unverifiable-source" };
116
+ const stats = statSync(manifestPath);
117
+ if (!stats.isFile()) return { status: "unavailable", code: "unverifiable-source" };
118
+ size = stats.size;
119
+ } catch {
120
+ return { status: "unavailable", code: "unverifiable-source" };
121
+ }
122
+ if (size > bounds.maxManifestBytes) {
123
+ return { status: "oversized", code: "manifest-limit-exceeded", resource: "manifest-bytes", limit: bounds.maxManifestBytes, observed: size };
124
+ }
125
+ const text = readFileSync(manifestPath, "utf8");
126
+ const nesting = sourceNesting(text);
127
+ if (nesting > bounds.maxManifestNesting) {
128
+ return { status: "oversized", code: "manifest-limit-exceeded", resource: "manifest-nesting", limit: bounds.maxManifestNesting, observed: nesting };
129
+ }
130
+ let parsed: unknown;
131
+ try {
132
+ parsed = JSON.parse(text);
133
+ } catch {
134
+ return { status: "unavailable", code: "unverifiable-source" };
135
+ }
136
+ if (!isRecord(parsed)) return { status: "unavailable", code: "unverifiable-source" };
137
+ return { name: textField(parsed, "name"), version: textField(parsed, "version") };
138
+ }
139
+
140
+ function packageRoot(repositoryRoot: string, directory: string | null): string {
141
+ return directory === null ? repositoryRoot : resolve(repositoryRoot, directory);
142
+ }
143
+
144
+ function candidateRefs(name: string, version: string, maxCandidates: number): readonly string[] {
145
+ const unscoped = name.includes("/") ? (name.split("/").at(-1) ?? name) : name;
146
+ return Array.from(new Set([`v${version}`, `${unscoped}@${version}`, `${name}@${version}`, version]))
147
+ .filter((ref) => ref.length <= 512)
148
+ .slice(0, maxCandidates);
149
+ }
150
+
151
+ function verifiedOutcome(
152
+ request: PackageSourceRequest,
153
+ version: string,
154
+ repository: NormalizedNpmRepository,
155
+ ref: string,
156
+ result: RepoFetchResult,
157
+ ): VerifiedPackageSource {
158
+ return {
159
+ status: "verified",
160
+ coordinate: { ...request.coordinate, resolvedVersion: version },
161
+ repository: {
162
+ url: repository.url,
163
+ requestedRef: ref,
164
+ resolvedRef: result.resolvedRef,
165
+ commit: result.commit,
166
+ },
167
+ workspace: {
168
+ cachePath: packageRoot(result.path, repository.directory),
169
+ origin: "fetched",
170
+ readOnly: true,
171
+ },
172
+ verification: {
173
+ status: "verified",
174
+ method: "registry-metadata-and-commit",
175
+ integrity: `git:${result.commit}`,
176
+ },
177
+ };
178
+ }
179
+
180
+ export class NpmPackageSourceResolver implements PackageSourceResolverPort {
181
+ private readonly versions: InstalledPackageVersionResolverPort;
182
+ private readonly registry: NpmRegistryPort;
183
+ private readonly repositories: RepoFetcherPort;
184
+
185
+ constructor(options: NpmPackageSourceResolverOptions) {
186
+ this.versions = options.versions;
187
+ this.registry = options.registry;
188
+ this.repositories = options.repositories;
189
+ }
190
+
191
+ async resolve(request: PackageSourceRequest, bounds: PackageSourceBounds): Promise<PackageSourceOutcome> {
192
+ if (request.coordinate.ecosystem !== "npm") return { status: "unavailable", code: "unsupported-ecosystem" };
193
+ const installed = await this.versions.resolve(
194
+ {
195
+ projectRoot: request.projectRoot,
196
+ packageName: request.coordinate.name,
197
+ requestedVersion: request.coordinate.requestedVersion,
198
+ },
199
+ {
200
+ maxManifestBytes: bounds.maxManifestBytes,
201
+ maxManifestEntries: bounds.maxManifestEntries,
202
+ maxManifestNesting: bounds.maxManifestNesting,
203
+ maxWorkspaces: bounds.maxWorkspaces,
204
+ maxDiagnostics: bounds.maxDiagnostics,
205
+ maxCandidates: bounds.maxCandidates,
206
+ maxEvidencePerVersion: bounds.maxCandidates,
207
+ },
208
+ );
209
+ const resolved = installedOutcome(installed, bounds);
210
+ if (typeof resolved !== "string") return resolved;
211
+ const version = resolved;
212
+
213
+ let metadata: NpmPackageVersionMetadata;
214
+ try {
215
+ metadata = await this.registry.fetchVersion(
216
+ { registry: request.coordinate.registry ?? DEFAULT_NPM_REGISTRY, name: request.coordinate.name, version },
217
+ {
218
+ maxResponseBytes: bounds.maxRegistryResponseBytes,
219
+ maxRedirects: bounds.maxRedirects,
220
+ maxRetries: bounds.maxRetries,
221
+ timeoutMs: bounds.timeoutMs,
222
+ },
223
+ );
224
+ } catch (error) {
225
+ if (error instanceof NpmPackageNotFound) return { status: "unavailable", code: "package-not-found" };
226
+ if (error instanceof NpmVersionNotFound) return { status: "unavailable", code: "version-not-found" };
227
+ if (error instanceof NpmRegistryAuthenticationRequired) {
228
+ return { status: "unauthenticated", code: "registry-authentication-required", requiredCredentialNames: error.requiredCredentialNames };
229
+ }
230
+ if (error instanceof NpmRegistryResponseLimitExceeded) {
231
+ return {
232
+ status: "oversized",
233
+ code: "registry-response-limit-exceeded",
234
+ resource: "registry-response-bytes",
235
+ limit: error.limit,
236
+ observed: error.observed,
237
+ };
238
+ }
239
+ if (error instanceof NpmRegistryRequestFailed) return { status: "unavailable", code: "unverifiable-source" };
240
+ return { status: "unavailable", code: "unverifiable-source" };
241
+ }
242
+
243
+ if (metadata.name !== request.coordinate.name || metadata.version !== version) {
244
+ return {
245
+ status: "mismatched",
246
+ code: "coordinate-mismatch",
247
+ expected: bounded(`${request.coordinate.name}@${version}`),
248
+ actual: bounded(`${metadata.name}@${metadata.version}`),
249
+ };
250
+ }
251
+ if (metadata.repository === null) return { status: "unavailable", code: "source-metadata-missing" };
252
+ const repository = normalizeNpmRepository(metadata.repository);
253
+ if (repository === null) return { status: "unavailable", code: "source-metadata-missing" };
254
+ if (metadata.gitHead !== null && !COMMIT_HASH.test(metadata.gitHead)) return { status: "unavailable", code: "unverifiable-source" };
255
+ const refs = metadata.gitHead === null ? candidateRefs(request.coordinate.name, version, bounds.maxCandidates) : [metadata.gitHead];
256
+ const deadline = Date.now() + bounds.timeoutMs;
257
+ let firstMismatch: PackageSourceOutcome | undefined;
258
+ for (const ref of refs) {
259
+ const remaining = deadline - Date.now();
260
+ if (remaining <= 0) return { status: "unavailable", code: "unverifiable-source" };
261
+ let fetched: RepoFetchResult;
262
+ try {
263
+ fetched = await this.repositories.fetch(npmRepositoryReference(repository, ref), {
264
+ exactRef: true,
265
+ maxCloneBytes: bounds.maxCloneBytes,
266
+ maxCacheBytes: bounds.maxCacheBytes,
267
+ timeoutMs: remaining,
268
+ });
269
+ } catch (error) {
270
+ if (error instanceof RepoFetchLimitExceeded) {
271
+ return {
272
+ status: "oversized",
273
+ code: error.resource === "clone-bytes" ? "clone-limit-exceeded" : "cache-limit-exceeded",
274
+ resource: error.resource,
275
+ limit: error.limit,
276
+ observed: error.observed,
277
+ };
278
+ }
279
+ if (error instanceof RepoFetchFailed) continue;
280
+ continue;
281
+ }
282
+ if (fetched.refFallbackOccurred || fetched.resolvedRef !== ref) {
283
+ firstMismatch ??= {
284
+ status: "mismatched",
285
+ code: "repository-ref-mismatch",
286
+ expected: ref,
287
+ actual: fetched.resolvedRef,
288
+ };
289
+ continue;
290
+ }
291
+ if (metadata.gitHead !== null && fetched.commit.toLowerCase() !== metadata.gitHead.toLowerCase()) {
292
+ return {
293
+ status: "mismatched",
294
+ code: "repository-commit-mismatch",
295
+ expected: metadata.gitHead,
296
+ actual: fetched.commit,
297
+ };
298
+ }
299
+ let identity: SourceIdentity | PackageSourceOutcome;
300
+ try {
301
+ identity = sourceIdentity(fetched.path, repository.directory, bounds);
302
+ } catch {
303
+ continue;
304
+ }
305
+ if ("status" in identity) {
306
+ if (identity.status === "oversized") return identity;
307
+ continue;
308
+ }
309
+ if (identity.name !== request.coordinate.name || identity.version !== version) {
310
+ firstMismatch ??= {
311
+ status: "mismatched",
312
+ code: "coordinate-mismatch",
313
+ expected: bounded(`${request.coordinate.name}@${version}`),
314
+ actual: bounded(`${identity.name ?? "missing"}@${identity.version ?? "missing"}`),
315
+ };
316
+ continue;
317
+ }
318
+ return verifiedOutcome(request, version, repository, ref, fetched);
319
+ }
320
+ return firstMismatch ?? { status: "unavailable", code: "unverifiable-source" };
321
+ }
322
+ }
@@ -0,0 +1,304 @@
1
+ import fetchBuilder, { type RequestInitWithRetry } from "fetch-retry";
2
+ import type { NpmPackageVersionMetadata, NpmRegistryBounds, NpmRegistryVersionRequest, NpmRepositoryMetadata } from "../domain/npm-package-metadata.ts";
3
+ import type { NpmRegistryPort } from "../ports/npm-registry-port.ts";
4
+
5
+ export const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
6
+ const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
7
+ const MAX_REDIRECTS = 20;
8
+ const MAX_RETRIES = 10;
9
+ const MAX_TIMEOUT_MS = 5 * 60 * 1000;
10
+
11
+ const rawRetryingFetch = fetchBuilder(globalThis.fetch);
12
+ type RetryingFetchOptions = RequestInitWithRetry<typeof globalThis.fetch>;
13
+
14
+ async function retryingFetch(input: URL, options: RetryingFetchOptions): Promise<Response> {
15
+ const result: unknown = await rawRetryingFetch(input, options);
16
+ if (!(result instanceof Response)) throw new TypeError("fetch-retry returned an invalid response");
17
+ return result;
18
+ }
19
+
20
+ export class InvalidNpmRegistryRequest extends Error {
21
+ constructor(field: string) {
22
+ super(`invalid npm registry request: ${field}`);
23
+ this.name = "InvalidNpmRegistryRequest";
24
+ }
25
+ }
26
+
27
+ export class NpmRegistryResponseLimitExceeded extends Error {
28
+ readonly limit: number;
29
+ readonly observed: number;
30
+
31
+ constructor(limit: number, observed: number) {
32
+ super(`npm registry response exceeded ${limit} bytes`);
33
+ this.name = "NpmRegistryResponseLimitExceeded";
34
+ this.limit = limit;
35
+ this.observed = observed;
36
+ }
37
+ }
38
+
39
+ export class NpmRegistryAuthenticationRequired extends Error {
40
+ readonly requiredCredentialNames = ["NPM_TOKEN"] as const;
41
+
42
+ constructor() {
43
+ super("npm registry authentication required; configure NPM_TOKEN");
44
+ this.name = "NpmRegistryAuthenticationRequired";
45
+ }
46
+ }
47
+
48
+ export class NpmPackageNotFound extends Error {
49
+ constructor() {
50
+ super("npm package was not found in the registry");
51
+ this.name = "NpmPackageNotFound";
52
+ }
53
+ }
54
+
55
+ export class NpmVersionNotFound extends Error {
56
+ constructor() {
57
+ super("npm package version was not found in the registry");
58
+ this.name = "NpmVersionNotFound";
59
+ }
60
+ }
61
+
62
+ export class NpmRegistryRequestFailed extends Error {
63
+ readonly code: "invalid-response" | "request-failed" | "timeout";
64
+
65
+ constructor(code: NpmRegistryRequestFailed["code"]) {
66
+ super(`npm registry request failed: ${code}`);
67
+ this.name = "NpmRegistryRequestFailed";
68
+ this.code = code;
69
+ }
70
+ }
71
+
72
+ export interface NpmRegistryClientOptions {
73
+ readonly token?: () => string | undefined;
74
+ }
75
+
76
+ type RegistryFetchOptions = RetryingFetchOptions & {
77
+ readonly headers: Readonly<Record<string, string>>;
78
+ };
79
+
80
+ function isRecord(value: unknown): value is Record<string, unknown> {
81
+ return typeof value === "object" && value !== null && !Array.isArray(value);
82
+ }
83
+
84
+ function textField(record: Record<string, unknown>, key: string): string | null {
85
+ const value = record[key];
86
+ return typeof value === "string" && value.length > 0 ? value : null;
87
+ }
88
+
89
+ function containsControlCharacter(value: string): boolean {
90
+ for (let index = 0; index < value.length; index++) {
91
+ const code = value.charCodeAt(index);
92
+ if (code <= 31 || code === 127) return true;
93
+ }
94
+ return false;
95
+ }
96
+
97
+ function validateText(value: string, field: string, maxLength: number): void {
98
+ if (value.length === 0 || value.length > maxLength || containsControlCharacter(value)) throw new InvalidNpmRegistryRequest(field);
99
+ }
100
+
101
+ function validateBound(value: number, field: string, maximum: number, allowZero = false): void {
102
+ if (!Number.isSafeInteger(value) || value < (allowZero ? 0 : 1) || value > maximum) throw new InvalidNpmRegistryRequest(field);
103
+ }
104
+
105
+ function registryUrl(raw: string): URL {
106
+ validateText(raw, "registry", 2048);
107
+ let parsed: URL;
108
+ try {
109
+ parsed = new URL(raw);
110
+ } catch {
111
+ throw new InvalidNpmRegistryRequest("registry");
112
+ }
113
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) throw new InvalidNpmRegistryRequest("registry");
114
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new InvalidNpmRegistryRequest("registry");
115
+ if (parsed.protocol === "http:" && !["127.0.0.1", "::1", "localhost"].includes(parsed.hostname)) {
116
+ throw new InvalidNpmRegistryRequest("registry must use HTTPS unless it is loopback");
117
+ }
118
+ return parsed;
119
+ }
120
+
121
+ function encodedPackageName(name: string): string {
122
+ validateText(name, "name", 512);
123
+ if (!name.startsWith("@")) return encodeURIComponent(name);
124
+ const separator = name.indexOf("/");
125
+ if (separator < 2 || separator === name.length - 1) throw new InvalidNpmRegistryRequest("name");
126
+ return `${encodeURIComponent(name.slice(0, separator))}%2f${encodeURIComponent(name.slice(separator + 1))}`;
127
+ }
128
+
129
+ function requestUrl(registry: URL, name: string, version?: string): string {
130
+ const base = registry.toString().replace(/\/?$/, "/");
131
+ const suffix = version === undefined ? encodedPackageName(name) : `${encodedPackageName(name)}/${encodeURIComponent(version)}`;
132
+ return new URL(suffix, base).toString();
133
+ }
134
+
135
+ function isRedirect(status: number): boolean {
136
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
137
+ }
138
+
139
+ function safeRedirectUrl(raw: string, current: URL): URL {
140
+ let target: URL;
141
+ try {
142
+ target = new URL(raw, current);
143
+ } catch {
144
+ throw new NpmRegistryRequestFailed("invalid-response");
145
+ }
146
+ if (target.protocol !== "https:" && target.protocol !== "http:") throw new NpmRegistryRequestFailed("invalid-response");
147
+ if (target.protocol === "http:" && !["127.0.0.1", "::1", "localhost"].includes(target.hostname)) {
148
+ throw new NpmRegistryRequestFailed("request-failed");
149
+ }
150
+ return target;
151
+ }
152
+
153
+ function repositoryMetadata(value: unknown): NpmRepositoryMetadata | null {
154
+ if (typeof value === "string" && value.length > 0) return { type: null, url: value, directory: null };
155
+ if (!isRecord(value)) return null;
156
+ const url = textField(value, "url");
157
+ if (url === null) return null;
158
+ return { type: textField(value, "type"), url, directory: textField(value, "directory") };
159
+ }
160
+
161
+ function packageMetadata(value: unknown): NpmPackageVersionMetadata {
162
+ if (!isRecord(value)) throw new NpmRegistryRequestFailed("invalid-response");
163
+ const name = textField(value, "name");
164
+ const version = textField(value, "version");
165
+ if (name === null || version === null) throw new NpmRegistryRequestFailed("invalid-response");
166
+ const dist = isRecord(value.dist) ? value.dist : null;
167
+ return {
168
+ name,
169
+ version,
170
+ repository: repositoryMetadata(value.repository),
171
+ gitHead: textField(value, "gitHead"),
172
+ integrity: dist === null ? null : textField(dist, "integrity"),
173
+ };
174
+ }
175
+
176
+ async function discard(response: Response): Promise<void> {
177
+ await response.body?.cancel().catch(() => undefined);
178
+ }
179
+
180
+ async function boundedJson(response: Response, budget: { used: number }, limit: number): Promise<unknown> {
181
+ const declaredLength = Number(response.headers.get("content-length"));
182
+ if (Number.isFinite(declaredLength) && declaredLength > limit - budget.used) {
183
+ await discard(response);
184
+ throw new NpmRegistryResponseLimitExceeded(limit, budget.used + declaredLength);
185
+ }
186
+ const chunks: Buffer[] = [];
187
+ const body: ReadableStream<Uint8Array> | null = response.body;
188
+ if (body === null) throw new NpmRegistryRequestFailed("invalid-response");
189
+ const reader = body.getReader();
190
+ let finished = false;
191
+ while (!finished) {
192
+ const read: unknown = await reader.read();
193
+ if (!isRecord(read) || typeof read.done !== "boolean") throw new NpmRegistryRequestFailed("invalid-response");
194
+ if (read.done) {
195
+ finished = true;
196
+ continue;
197
+ }
198
+ if (!(read.value instanceof Uint8Array)) throw new NpmRegistryRequestFailed("invalid-response");
199
+ budget.used += read.value.byteLength;
200
+ if (budget.used > limit) {
201
+ await reader.cancel();
202
+ throw new NpmRegistryResponseLimitExceeded(limit, budget.used);
203
+ }
204
+ chunks.push(Buffer.from(read.value));
205
+ }
206
+ try {
207
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
208
+ } catch {
209
+ throw new NpmRegistryRequestFailed("invalid-response");
210
+ }
211
+ }
212
+
213
+ export class NpmRegistryClient implements NpmRegistryPort {
214
+ private readonly token: () => string | undefined;
215
+
216
+ constructor(options: NpmRegistryClientOptions = {}) {
217
+ this.token = options.token ?? (() => process.env.NPM_TOKEN);
218
+ }
219
+
220
+ private async fetchFollowingRedirects(url: string, options: RegistryFetchOptions, bounds: NpmRegistryBounds, deadline: number): Promise<Response> {
221
+ let current = new URL(url);
222
+ let headers = { ...options.headers };
223
+ for (let redirects = 0; ; redirects++) {
224
+ if (Date.now() >= deadline) throw new NpmRegistryRequestFailed("timeout");
225
+ const response = await retryingFetch(current, { ...options, headers, redirect: "manual" });
226
+ if (!isRedirect(response.status)) return response;
227
+ await discard(response);
228
+ if (redirects >= bounds.maxRedirects) throw new NpmRegistryRequestFailed("request-failed");
229
+ const location = response.headers.get("location");
230
+ if (location === null) throw new NpmRegistryRequestFailed("invalid-response");
231
+ const target = safeRedirectUrl(location, current);
232
+ if (target.origin !== current.origin) {
233
+ headers = { ...headers };
234
+ delete headers.authorization;
235
+ }
236
+ current = target;
237
+ }
238
+ }
239
+
240
+ async fetchVersion(request: NpmRegistryVersionRequest, bounds: NpmRegistryBounds): Promise<NpmPackageVersionMetadata> {
241
+ const registry = registryUrl(request.registry || DEFAULT_NPM_REGISTRY);
242
+ validateText(request.version, "version", 256);
243
+ validateBound(bounds.maxResponseBytes, "maxResponseBytes", MAX_RESPONSE_BYTES);
244
+ validateBound(bounds.maxRedirects, "maxRedirects", MAX_REDIRECTS, true);
245
+ validateBound(bounds.maxRetries, "maxRetries", MAX_RETRIES, true);
246
+ validateBound(bounds.timeoutMs, "timeoutMs", MAX_TIMEOUT_MS);
247
+ const token = this.token();
248
+ const headers: Record<string, string> = { accept: "application/json" };
249
+ if (token) headers.authorization = `Bearer ${token}`;
250
+ const controller = new AbortController();
251
+ const timer = setTimeout(() => controller.abort(), bounds.timeoutMs);
252
+ const options: RegistryFetchOptions = {
253
+ headers,
254
+ redirect: "manual",
255
+ retries: bounds.maxRetries,
256
+ retryDelay: (attempt) => Math.min(10 * 2 ** attempt, 100),
257
+ retryOn: async (attempt, error, response) => {
258
+ if (controller.signal.aborted || attempt >= bounds.maxRetries) return false;
259
+ const retryable = error !== null || (response !== null && (response.status === 408 || response.status === 429 || response.status >= 500));
260
+ if (retryable && response !== null) await discard(response);
261
+ return retryable;
262
+ },
263
+ signal: controller.signal,
264
+ };
265
+ const deadline = Date.now() + bounds.timeoutMs;
266
+ const budget = { used: 0 };
267
+ try {
268
+ const response = await this.fetchFollowingRedirects(requestUrl(registry, request.name, request.version), options, bounds, deadline);
269
+ if (response.status === 401 || response.status === 403) {
270
+ await discard(response);
271
+ throw new NpmRegistryAuthenticationRequired();
272
+ }
273
+ if (response.status === 404) {
274
+ await discard(response);
275
+ const packageResponse = await this.fetchFollowingRedirects(requestUrl(registry, request.name), options, bounds, deadline);
276
+ await discard(packageResponse);
277
+ if (packageResponse.status === 401 || packageResponse.status === 403) throw new NpmRegistryAuthenticationRequired();
278
+ if (packageResponse.status === 404) throw new NpmPackageNotFound();
279
+ if (packageResponse.status >= 200 && packageResponse.status < 300) throw new NpmVersionNotFound();
280
+ throw new NpmRegistryRequestFailed("request-failed");
281
+ }
282
+ if (response.status < 200 || response.status >= 300) {
283
+ await discard(response);
284
+ throw new NpmRegistryRequestFailed("request-failed");
285
+ }
286
+ return packageMetadata(await boundedJson(response, budget, bounds.maxResponseBytes));
287
+ } catch (error) {
288
+ if (
289
+ error instanceof InvalidNpmRegistryRequest ||
290
+ error instanceof NpmRegistryResponseLimitExceeded ||
291
+ error instanceof NpmRegistryAuthenticationRequired ||
292
+ error instanceof NpmPackageNotFound ||
293
+ error instanceof NpmVersionNotFound ||
294
+ error instanceof NpmRegistryRequestFailed
295
+ ) {
296
+ throw error;
297
+ }
298
+ if (controller.signal.aborted || Date.now() >= deadline) throw new NpmRegistryRequestFailed("timeout");
299
+ throw new NpmRegistryRequestFailed("request-failed");
300
+ } finally {
301
+ clearTimeout(timer);
302
+ }
303
+ }
304
+ }