@push.rocks/smartregistry 2.2.2 → 2.3.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 (91) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/cargo/classes.cargoregistry.d.ts +7 -1
  3. package/dist_ts/cargo/classes.cargoregistry.js +42 -4
  4. package/dist_ts/cargo/classes.cargoupstream.d.ts +44 -0
  5. package/dist_ts/cargo/classes.cargoupstream.js +129 -0
  6. package/dist_ts/cargo/index.d.ts +1 -0
  7. package/dist_ts/cargo/index.js +2 -1
  8. package/dist_ts/classes.smartregistry.js +8 -8
  9. package/dist_ts/composer/classes.composerregistry.d.ts +7 -1
  10. package/dist_ts/composer/classes.composerregistry.js +34 -3
  11. package/dist_ts/composer/classes.composerupstream.d.ts +40 -0
  12. package/dist_ts/composer/classes.composerupstream.js +159 -0
  13. package/dist_ts/composer/index.d.ts +1 -0
  14. package/dist_ts/composer/index.js +2 -1
  15. package/dist_ts/core/interfaces.core.d.ts +3 -0
  16. package/dist_ts/index.d.ts +1 -0
  17. package/dist_ts/index.js +3 -1
  18. package/dist_ts/maven/classes.mavenregistry.d.ts +12 -1
  19. package/dist_ts/maven/classes.mavenregistry.js +69 -4
  20. package/dist_ts/maven/classes.mavenupstream.d.ts +45 -0
  21. package/dist_ts/maven/classes.mavenupstream.js +153 -0
  22. package/dist_ts/maven/index.d.ts +1 -0
  23. package/dist_ts/maven/index.js +2 -1
  24. package/dist_ts/npm/classes.npmregistry.d.ts +3 -1
  25. package/dist_ts/npm/classes.npmregistry.js +55 -6
  26. package/dist_ts/npm/classes.npmupstream.d.ts +51 -0
  27. package/dist_ts/npm/classes.npmupstream.js +206 -0
  28. package/dist_ts/npm/index.d.ts +1 -0
  29. package/dist_ts/npm/index.js +2 -1
  30. package/dist_ts/oci/classes.ociregistry.d.ts +4 -1
  31. package/dist_ts/oci/classes.ociregistry.js +78 -17
  32. package/dist_ts/oci/classes.ociupstream.d.ts +62 -0
  33. package/dist_ts/oci/classes.ociupstream.js +206 -0
  34. package/dist_ts/oci/index.d.ts +1 -0
  35. package/dist_ts/oci/index.js +2 -1
  36. package/dist_ts/plugins.d.ts +4 -1
  37. package/dist_ts/plugins.js +6 -2
  38. package/dist_ts/pypi/classes.pypiregistry.d.ts +7 -1
  39. package/dist_ts/pypi/classes.pypiregistry.js +60 -4
  40. package/dist_ts/pypi/classes.pypiupstream.d.ts +48 -0
  41. package/dist_ts/pypi/classes.pypiupstream.js +165 -0
  42. package/dist_ts/pypi/index.d.ts +1 -0
  43. package/dist_ts/pypi/index.js +2 -1
  44. package/dist_ts/rubygems/classes.rubygemsregistry.d.ts +7 -1
  45. package/dist_ts/rubygems/classes.rubygemsregistry.js +35 -4
  46. package/dist_ts/rubygems/classes.rubygemsupstream.d.ts +47 -0
  47. package/dist_ts/rubygems/classes.rubygemsupstream.js +184 -0
  48. package/dist_ts/rubygems/index.d.ts +1 -0
  49. package/dist_ts/rubygems/index.js +2 -1
  50. package/dist_ts/upstream/classes.baseupstream.d.ts +112 -0
  51. package/dist_ts/upstream/classes.baseupstream.js +409 -0
  52. package/dist_ts/upstream/classes.circuitbreaker.d.ts +111 -0
  53. package/dist_ts/upstream/classes.circuitbreaker.js +192 -0
  54. package/dist_ts/upstream/classes.upstreamcache.d.ts +123 -0
  55. package/dist_ts/upstream/classes.upstreamcache.js +328 -0
  56. package/dist_ts/upstream/index.d.ts +6 -0
  57. package/dist_ts/upstream/index.js +7 -0
  58. package/dist_ts/upstream/interfaces.upstream.d.ts +169 -0
  59. package/dist_ts/upstream/interfaces.upstream.js +23 -0
  60. package/package.json +4 -2
  61. package/ts/00_commitinfo_data.ts +1 -1
  62. package/ts/cargo/classes.cargoregistry.ts +48 -3
  63. package/ts/cargo/classes.cargoupstream.ts +159 -0
  64. package/ts/cargo/index.ts +1 -0
  65. package/ts/classes.smartregistry.ts +49 -7
  66. package/ts/composer/classes.composerregistry.ts +39 -2
  67. package/ts/composer/classes.composerupstream.ts +200 -0
  68. package/ts/composer/index.ts +1 -0
  69. package/ts/core/interfaces.core.ts +3 -0
  70. package/ts/index.ts +3 -0
  71. package/ts/maven/classes.mavenregistry.ts +84 -3
  72. package/ts/maven/classes.mavenupstream.ts +220 -0
  73. package/ts/maven/index.ts +1 -0
  74. package/ts/npm/classes.npmregistry.ts +61 -5
  75. package/ts/npm/classes.npmupstream.ts +260 -0
  76. package/ts/npm/index.ts +1 -0
  77. package/ts/oci/classes.ociregistry.ts +89 -17
  78. package/ts/oci/classes.ociupstream.ts +263 -0
  79. package/ts/oci/index.ts +1 -0
  80. package/ts/plugins.ts +7 -1
  81. package/ts/pypi/classes.pypiregistry.ts +68 -3
  82. package/ts/pypi/classes.pypiupstream.ts +211 -0
  83. package/ts/pypi/index.ts +1 -0
  84. package/ts/rubygems/classes.rubygemsregistry.ts +40 -3
  85. package/ts/rubygems/classes.rubygemsupstream.ts +230 -0
  86. package/ts/rubygems/index.ts +1 -0
  87. package/ts/upstream/classes.baseupstream.ts +521 -0
  88. package/ts/upstream/classes.circuitbreaker.ts +238 -0
  89. package/ts/upstream/classes.upstreamcache.ts +423 -0
  90. package/ts/upstream/index.ts +11 -0
  91. package/ts/upstream/interfaces.upstream.ts +195 -0
@@ -0,0 +1,263 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { BaseUpstream } from '../upstream/classes.baseupstream.js';
3
+ import type {
4
+ IProtocolUpstreamConfig,
5
+ IUpstreamFetchContext,
6
+ IUpstreamResult,
7
+ IUpstreamRegistryConfig,
8
+ } from '../upstream/interfaces.upstream.js';
9
+ import type { IOciManifest, IOciImageIndex, ITagList } from './interfaces.oci.js';
10
+
11
+ /**
12
+ * OCI-specific upstream implementation.
13
+ *
14
+ * Handles:
15
+ * - Manifest fetching (image manifests and index manifests)
16
+ * - Blob proxying (layers, configs)
17
+ * - Tag list fetching
18
+ * - Content-addressable caching (blobs are immutable)
19
+ * - Docker Hub authentication flow
20
+ */
21
+ export class OciUpstream extends BaseUpstream {
22
+ protected readonly protocolName = 'oci';
23
+
24
+ /** Local registry base path for URL building */
25
+ private readonly localBasePath: string;
26
+
27
+ constructor(
28
+ config: IProtocolUpstreamConfig,
29
+ localBasePath: string = '/oci',
30
+ logger?: plugins.smartlog.Smartlog,
31
+ ) {
32
+ super(config, logger);
33
+ this.localBasePath = localBasePath;
34
+ }
35
+
36
+ /**
37
+ * Fetch a manifest from upstream registries.
38
+ */
39
+ public async fetchManifest(
40
+ repository: string,
41
+ reference: string,
42
+ ): Promise<{ manifest: IOciManifest | IOciImageIndex; contentType: string; digest: string } | null> {
43
+ const context: IUpstreamFetchContext = {
44
+ protocol: 'oci',
45
+ resource: repository,
46
+ resourceType: 'manifest',
47
+ path: `/v2/${repository}/manifests/${reference}`,
48
+ method: 'GET',
49
+ headers: {
50
+ 'accept': [
51
+ 'application/vnd.oci.image.manifest.v1+json',
52
+ 'application/vnd.oci.image.index.v1+json',
53
+ 'application/vnd.docker.distribution.manifest.v2+json',
54
+ 'application/vnd.docker.distribution.manifest.list.v2+json',
55
+ 'application/vnd.docker.distribution.manifest.v1+json',
56
+ ].join(', '),
57
+ },
58
+ query: {},
59
+ };
60
+
61
+ const result = await this.fetch(context);
62
+
63
+ if (!result || !result.success) {
64
+ return null;
65
+ }
66
+
67
+ let manifest: IOciManifest | IOciImageIndex;
68
+ if (Buffer.isBuffer(result.body)) {
69
+ manifest = JSON.parse(result.body.toString('utf8'));
70
+ } else {
71
+ manifest = result.body;
72
+ }
73
+
74
+ const contentType = result.headers['content-type'] || 'application/vnd.oci.image.manifest.v1+json';
75
+ const digest = result.headers['docker-content-digest'] || '';
76
+
77
+ return { manifest, contentType, digest };
78
+ }
79
+
80
+ /**
81
+ * Check if a manifest exists in upstream (HEAD request).
82
+ */
83
+ public async headManifest(
84
+ repository: string,
85
+ reference: string,
86
+ ): Promise<{ exists: boolean; contentType?: string; digest?: string; size?: number } | null> {
87
+ const context: IUpstreamFetchContext = {
88
+ protocol: 'oci',
89
+ resource: repository,
90
+ resourceType: 'manifest',
91
+ path: `/v2/${repository}/manifests/${reference}`,
92
+ method: 'HEAD',
93
+ headers: {
94
+ 'accept': [
95
+ 'application/vnd.oci.image.manifest.v1+json',
96
+ 'application/vnd.oci.image.index.v1+json',
97
+ 'application/vnd.docker.distribution.manifest.v2+json',
98
+ 'application/vnd.docker.distribution.manifest.list.v2+json',
99
+ ].join(', '),
100
+ },
101
+ query: {},
102
+ };
103
+
104
+ const result = await this.fetch(context);
105
+
106
+ if (!result) {
107
+ return null;
108
+ }
109
+
110
+ if (!result.success) {
111
+ return { exists: false };
112
+ }
113
+
114
+ return {
115
+ exists: true,
116
+ contentType: result.headers['content-type'],
117
+ digest: result.headers['docker-content-digest'],
118
+ size: result.headers['content-length'] ? parseInt(result.headers['content-length'], 10) : undefined,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Fetch a blob from upstream registries.
124
+ */
125
+ public async fetchBlob(repository: string, digest: string): Promise<Buffer | null> {
126
+ const context: IUpstreamFetchContext = {
127
+ protocol: 'oci',
128
+ resource: repository,
129
+ resourceType: 'blob',
130
+ path: `/v2/${repository}/blobs/${digest}`,
131
+ method: 'GET',
132
+ headers: {
133
+ 'accept': 'application/octet-stream',
134
+ },
135
+ query: {},
136
+ };
137
+
138
+ const result = await this.fetch(context);
139
+
140
+ if (!result || !result.success) {
141
+ return null;
142
+ }
143
+
144
+ return Buffer.isBuffer(result.body) ? result.body : Buffer.from(result.body);
145
+ }
146
+
147
+ /**
148
+ * Check if a blob exists in upstream (HEAD request).
149
+ */
150
+ public async headBlob(
151
+ repository: string,
152
+ digest: string,
153
+ ): Promise<{ exists: boolean; size?: number } | null> {
154
+ const context: IUpstreamFetchContext = {
155
+ protocol: 'oci',
156
+ resource: repository,
157
+ resourceType: 'blob',
158
+ path: `/v2/${repository}/blobs/${digest}`,
159
+ method: 'HEAD',
160
+ headers: {},
161
+ query: {},
162
+ };
163
+
164
+ const result = await this.fetch(context);
165
+
166
+ if (!result) {
167
+ return null;
168
+ }
169
+
170
+ if (!result.success) {
171
+ return { exists: false };
172
+ }
173
+
174
+ return {
175
+ exists: true,
176
+ size: result.headers['content-length'] ? parseInt(result.headers['content-length'], 10) : undefined,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Fetch the tag list for a repository.
182
+ */
183
+ public async fetchTags(repository: string, n?: number, last?: string): Promise<ITagList | null> {
184
+ const query: Record<string, string> = {};
185
+ if (n) query.n = n.toString();
186
+ if (last) query.last = last;
187
+
188
+ const context: IUpstreamFetchContext = {
189
+ protocol: 'oci',
190
+ resource: repository,
191
+ resourceType: 'tags',
192
+ path: `/v2/${repository}/tags/list`,
193
+ method: 'GET',
194
+ headers: {
195
+ 'accept': 'application/json',
196
+ },
197
+ query,
198
+ };
199
+
200
+ const result = await this.fetch(context);
201
+
202
+ if (!result || !result.success) {
203
+ return null;
204
+ }
205
+
206
+ let tagList: ITagList;
207
+ if (Buffer.isBuffer(result.body)) {
208
+ tagList = JSON.parse(result.body.toString('utf8'));
209
+ } else {
210
+ tagList = result.body;
211
+ }
212
+
213
+ return tagList;
214
+ }
215
+
216
+ /**
217
+ * Override URL building for OCI-specific handling.
218
+ * OCI registries use /v2/ prefix and may require special handling for Docker Hub.
219
+ */
220
+ protected buildUpstreamUrl(
221
+ upstream: IUpstreamRegistryConfig,
222
+ context: IUpstreamFetchContext,
223
+ ): string {
224
+ let baseUrl = upstream.url;
225
+
226
+ // Remove trailing slash
227
+ if (baseUrl.endsWith('/')) {
228
+ baseUrl = baseUrl.slice(0, -1);
229
+ }
230
+
231
+ // Handle Docker Hub special case
232
+ // Docker Hub uses registry-1.docker.io but library images need special handling
233
+ if (baseUrl.includes('docker.io') || baseUrl.includes('registry-1.docker.io')) {
234
+ // For library images (e.g., "nginx" -> "library/nginx")
235
+ const pathParts = context.path.match(/^\/v2\/([^\/]+)\/(.+)$/);
236
+ if (pathParts) {
237
+ const [, repository, rest] = pathParts;
238
+ // If repository doesn't contain a slash, it's a library image
239
+ if (!repository.includes('/')) {
240
+ return `${baseUrl}/v2/library/${repository}/${rest}`;
241
+ }
242
+ }
243
+ }
244
+
245
+ return `${baseUrl}${context.path}`;
246
+ }
247
+
248
+ /**
249
+ * Override header building for OCI-specific authentication.
250
+ * OCI registries may require token-based auth obtained from a separate endpoint.
251
+ */
252
+ protected buildHeaders(
253
+ upstream: IUpstreamRegistryConfig,
254
+ context: IUpstreamFetchContext,
255
+ ): Record<string, string> {
256
+ const headers = super.buildHeaders(upstream, context);
257
+
258
+ // OCI registries typically use Docker-Distribution-API-Version header
259
+ headers['docker-distribution-api-version'] = 'registry/2.0';
260
+
261
+ return headers;
262
+ }
263
+ }
package/ts/oci/index.ts CHANGED
@@ -3,4 +3,5 @@
3
3
  */
4
4
 
5
5
  export { OciRegistry } from './classes.ociregistry.js';
6
+ export { OciUpstream } from './classes.ociupstream.js';
6
7
  export * from './interfaces.oci.js';
package/ts/plugins.ts CHANGED
@@ -8,10 +8,16 @@ import * as smartarchive from '@push.rocks/smartarchive';
8
8
  import * as smartbucket from '@push.rocks/smartbucket';
9
9
  import * as smartlog from '@push.rocks/smartlog';
10
10
  import * as smartpath from '@push.rocks/smartpath';
11
+ import * as smartrequest from '@push.rocks/smartrequest';
11
12
 
12
- export { smartarchive, smartbucket, smartlog, smartpath };
13
+ export { smartarchive, smartbucket, smartlog, smartpath, smartrequest };
13
14
 
14
15
  // @tsclass scope
15
16
  import * as tsclass from '@tsclass/tsclass';
16
17
 
17
18
  export { tsclass };
19
+
20
+ // third party
21
+ import { minimatch } from 'minimatch';
22
+
23
+ export { minimatch };
@@ -3,6 +3,7 @@ import { BaseRegistry } from '../core/classes.baseregistry.js';
3
3
  import { RegistryStorage } from '../core/classes.registrystorage.js';
4
4
  import { AuthManager } from '../core/classes.authmanager.js';
5
5
  import type { IRequestContext, IResponse, IAuthToken } from '../core/interfaces.core.js';
6
+ import type { IProtocolUpstreamConfig } from '../upstream/interfaces.upstream.js';
6
7
  import { isBinaryData, toBuffer } from '../core/helpers.buffer.js';
7
8
  import type {
8
9
  IPypiPackageMetadata,
@@ -11,6 +12,7 @@ import type {
11
12
  IPypiUploadResponse,
12
13
  } from './interfaces.pypi.js';
13
14
  import * as helpers from './helpers.pypi.js';
15
+ import { PypiUpstream } from './classes.pypiupstream.js';
14
16
 
15
17
  /**
16
18
  * PyPI registry implementation
@@ -22,12 +24,14 @@ export class PypiRegistry extends BaseRegistry {
22
24
  private basePath: string = '/pypi';
23
25
  private registryUrl: string;
24
26
  private logger: Smartlog;
27
+ private upstream: PypiUpstream | null = null;
25
28
 
26
29
  constructor(
27
30
  storage: RegistryStorage,
28
31
  authManager: AuthManager,
29
32
  basePath: string = '/pypi',
30
- registryUrl: string = 'http://localhost:5000'
33
+ registryUrl: string = 'http://localhost:5000',
34
+ upstreamConfig?: IProtocolUpstreamConfig
31
35
  ) {
32
36
  super();
33
37
  this.storage = storage;
@@ -47,6 +51,20 @@ export class PypiRegistry extends BaseRegistry {
47
51
  }
48
52
  });
49
53
  this.logger.enableConsole();
54
+
55
+ // Initialize upstream if configured
56
+ if (upstreamConfig?.enabled) {
57
+ this.upstream = new PypiUpstream(upstreamConfig, registryUrl, this.logger);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Clean up resources (timers, connections, etc.)
63
+ */
64
+ public destroy(): void {
65
+ if (this.upstream) {
66
+ this.upstream.stop();
67
+ }
50
68
  }
51
69
 
52
70
  public async init(): Promise<void> {
@@ -214,7 +232,45 @@ export class PypiRegistry extends BaseRegistry {
214
232
  const normalized = helpers.normalizePypiPackageName(packageName);
215
233
 
216
234
  // Get package metadata
217
- const metadata = await this.storage.getPypiPackageMetadata(normalized);
235
+ let metadata = await this.storage.getPypiPackageMetadata(normalized);
236
+
237
+ // Try upstream if not found locally
238
+ if (!metadata && this.upstream) {
239
+ const upstreamHtml = await this.upstream.fetchSimplePackage(normalized);
240
+ if (upstreamHtml) {
241
+ // Parse the HTML to extract file information and cache it
242
+ // For now, just return the upstream HTML directly (caching can be improved later)
243
+ const acceptHeader = context.headers['accept'] || context.headers['Accept'] || '';
244
+ const preferJson = acceptHeader.includes('application/vnd.pypi.simple') &&
245
+ acceptHeader.includes('json');
246
+
247
+ if (preferJson) {
248
+ // Try to get JSON format from upstream
249
+ const upstreamJson = await this.upstream.fetchPackageJson(normalized);
250
+ if (upstreamJson) {
251
+ return {
252
+ status: 200,
253
+ headers: {
254
+ 'Content-Type': 'application/vnd.pypi.simple.v1+json',
255
+ 'Cache-Control': 'public, max-age=300'
256
+ },
257
+ body: upstreamJson,
258
+ };
259
+ }
260
+ }
261
+
262
+ // Return HTML format
263
+ return {
264
+ status: 200,
265
+ headers: {
266
+ 'Content-Type': 'text/html; charset=utf-8',
267
+ 'Cache-Control': 'public, max-age=300'
268
+ },
269
+ body: upstreamHtml,
270
+ };
271
+ }
272
+ }
273
+
218
274
  if (!metadata) {
219
275
  return this.errorResponse(404, 'Package not found');
220
276
  }
@@ -449,7 +505,16 @@ export class PypiRegistry extends BaseRegistry {
449
505
  */
450
506
  private async handleDownload(packageName: string, filename: string): Promise<IResponse> {
451
507
  const normalized = helpers.normalizePypiPackageName(packageName);
452
- const fileData = await this.storage.getPypiPackageFile(normalized, filename);
508
+ let fileData = await this.storage.getPypiPackageFile(normalized, filename);
509
+
510
+ // Try upstream if not found locally
511
+ if (!fileData && this.upstream) {
512
+ fileData = await this.upstream.fetchPackageFile(normalized, filename);
513
+ if (fileData) {
514
+ // Cache locally
515
+ await this.storage.putPypiPackageFile(normalized, filename, fileData);
516
+ }
517
+ }
453
518
 
454
519
  if (!fileData) {
455
520
  return {
@@ -0,0 +1,211 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { BaseUpstream } from '../upstream/classes.baseupstream.js';
3
+ import type {
4
+ IProtocolUpstreamConfig,
5
+ IUpstreamFetchContext,
6
+ IUpstreamRegistryConfig,
7
+ } from '../upstream/interfaces.upstream.js';
8
+
9
+ /**
10
+ * PyPI-specific upstream implementation.
11
+ *
12
+ * Handles:
13
+ * - Simple API (HTML) - PEP 503
14
+ * - JSON API - PEP 691
15
+ * - Package file downloads (wheels, sdists)
16
+ * - Package name normalization
17
+ */
18
+ export class PypiUpstream extends BaseUpstream {
19
+ protected readonly protocolName = 'pypi';
20
+
21
+ /** Local registry URL for rewriting download URLs */
22
+ private readonly localRegistryUrl: string;
23
+
24
+ constructor(
25
+ config: IProtocolUpstreamConfig,
26
+ localRegistryUrl: string,
27
+ logger?: plugins.smartlog.Smartlog,
28
+ ) {
29
+ super(config, logger);
30
+ this.localRegistryUrl = localRegistryUrl;
31
+ }
32
+
33
+ /**
34
+ * Fetch Simple API index (list of all packages) in HTML format.
35
+ */
36
+ public async fetchSimpleIndex(): Promise<string | null> {
37
+ const context: IUpstreamFetchContext = {
38
+ protocol: 'pypi',
39
+ resource: '*',
40
+ resourceType: 'index',
41
+ path: '/simple/',
42
+ method: 'GET',
43
+ headers: {
44
+ 'accept': 'text/html',
45
+ },
46
+ query: {},
47
+ };
48
+
49
+ const result = await this.fetch(context);
50
+
51
+ if (!result || !result.success) {
52
+ return null;
53
+ }
54
+
55
+ if (Buffer.isBuffer(result.body)) {
56
+ return result.body.toString('utf8');
57
+ }
58
+
59
+ return typeof result.body === 'string' ? result.body : null;
60
+ }
61
+
62
+ /**
63
+ * Fetch Simple API package page (list of files) in HTML format.
64
+ */
65
+ public async fetchSimplePackage(packageName: string): Promise<string | null> {
66
+ const normalizedName = this.normalizePackageName(packageName);
67
+ const path = `/simple/${normalizedName}/`;
68
+
69
+ const context: IUpstreamFetchContext = {
70
+ protocol: 'pypi',
71
+ resource: packageName,
72
+ resourceType: 'simple',
73
+ path,
74
+ method: 'GET',
75
+ headers: {
76
+ 'accept': 'text/html',
77
+ },
78
+ query: {},
79
+ };
80
+
81
+ const result = await this.fetch(context);
82
+
83
+ if (!result || !result.success) {
84
+ return null;
85
+ }
86
+
87
+ if (Buffer.isBuffer(result.body)) {
88
+ return result.body.toString('utf8');
89
+ }
90
+
91
+ return typeof result.body === 'string' ? result.body : null;
92
+ }
93
+
94
+ /**
95
+ * Fetch package metadata using JSON API (PEP 691).
96
+ */
97
+ public async fetchPackageJson(packageName: string): Promise<any | null> {
98
+ const normalizedName = this.normalizePackageName(packageName);
99
+ const path = `/simple/${normalizedName}/`;
100
+
101
+ const context: IUpstreamFetchContext = {
102
+ protocol: 'pypi',
103
+ resource: packageName,
104
+ resourceType: 'metadata',
105
+ path,
106
+ method: 'GET',
107
+ headers: {
108
+ 'accept': 'application/vnd.pypi.simple.v1+json',
109
+ },
110
+ query: {},
111
+ };
112
+
113
+ const result = await this.fetch(context);
114
+
115
+ if (!result || !result.success) {
116
+ return null;
117
+ }
118
+
119
+ if (Buffer.isBuffer(result.body)) {
120
+ return JSON.parse(result.body.toString('utf8'));
121
+ }
122
+
123
+ return result.body;
124
+ }
125
+
126
+ /**
127
+ * Fetch full package info from PyPI JSON API (/pypi/{package}/json).
128
+ */
129
+ public async fetchPypiJson(packageName: string): Promise<any | null> {
130
+ const normalizedName = this.normalizePackageName(packageName);
131
+ const path = `/pypi/${normalizedName}/json`;
132
+
133
+ const context: IUpstreamFetchContext = {
134
+ protocol: 'pypi',
135
+ resource: packageName,
136
+ resourceType: 'pypi-json',
137
+ path,
138
+ method: 'GET',
139
+ headers: {
140
+ 'accept': 'application/json',
141
+ },
142
+ query: {},
143
+ };
144
+
145
+ const result = await this.fetch(context);
146
+
147
+ if (!result || !result.success) {
148
+ return null;
149
+ }
150
+
151
+ if (Buffer.isBuffer(result.body)) {
152
+ return JSON.parse(result.body.toString('utf8'));
153
+ }
154
+
155
+ return result.body;
156
+ }
157
+
158
+ /**
159
+ * Fetch a package file (wheel or sdist) from upstream.
160
+ */
161
+ public async fetchPackageFile(packageName: string, filename: string): Promise<Buffer | null> {
162
+ const normalizedName = this.normalizePackageName(packageName);
163
+ const path = `/packages/${normalizedName}/${filename}`;
164
+
165
+ const context: IUpstreamFetchContext = {
166
+ protocol: 'pypi',
167
+ resource: packageName,
168
+ resourceType: 'package',
169
+ path,
170
+ method: 'GET',
171
+ headers: {
172
+ 'accept': 'application/octet-stream',
173
+ },
174
+ query: {},
175
+ };
176
+
177
+ const result = await this.fetch(context);
178
+
179
+ if (!result || !result.success) {
180
+ return null;
181
+ }
182
+
183
+ return Buffer.isBuffer(result.body) ? result.body : Buffer.from(result.body);
184
+ }
185
+
186
+ /**
187
+ * Normalize a PyPI package name according to PEP 503.
188
+ * - Lowercase all characters
189
+ * - Replace runs of ., -, _ with single -
190
+ */
191
+ private normalizePackageName(name: string): string {
192
+ return name.toLowerCase().replace(/[-_.]+/g, '-');
193
+ }
194
+
195
+ /**
196
+ * Override URL building for PyPI-specific handling.
197
+ */
198
+ protected buildUpstreamUrl(
199
+ upstream: IUpstreamRegistryConfig,
200
+ context: IUpstreamFetchContext,
201
+ ): string {
202
+ let baseUrl = upstream.url;
203
+
204
+ // Remove trailing slash
205
+ if (baseUrl.endsWith('/')) {
206
+ baseUrl = baseUrl.slice(0, -1);
207
+ }
208
+
209
+ return `${baseUrl}${context.path}`;
210
+ }
211
+ }
package/ts/pypi/index.ts CHANGED
@@ -5,4 +5,5 @@
5
5
 
6
6
  export * from './interfaces.pypi.js';
7
7
  export * from './classes.pypiregistry.js';
8
+ export { PypiUpstream } from './classes.pypiupstream.js';
8
9
  export * as pypiHelpers from './helpers.pypi.js';