feat(npm-client): create NPM registry API client package
- Implement changes feed client for real-time package discovery - Implement keyword search with auto-pagination support - Implement package metadata fetcher with tpmjs field extraction - Implement download statistics client for quality scoring - Implement rate limiter with configurable concurrency and delays - Implement retry logic with exponential backoff for 429 errors All API responses validated with Zod schemas for type safety. Rate limiter defaults: max 10 concurrent, 100ms minimum delay. Handles scoped packages (@scope/name) correctly. Graceful 404 handling returns null instead of throwing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1a86036ace
commit
1b3b47f8b2
8 changed files with 647 additions and 0 deletions
19
packages/npm-client/package.json
Normal file
19
packages/npm-client/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "@tpmjs/npm-client",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tpmjs/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
80
packages/npm-client/src/changes.ts
Normal file
80
packages/npm-client/src/changes.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* NPM Changes Feed Client
|
||||
* Polls the NPM registry changes feed for new package publications
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const NPM_CHANGES_URL = 'https://replicate.npmjs.com/registry/_changes';
|
||||
|
||||
/**
|
||||
* Schema for a single change in the feed
|
||||
*/
|
||||
const ChangeSchema = z.object({
|
||||
id: z.string(),
|
||||
seq: z.union([z.string(), z.number()]),
|
||||
changes: z.array(z.object({ rev: z.string() })).optional(),
|
||||
deleted: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for the changes feed response
|
||||
*/
|
||||
const ChangesFeedSchema = z.object({
|
||||
results: z.array(ChangeSchema),
|
||||
last_seq: z.union([z.string(), z.number()]),
|
||||
pending: z.number().optional(),
|
||||
});
|
||||
|
||||
export type Change = z.infer<typeof ChangeSchema>;
|
||||
export type ChangesFeedResponse = z.infer<typeof ChangesFeedSchema>;
|
||||
|
||||
export interface FetchChangesOptions {
|
||||
/** The sequence number to start from */
|
||||
since?: string | number;
|
||||
/** Maximum number of changes to fetch */
|
||||
limit?: number;
|
||||
/** Include document bodies (slower but more data) */
|
||||
includeDocs?: boolean;
|
||||
}
|
||||
|
||||
export interface FetchChangesResult {
|
||||
results: Change[];
|
||||
lastSeq: string;
|
||||
pending?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches changes from the NPM registry
|
||||
* @param options - Options for fetching changes
|
||||
* @returns The changes and the last sequence number
|
||||
*/
|
||||
export async function fetchChanges(options: FetchChangesOptions = {}): Promise<FetchChangesResult> {
|
||||
const { since = '0', limit = 100, includeDocs = false } = options;
|
||||
|
||||
const url = new URL(NPM_CHANGES_URL);
|
||||
url.searchParams.set('since', String(since));
|
||||
url.searchParams.set('limit', String(limit));
|
||||
if (includeDocs) {
|
||||
url.searchParams.set('include_docs', 'true');
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`NPM changes feed error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const parsed = ChangesFeedSchema.parse(data);
|
||||
|
||||
return {
|
||||
results: parsed.results.filter((change) => !change.deleted),
|
||||
lastSeq: String(parsed.last_seq),
|
||||
pending: parsed.pending,
|
||||
};
|
||||
}
|
||||
38
packages/npm-client/src/index.ts
Normal file
38
packages/npm-client/src/index.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* @tpmjs/npm-client
|
||||
* NPM Registry API client for TPMJS
|
||||
*/
|
||||
|
||||
// Changes feed
|
||||
export {
|
||||
fetchChanges,
|
||||
type Change,
|
||||
type ChangesFeedResponse,
|
||||
type FetchChangesOptions,
|
||||
type FetchChangesResult,
|
||||
} from './changes';
|
||||
|
||||
// Keyword search
|
||||
export {
|
||||
searchByKeyword,
|
||||
searchAllByKeyword,
|
||||
type SearchPackage,
|
||||
type SearchResult,
|
||||
type SearchResponse,
|
||||
type SearchByKeywordOptions,
|
||||
} from './search';
|
||||
|
||||
// Package metadata
|
||||
export {
|
||||
fetchPackageMetadata,
|
||||
fetchLatestPackageVersion,
|
||||
fetchPackageTpmjsField,
|
||||
type PackageVersion,
|
||||
type PackageMetadata,
|
||||
} from './package';
|
||||
|
||||
// Download statistics
|
||||
export { fetchDownloadStats, fetchBulkDownloadStats, type DownloadsResponse } from './stats';
|
||||
|
||||
// Rate limiting
|
||||
export { delay, RateLimiter, npmRateLimiter, retryWithBackoff } from './rate-limiter';
|
||||
163
packages/npm-client/src/package.ts
Normal file
163
packages/npm-client/src/package.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* NPM Package Metadata Client
|
||||
* Fetches detailed package information from NPM registry
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const NPM_REGISTRY_URL = 'https://registry.npmjs.org';
|
||||
|
||||
/**
|
||||
* Schema for package version metadata
|
||||
*/
|
||||
const PackageVersionSchema = z.object({
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
description: z.string().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
homepage: z.string().optional(),
|
||||
repository: z
|
||||
.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
type: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
license: z.string().optional(),
|
||||
author: z
|
||||
.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
name: z.string(),
|
||||
email: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
maintainers: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
email: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
// The tpmjs field - this is what we're looking for!
|
||||
tpmjs: z.unknown().optional(),
|
||||
// Dist info
|
||||
dist: z
|
||||
.object({
|
||||
shasum: z.string(),
|
||||
tarball: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for full package metadata
|
||||
*/
|
||||
const PackageMetadataSchema = z.object({
|
||||
name: z.string(),
|
||||
'dist-tags': z.record(z.string()),
|
||||
versions: z.record(PackageVersionSchema),
|
||||
time: z.record(z.string()),
|
||||
maintainers: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
description: z.string().optional(),
|
||||
homepage: z.string().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
repository: z
|
||||
.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
type: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
license: z.string().optional(),
|
||||
readme: z.string().optional(),
|
||||
});
|
||||
|
||||
export type PackageVersion = z.infer<typeof PackageVersionSchema>;
|
||||
export type PackageMetadata = z.infer<typeof PackageMetadataSchema>;
|
||||
|
||||
/**
|
||||
* Fetches package metadata from NPM registry
|
||||
* @param packageName - The name of the package
|
||||
* @returns The package metadata, or null if not found
|
||||
*/
|
||||
export async function fetchPackageMetadata(packageName: string): Promise<PackageMetadata | null> {
|
||||
// Encode the package name to handle scoped packages (@scope/name)
|
||||
const encodedName = packageName.replace('/', '%2F');
|
||||
const url = `${NPM_REGISTRY_URL}/${encodedName}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`NPM registry error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return PackageMetadataSchema.parse(data);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('404')) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest version of a package
|
||||
* @param packageName - The name of the package
|
||||
* @returns The latest package version metadata, or null if not found
|
||||
*/
|
||||
export async function fetchLatestPackageVersion(
|
||||
packageName: string
|
||||
): Promise<PackageVersion | null> {
|
||||
const metadata = await fetchPackageMetadata(packageName);
|
||||
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const latestTag = metadata['dist-tags'].latest;
|
||||
if (!latestTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return metadata.versions[latestTag] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a package has a tpmjs field in its latest version
|
||||
* @param packageName - The name of the package
|
||||
* @returns The tpmjs field if present, null otherwise
|
||||
*/
|
||||
export async function fetchPackageTpmjsField(packageName: string): Promise<unknown | null> {
|
||||
const latestVersion = await fetchLatestPackageVersion(packageName);
|
||||
|
||||
if (!latestVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return latestVersion.tpmjs || null;
|
||||
}
|
||||
104
packages/npm-client/src/rate-limiter.ts
Normal file
104
packages/npm-client/src/rate-limiter.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Simple rate limiter utility
|
||||
* Helps avoid overwhelming NPM API with too many requests
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delays execution for a specified number of milliseconds
|
||||
*/
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiter class that enforces a maximum number of requests per time period
|
||||
*/
|
||||
export class RateLimiter {
|
||||
private queue: Array<() => void> = [];
|
||||
private running = 0;
|
||||
|
||||
constructor(
|
||||
private maxConcurrent = 10,
|
||||
private minDelayMs = 100
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Executes a function with rate limiting
|
||||
*/
|
||||
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// Wait if we're at max concurrent requests
|
||||
while (this.running >= this.maxConcurrent) {
|
||||
await new Promise<void>((resolve) => this.queue.push(resolve));
|
||||
}
|
||||
|
||||
this.running++;
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
await delay(this.minDelayMs); // Enforce minimum delay between requests
|
||||
return result;
|
||||
} finally {
|
||||
this.running--;
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes multiple functions with rate limiting
|
||||
*/
|
||||
async executeAll<T>(fns: Array<() => Promise<T>>): Promise<T[]> {
|
||||
return Promise.all(fns.map((fn) => this.execute(fn)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default rate limiter instance for NPM API calls
|
||||
* - Max 10 concurrent requests
|
||||
* - Min 100ms delay between requests
|
||||
*/
|
||||
export const npmRateLimiter = new RateLimiter(10, 100);
|
||||
|
||||
/**
|
||||
* Retry a function with exponential backoff
|
||||
*/
|
||||
export async function retryWithBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries = 3,
|
||||
initialDelayMs = 1000
|
||||
): Promise<T> {
|
||||
let lastError: Error | unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// Don't retry on last attempt
|
||||
if (attempt === maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if it's a 429 (rate limit) error
|
||||
const is429 =
|
||||
error instanceof Error && (error.message.includes('429') || error.message.includes('rate'));
|
||||
|
||||
if (is429) {
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, etc.
|
||||
const delayMs = initialDelayMs * 2 ** attempt;
|
||||
console.warn(
|
||||
`Rate limited, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`
|
||||
);
|
||||
await delay(delayMs);
|
||||
} else {
|
||||
// For other errors, just use a short delay
|
||||
await delay(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
147
packages/npm-client/src/search.ts
Normal file
147
packages/npm-client/src/search.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* NPM Keyword Search Client
|
||||
* Searches NPM registry by keyword to find packages
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const NPM_SEARCH_URL = 'https://registry.npmjs.org/-/v1/search';
|
||||
|
||||
/**
|
||||
* Schema for search result package
|
||||
*/
|
||||
const SearchPackageSchema = z.object({
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
description: z.string().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
date: z.string().optional(),
|
||||
links: z
|
||||
.object({
|
||||
npm: z.string().optional(),
|
||||
homepage: z.string().optional(),
|
||||
repository: z.string().optional(),
|
||||
bugs: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
author: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
email: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
publisher: z
|
||||
.object({
|
||||
username: z.string(),
|
||||
email: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
maintainers: z
|
||||
.array(
|
||||
z.object({
|
||||
username: z.string(),
|
||||
email: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for search result object
|
||||
*/
|
||||
const SearchResultSchema = z.object({
|
||||
package: SearchPackageSchema,
|
||||
score: z
|
||||
.object({
|
||||
final: z.number(),
|
||||
detail: z
|
||||
.object({
|
||||
quality: z.number(),
|
||||
popularity: z.number(),
|
||||
maintenance: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
searchScore: z.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for search response
|
||||
*/
|
||||
const SearchResponseSchema = z.object({
|
||||
objects: z.array(SearchResultSchema),
|
||||
total: z.number(),
|
||||
time: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SearchPackage = z.infer<typeof SearchPackageSchema>;
|
||||
export type SearchResult = z.infer<typeof SearchResultSchema>;
|
||||
export type SearchResponse = z.infer<typeof SearchResponseSchema>;
|
||||
|
||||
export interface SearchByKeywordOptions {
|
||||
/** The keyword to search for */
|
||||
keyword: string;
|
||||
/** Number of results to return (default: 250, max: 250) */
|
||||
size?: number;
|
||||
/** Offset for pagination (default: 0) */
|
||||
from?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches NPM registry by keyword
|
||||
* @param options - Search options
|
||||
* @returns Array of matching packages
|
||||
*/
|
||||
export async function searchByKeyword(options: SearchByKeywordOptions): Promise<SearchResult[]> {
|
||||
const { keyword, size = 250, from = 0 } = options;
|
||||
|
||||
const url = new URL(NPM_SEARCH_URL);
|
||||
url.searchParams.set('text', `keywords:${keyword}`);
|
||||
url.searchParams.set('size', String(Math.min(size, 250))); // NPM max is 250
|
||||
url.searchParams.set('from', String(from));
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`NPM search error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const parsed = SearchResponseSchema.parse(data);
|
||||
|
||||
return parsed.objects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for all packages with a specific keyword
|
||||
* Handles pagination automatically to get all results
|
||||
*/
|
||||
export async function searchAllByKeyword(keyword: string): Promise<SearchResult[]> {
|
||||
const allResults: SearchResult[] = [];
|
||||
let from = 0;
|
||||
const size = 250;
|
||||
|
||||
while (true) {
|
||||
const results = await searchByKeyword({ keyword, size, from });
|
||||
|
||||
if (results.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
allResults.push(...results);
|
||||
|
||||
// If we got less than the page size, we're done
|
||||
if (results.length < size) {
|
||||
break;
|
||||
}
|
||||
|
||||
from += size;
|
||||
}
|
||||
|
||||
return allResults;
|
||||
}
|
||||
85
packages/npm-client/src/stats.ts
Normal file
85
packages/npm-client/src/stats.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* NPM Download Statistics Client
|
||||
* Fetches download counts from NPM statistics API
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const NPM_DOWNLOADS_URL = 'https://api.npmjs.org/downloads';
|
||||
|
||||
/**
|
||||
* Schema for downloads response
|
||||
*/
|
||||
const DownloadsResponseSchema = z.object({
|
||||
downloads: z.number(),
|
||||
start: z.string(),
|
||||
end: z.string(),
|
||||
package: z.string(),
|
||||
});
|
||||
|
||||
export type DownloadsResponse = z.infer<typeof DownloadsResponseSchema>;
|
||||
|
||||
/**
|
||||
* Fetches download statistics for a package
|
||||
* @param packageName - The name of the package
|
||||
* @param period - Time period (default: 'last-month')
|
||||
* @returns The download count, or 0 if not found
|
||||
*/
|
||||
export async function fetchDownloadStats(
|
||||
packageName: string,
|
||||
period: 'last-day' | 'last-week' | 'last-month' | 'last-year' = 'last-month'
|
||||
): Promise<number> {
|
||||
const url = `${NPM_DOWNLOADS_URL}/point/${period}/${packageName}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
// Package not found or no download data
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`NPM downloads API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const parsed = DownloadsResponseSchema.parse(data);
|
||||
|
||||
return parsed.downloads;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('404')) {
|
||||
return 0;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches download statistics for multiple packages in bulk
|
||||
* Note: NPM API doesn't have a native bulk endpoint, so this makes individual requests
|
||||
* Use with rate limiting to avoid overwhelming the API
|
||||
*/
|
||||
export async function fetchBulkDownloadStats(
|
||||
packageNames: string[],
|
||||
period: 'last-day' | 'last-week' | 'last-month' | 'last-year' = 'last-month'
|
||||
): Promise<Map<string, number>> {
|
||||
const results = new Map<string, number>();
|
||||
|
||||
// Process in batches to avoid overwhelming the API
|
||||
for (const packageName of packageNames) {
|
||||
try {
|
||||
const downloads = await fetchDownloadStats(packageName, period);
|
||||
results.set(packageName, downloads);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch stats for ${packageName}:`, error);
|
||||
results.set(packageName, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
11
packages/npm-client/tsconfig.json
Normal file
11
packages/npm-client/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "@tpmjs/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"incremental": false,
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue