@package-broker/core 0.2.15
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.
- package/dist/cache/index.d.ts +2 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +2 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/cache/memory-driver.d.ts +15 -0
- package/dist/cache/memory-driver.d.ts.map +1 -0
- package/dist/cache/memory-driver.js +56 -0
- package/dist/cache/memory-driver.js.map +1 -0
- package/dist/db/d1-driver.d.ts +3 -0
- package/dist/db/d1-driver.d.ts.map +1 -0
- package/dist/db/d1-driver.js +7 -0
- package/dist/db/d1-driver.js.map +1 -0
- package/dist/db/index.d.ts +5 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +4 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/schema.d.ts +696 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +99 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/factory.d.ts +34 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +121 -0
- package/dist/factory.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/jobs/index.d.ts +2 -0
- package/dist/jobs/index.d.ts.map +1 -0
- package/dist/jobs/index.js +7 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/jobs/processor.d.ts +49 -0
- package/dist/jobs/processor.d.ts.map +1 -0
- package/dist/jobs/processor.js +118 -0
- package/dist/jobs/processor.js.map +1 -0
- package/dist/middleware/auth.d.ts +52 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +300 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/composer-version.d.ts +7 -0
- package/dist/middleware/composer-version.d.ts.map +1 -0
- package/dist/middleware/composer-version.js +18 -0
- package/dist/middleware/composer-version.js.map +1 -0
- package/dist/middleware/error-handler.d.ts +7 -0
- package/dist/middleware/error-handler.d.ts.map +1 -0
- package/dist/middleware/error-handler.js +45 -0
- package/dist/middleware/error-handler.js.map +1 -0
- package/dist/middleware/index.d.ts +5 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +6 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/middleware/request-id.d.ts +9 -0
- package/dist/middleware/request-id.d.ts.map +1 -0
- package/dist/middleware/request-id.js +36 -0
- package/dist/middleware/request-id.js.map +1 -0
- package/dist/ports.d.ts +32 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +4 -0
- package/dist/ports.js.map +1 -0
- package/dist/queue/consumer.d.ts +18 -0
- package/dist/queue/consumer.d.ts.map +1 -0
- package/dist/queue/consumer.js +82 -0
- package/dist/queue/consumer.js.map +1 -0
- package/dist/queue/index.d.ts +2 -0
- package/dist/queue/index.d.ts.map +1 -0
- package/dist/queue/index.js +2 -0
- package/dist/queue/index.js.map +1 -0
- package/dist/queue/memory-driver.d.ts +13 -0
- package/dist/queue/memory-driver.d.ts.map +1 -0
- package/dist/queue/memory-driver.js +22 -0
- package/dist/queue/memory-driver.js.map +1 -0
- package/dist/queue/types.d.ts +19 -0
- package/dist/queue/types.d.ts.map +1 -0
- package/dist/queue/types.js +3 -0
- package/dist/queue/types.js.map +1 -0
- package/dist/routes/api/artifacts.d.ts +25 -0
- package/dist/routes/api/artifacts.d.ts.map +1 -0
- package/dist/routes/api/artifacts.js +57 -0
- package/dist/routes/api/artifacts.js.map +1 -0
- package/dist/routes/api/auth.d.ts +50 -0
- package/dist/routes/api/auth.d.ts.map +1 -0
- package/dist/routes/api/auth.js +268 -0
- package/dist/routes/api/auth.js.map +1 -0
- package/dist/routes/api/index.d.ts +9 -0
- package/dist/routes/api/index.d.ts.map +1 -0
- package/dist/routes/api/index.js +10 -0
- package/dist/routes/api/index.js.map +1 -0
- package/dist/routes/api/packages.d.ts +47 -0
- package/dist/routes/api/packages.d.ts.map +1 -0
- package/dist/routes/api/packages.js +671 -0
- package/dist/routes/api/packages.js.map +1 -0
- package/dist/routes/api/repositories.d.ts +56 -0
- package/dist/routes/api/repositories.d.ts.map +1 -0
- package/dist/routes/api/repositories.js +317 -0
- package/dist/routes/api/repositories.js.map +1 -0
- package/dist/routes/api/settings.d.ts +28 -0
- package/dist/routes/api/settings.d.ts.map +1 -0
- package/dist/routes/api/settings.js +81 -0
- package/dist/routes/api/settings.js.map +1 -0
- package/dist/routes/api/stats.d.ts +21 -0
- package/dist/routes/api/stats.d.ts.map +1 -0
- package/dist/routes/api/stats.js +52 -0
- package/dist/routes/api/stats.js.map +1 -0
- package/dist/routes/api/tokens.d.ts +39 -0
- package/dist/routes/api/tokens.d.ts.map +1 -0
- package/dist/routes/api/tokens.js +191 -0
- package/dist/routes/api/tokens.js.map +1 -0
- package/dist/routes/api/users.d.ts +5 -0
- package/dist/routes/api/users.d.ts.map +1 -0
- package/dist/routes/api/users.js +125 -0
- package/dist/routes/api/users.js.map +1 -0
- package/dist/routes/composer.d.ts +133 -0
- package/dist/routes/composer.d.ts.map +1 -0
- package/dist/routes/composer.js +1179 -0
- package/dist/routes/composer.js.map +1 -0
- package/dist/routes/dist.d.ts +32 -0
- package/dist/routes/dist.d.ts.map +1 -0
- package/dist/routes/dist.js +761 -0
- package/dist/routes/dist.js.map +1 -0
- package/dist/routes/health.d.ts +7 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +22 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/index.d.ts +5 -0
- package/dist/routes/index.d.ts.map +1 -0
- package/dist/routes/index.js +6 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/services/EmailService.d.ts +20 -0
- package/dist/services/EmailService.d.ts.map +1 -0
- package/dist/services/EmailService.js +27 -0
- package/dist/services/EmailService.js.map +1 -0
- package/dist/services/UserService.d.ts +27 -0
- package/dist/services/UserService.d.ts.map +1 -0
- package/dist/services/UserService.js +164 -0
- package/dist/services/UserService.js.map +1 -0
- package/dist/storage/driver.d.ts +65 -0
- package/dist/storage/driver.d.ts.map +1 -0
- package/dist/storage/driver.js +59 -0
- package/dist/storage/driver.js.map +1 -0
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +5 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/r2-driver.d.ts +16 -0
- package/dist/storage/r2-driver.d.ts.map +1 -0
- package/dist/storage/r2-driver.js +28 -0
- package/dist/storage/r2-driver.js.map +1 -0
- package/dist/storage/s3-driver.d.ts +22 -0
- package/dist/storage/s3-driver.d.ts.map +1 -0
- package/dist/storage/s3-driver.js +66 -0
- package/dist/storage/s3-driver.js.map +1 -0
- package/dist/sync/github-sync.d.ts +15 -0
- package/dist/sync/github-sync.d.ts.map +1 -0
- package/dist/sync/github-sync.js +39 -0
- package/dist/sync/github-sync.js.map +1 -0
- package/dist/sync/index.d.ts +5 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +6 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/repository-sync.d.ts +18 -0
- package/dist/sync/repository-sync.d.ts.map +1 -0
- package/dist/sync/repository-sync.js +214 -0
- package/dist/sync/repository-sync.js.map +1 -0
- package/dist/sync/strategies/composer-repo.d.ts +11 -0
- package/dist/sync/strategies/composer-repo.d.ts.map +1 -0
- package/dist/sync/strategies/composer-repo.js +269 -0
- package/dist/sync/strategies/composer-repo.js.map +1 -0
- package/dist/sync/strategies/github-api.d.ts +6 -0
- package/dist/sync/strategies/github-api.d.ts.map +1 -0
- package/dist/sync/strategies/github-api.js +137 -0
- package/dist/sync/strategies/github-api.js.map +1 -0
- package/dist/sync/strategies/github-packages.d.ts +7 -0
- package/dist/sync/strategies/github-packages.d.ts.map +1 -0
- package/dist/sync/strategies/github-packages.js +66 -0
- package/dist/sync/strategies/github-packages.js.map +1 -0
- package/dist/sync/strategies/index.d.ts +4 -0
- package/dist/sync/strategies/index.d.ts.map +1 -0
- package/dist/sync/strategies/index.js +5 -0
- package/dist/sync/strategies/index.js.map +1 -0
- package/dist/sync/types.d.ts +60 -0
- package/dist/sync/types.d.ts.map +1 -0
- package/dist/sync/types.js +3 -0
- package/dist/sync/types.js.map +1 -0
- package/dist/utils/analytics.d.ts +142 -0
- package/dist/utils/analytics.d.ts.map +1 -0
- package/dist/utils/analytics.js +229 -0
- package/dist/utils/analytics.js.map +1 -0
- package/dist/utils/download.d.ts +10 -0
- package/dist/utils/download.d.ts.map +1 -0
- package/dist/utils/download.js +34 -0
- package/dist/utils/download.js.map +1 -0
- package/dist/utils/encryption.d.ts +20 -0
- package/dist/utils/encryption.d.ts.map +1 -0
- package/dist/utils/encryption.js +76 -0
- package/dist/utils/encryption.js.map +1 -0
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +6 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/logger.d.ts +78 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +134 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/upstream-fetch.d.ts +15 -0
- package/dist/utils/upstream-fetch.d.ts.map +1 -0
- package/dist/utils/upstream-fetch.js +108 -0
- package/dist/utils/upstream-fetch.js.map +1 -0
- package/dist/workflows/index.d.ts +3 -0
- package/dist/workflows/index.d.ts.map +1 -0
- package/dist/workflows/index.js +8 -0
- package/dist/workflows/index.js.map +1 -0
- package/dist/workflows/package-storage.d.ts +47 -0
- package/dist/workflows/package-storage.d.ts.map +1 -0
- package/dist/workflows/package-storage.js +136 -0
- package/dist/workflows/package-storage.js.map +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PACKAGE.broker
|
|
3
|
+
* Copyright (C) 2025 Łukasz Bajsarowicz
|
|
4
|
+
* Licensed under AGPL-3.0
|
|
5
|
+
*/
|
|
6
|
+
import { repositories, packages } from '../db/schema';
|
|
7
|
+
import { eq, and, inArray } from 'drizzle-orm';
|
|
8
|
+
import { createJobProcessor } from '../jobs/processor';
|
|
9
|
+
import { isPackagistMirroringEnabled, isPackageCachingEnabled } from './api/settings';
|
|
10
|
+
import { COMPOSER_USER_AGENT } from '@package-broker/shared';
|
|
11
|
+
import { nanoid } from 'nanoid';
|
|
12
|
+
import { encryptCredentials } from '../utils/encryption';
|
|
13
|
+
import { getLogger } from '../utils/logger';
|
|
14
|
+
import { getAnalytics } from '../utils/analytics';
|
|
15
|
+
/**
|
|
16
|
+
* GET /packages.json
|
|
17
|
+
* Serve aggregated packages.json for all private repositories
|
|
18
|
+
* Uses KV caching with stale-while-revalidate strategy
|
|
19
|
+
*/
|
|
20
|
+
export async function packagesJsonRoute(c) {
|
|
21
|
+
const kvKey = 'packages:all:packages.json';
|
|
22
|
+
const metadataKey = 'packages:all:metadata';
|
|
23
|
+
// First, check if there are pending repositories that need sync
|
|
24
|
+
// This must happen BEFORE returning cached data to ensure new repos are synced
|
|
25
|
+
const hasPendingRepos = await syncPendingRepositories(c);
|
|
26
|
+
// If we synced repos, clear cache to get fresh data
|
|
27
|
+
if (hasPendingRepos && c.env.KV) {
|
|
28
|
+
await c.env.KV.delete(kvKey).catch(() => { });
|
|
29
|
+
await c.env.KV.delete(metadataKey).catch(() => { });
|
|
30
|
+
}
|
|
31
|
+
// Check conditional request (If-Modified-Since)
|
|
32
|
+
const ifModifiedSince = c.req.header('If-Modified-Since');
|
|
33
|
+
const metadata = c.env.KV ? await c.env.KV.get(metadataKey, 'json') : null;
|
|
34
|
+
if (ifModifiedSince && metadata?.lastModified) {
|
|
35
|
+
const clientDate = new Date(ifModifiedSince).getTime();
|
|
36
|
+
if (clientDate >= metadata.lastModified) {
|
|
37
|
+
return new Response(null, { status: 304 });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Try to get from KV cache
|
|
41
|
+
const cached = c.env.KV ? await c.env.KV.get(kvKey) : null;
|
|
42
|
+
if (cached) {
|
|
43
|
+
const headers = new Headers();
|
|
44
|
+
headers.set('Content-Type', 'application/json');
|
|
45
|
+
if (metadata?.lastModified) {
|
|
46
|
+
headers.set('Last-Modified', new Date(metadata.lastModified).toUTCString());
|
|
47
|
+
}
|
|
48
|
+
headers.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=60');
|
|
49
|
+
// Track metadata request (cache hit)
|
|
50
|
+
const analytics = getAnalytics();
|
|
51
|
+
const requestId = c.get('requestId');
|
|
52
|
+
analytics.trackPackageMetadataRequest({
|
|
53
|
+
requestId,
|
|
54
|
+
cacheHit: true,
|
|
55
|
+
});
|
|
56
|
+
return new Response(cached, { status: 200, headers });
|
|
57
|
+
}
|
|
58
|
+
// No cache - build packages.json from database
|
|
59
|
+
const packagesJson = await buildPackagesJson(c);
|
|
60
|
+
// Cache the result (fire-and-forget to avoid blocking on KV rate limits)
|
|
61
|
+
const cachingEnabled = await isPackageCachingEnabled(c.env.KV);
|
|
62
|
+
if (cachingEnabled && c.env.KV) {
|
|
63
|
+
c.executionCtx.waitUntil(Promise.all([
|
|
64
|
+
c.env.KV.put(kvKey, JSON.stringify(packagesJson)).catch(() => { }),
|
|
65
|
+
c.env.KV.put(metadataKey, JSON.stringify({ lastModified: Date.now() })).catch(() => { })
|
|
66
|
+
]));
|
|
67
|
+
}
|
|
68
|
+
// Track metadata request (cache miss)
|
|
69
|
+
const analytics = getAnalytics();
|
|
70
|
+
const requestId = c.get('requestId');
|
|
71
|
+
const packageCount = packagesJson.packages ? Object.keys(packagesJson.packages).length : 0;
|
|
72
|
+
analytics.trackPackageMetadataRequest({
|
|
73
|
+
requestId,
|
|
74
|
+
cacheHit: false,
|
|
75
|
+
packageCount,
|
|
76
|
+
});
|
|
77
|
+
const headers = new Headers();
|
|
78
|
+
headers.set('Content-Type', 'application/json');
|
|
79
|
+
headers.set('Last-Modified', new Date().toUTCString());
|
|
80
|
+
headers.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=60');
|
|
81
|
+
return new Response(JSON.stringify(packagesJson), { status: 200, headers });
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* GET /p2/:vendor/:package.json
|
|
85
|
+
* Serve individual package metadata (Composer 2 provider format)
|
|
86
|
+
* Supports public Packagist mirroring with lazy loading
|
|
87
|
+
*/
|
|
88
|
+
export async function p2PackageRoute(c) {
|
|
89
|
+
const vendor = c.req.param('vendor');
|
|
90
|
+
const packageFile = c.req.param('package');
|
|
91
|
+
const packageName = `${vendor}/${packageFile?.replace('.json', '')}`;
|
|
92
|
+
if (!vendor || !packageFile) {
|
|
93
|
+
return c.json({ error: 'Bad Request', message: 'Invalid package name' }, 400);
|
|
94
|
+
}
|
|
95
|
+
const kvKey = `p2:${packageName}`;
|
|
96
|
+
const metadataKey = `p2:${packageName}:metadata`;
|
|
97
|
+
const db = c.get('database');
|
|
98
|
+
// Check conditional request
|
|
99
|
+
const ifModifiedSince = c.req.header('If-Modified-Since');
|
|
100
|
+
const metadata = c.env.KV ? await c.env.KV.get(metadataKey, 'json') : null;
|
|
101
|
+
if (ifModifiedSince && metadata?.lastModified) {
|
|
102
|
+
const clientDate = new Date(ifModifiedSince).getTime();
|
|
103
|
+
if (clientDate >= metadata.lastModified) {
|
|
104
|
+
return new Response(null, { status: 304 });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Try to get from KV cache first (includes Packagist proxied packages)
|
|
108
|
+
const cached = c.env.KV ? await c.env.KV.get(kvKey) : null;
|
|
109
|
+
if (cached) {
|
|
110
|
+
// Return cached data directly - validation happens during storage, not retrieval
|
|
111
|
+
// This avoids expensive O(n) validation loops that consume CPU time
|
|
112
|
+
try {
|
|
113
|
+
const cachedData = JSON.parse(cached);
|
|
114
|
+
// Validate cached data type and format
|
|
115
|
+
if (typeof cachedData !== 'object' ||
|
|
116
|
+
cachedData === null ||
|
|
117
|
+
(cachedData.transformed && !cachedData.packages)) {
|
|
118
|
+
const logger = getLogger();
|
|
119
|
+
logger.warn('Invalid cache format (not an object or old format), treating as cache miss', { packageName });
|
|
120
|
+
// Fire-and-forget cache deletion
|
|
121
|
+
if (c.env.KV) {
|
|
122
|
+
c.executionCtx.waitUntil(Promise.all([
|
|
123
|
+
c.env.KV.delete(kvKey).catch(() => { }),
|
|
124
|
+
c.env.KV.delete(metadataKey).catch(() => { })
|
|
125
|
+
]));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
// Valid cached response - return as-is (trust the data)
|
|
130
|
+
const headers = new Headers();
|
|
131
|
+
headers.set('Content-Type', 'application/json');
|
|
132
|
+
if (metadata?.lastModified) {
|
|
133
|
+
headers.set('Last-Modified', new Date(metadata.lastModified).toUTCString());
|
|
134
|
+
}
|
|
135
|
+
headers.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=60');
|
|
136
|
+
headers.set('X-Cache', 'HIT-KV');
|
|
137
|
+
// Track metadata request (cache hit)
|
|
138
|
+
const analytics = getAnalytics();
|
|
139
|
+
const requestId = c.get('requestId');
|
|
140
|
+
const packageCount = cachedData.packages?.[packageName] ? Object.keys(cachedData.packages[packageName]).length : 0;
|
|
141
|
+
analytics.trackPackageMetadataRequest({
|
|
142
|
+
requestId,
|
|
143
|
+
cacheHit: true,
|
|
144
|
+
packageCount,
|
|
145
|
+
});
|
|
146
|
+
return new Response(cached, { status: 200, headers });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
// Invalid JSON in cache - delete and treat as cache miss
|
|
151
|
+
const logger = getLogger();
|
|
152
|
+
logger.warn('Invalid JSON in cache, treating as cache miss', { packageName, error: e instanceof Error ? e.message : String(e) });
|
|
153
|
+
// Fire-and-forget cache deletion
|
|
154
|
+
if (c.env.KV) {
|
|
155
|
+
c.executionCtx.waitUntil(Promise.all([
|
|
156
|
+
c.env.KV.delete(kvKey).catch(() => { }),
|
|
157
|
+
c.env.KV.delete(metadataKey).catch(() => { })
|
|
158
|
+
]));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Check if packages are already in database
|
|
163
|
+
const existingPackages = await db
|
|
164
|
+
.select()
|
|
165
|
+
.from(packages)
|
|
166
|
+
.where(eq(packages.name, packageName));
|
|
167
|
+
if (existingPackages.length > 0) {
|
|
168
|
+
// Build response from database packages
|
|
169
|
+
const packageData = buildP2Response(packageName, existingPackages);
|
|
170
|
+
// Cache the result (fire-and-forget to avoid blocking on KV rate limits)
|
|
171
|
+
const cachingEnabled = await isPackageCachingEnabled(c.env.KV);
|
|
172
|
+
if (cachingEnabled && c.env.KV) {
|
|
173
|
+
c.executionCtx.waitUntil(Promise.all([
|
|
174
|
+
c.env.KV.put(kvKey, JSON.stringify(packageData)).catch(() => { }),
|
|
175
|
+
c.env.KV.put(metadataKey, JSON.stringify({ lastModified: Date.now() })).catch(() => { })
|
|
176
|
+
]));
|
|
177
|
+
}
|
|
178
|
+
// Track metadata request (cache miss, from DB)
|
|
179
|
+
const analytics = getAnalytics();
|
|
180
|
+
const requestId = c.get('requestId');
|
|
181
|
+
const packageCount = packageData.packages[packageName] ? Object.keys(packageData.packages[packageName]).length : 0;
|
|
182
|
+
analytics.trackPackageMetadataRequest({
|
|
183
|
+
requestId,
|
|
184
|
+
cacheHit: false,
|
|
185
|
+
packageCount,
|
|
186
|
+
});
|
|
187
|
+
const headers = new Headers();
|
|
188
|
+
headers.set('Content-Type', 'application/json');
|
|
189
|
+
headers.set('Last-Modified', new Date().toUTCString());
|
|
190
|
+
headers.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=60');
|
|
191
|
+
headers.set('X-Cache', 'HIT-DB');
|
|
192
|
+
return new Response(JSON.stringify(packageData), { status: 200, headers });
|
|
193
|
+
}
|
|
194
|
+
// Not in database - try lazy loading from upstream repositories
|
|
195
|
+
const activeRepos = await db
|
|
196
|
+
.select()
|
|
197
|
+
.from(repositories)
|
|
198
|
+
.where(eq(repositories.status, 'active'));
|
|
199
|
+
// Try to fetch from upstream Composer repositories
|
|
200
|
+
for (const repo of activeRepos) {
|
|
201
|
+
if (repo.vcs_type === 'composer') {
|
|
202
|
+
try {
|
|
203
|
+
const { fetchPackageFromUpstream } = await import('../utils/upstream-fetch');
|
|
204
|
+
const packageData = await fetchPackageFromUpstream({
|
|
205
|
+
id: repo.id,
|
|
206
|
+
url: repo.url,
|
|
207
|
+
vcs_type: repo.vcs_type,
|
|
208
|
+
credential_type: repo.credential_type,
|
|
209
|
+
auth_credentials: repo.auth_credentials,
|
|
210
|
+
package_filter: repo.package_filter,
|
|
211
|
+
}, packageName, c.env.ENCRYPTION_KEY);
|
|
212
|
+
if (packageData) {
|
|
213
|
+
// Transform dist URLs in memory (lightweight, no D1 operations)
|
|
214
|
+
const url = new URL(c.req.url);
|
|
215
|
+
const baseUrl = `${url.protocol}//${url.host}`;
|
|
216
|
+
const transformedData = transformDistUrlsInMemory(packageData, repo.id, baseUrl);
|
|
217
|
+
// Check if we should skip storage (for Free tier optimization)
|
|
218
|
+
const skipStorage = c.env.SKIP_PACKAGE_STORAGE === 'true';
|
|
219
|
+
// Store in D1 in background (doesn't block response)
|
|
220
|
+
// Priority: 1. Cloudflare Workflow (durable, high CPU limits)
|
|
221
|
+
// 2. waitUntil (best-effort, low CPU limits)
|
|
222
|
+
if (!skipStorage) {
|
|
223
|
+
const workflow = c.env.PACKAGE_STORAGE_WORKFLOW;
|
|
224
|
+
const repoLogger = getLogger();
|
|
225
|
+
if (workflow) {
|
|
226
|
+
// Use Cloudflare Workflow for durable background processing
|
|
227
|
+
c.executionCtx.waitUntil((async () => {
|
|
228
|
+
try {
|
|
229
|
+
const instance = await workflow.create({
|
|
230
|
+
id: `pkg-${packageName.replace('/', '-')}-${repo.id}-${Date.now()}`,
|
|
231
|
+
params: {
|
|
232
|
+
packageName,
|
|
233
|
+
packageData,
|
|
234
|
+
repoId: repo.id,
|
|
235
|
+
proxyBaseUrl: baseUrl,
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
repoLogger.debug('Workflow triggered for repo package storage', {
|
|
239
|
+
packageName,
|
|
240
|
+
repoId: repo.id,
|
|
241
|
+
instanceId: instance.id
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
// Workflow creation failed - fall back to inline processing
|
|
246
|
+
repoLogger.warn('Workflow creation failed for repo, falling back to inline', {
|
|
247
|
+
packageName,
|
|
248
|
+
repoId: repo.id,
|
|
249
|
+
error: e instanceof Error ? e.message : String(e)
|
|
250
|
+
});
|
|
251
|
+
try {
|
|
252
|
+
const db = c.get('database');
|
|
253
|
+
await transformPackageDistUrls(packageData, repo.id, baseUrl, db);
|
|
254
|
+
}
|
|
255
|
+
catch (fallbackError) {
|
|
256
|
+
repoLogger.warn('Fallback storage also failed', {
|
|
257
|
+
packageName,
|
|
258
|
+
repoId: repo.id,
|
|
259
|
+
error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
})());
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
// Fallback to waitUntil (original behavior, may hit CPU limits)
|
|
267
|
+
c.executionCtx.waitUntil((async () => {
|
|
268
|
+
try {
|
|
269
|
+
const db = c.get('database');
|
|
270
|
+
const { storedCount, errors } = await transformPackageDistUrls(packageData, repo.id, baseUrl, db);
|
|
271
|
+
repoLogger.info('Stored package versions from repo (background)', { packageName, repoId: repo.id, storedCount, errorCount: errors.length });
|
|
272
|
+
if (errors.length > 0) {
|
|
273
|
+
repoLogger.warn('Package storage errors (background)', { packageName, repoId: repo.id, errors });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
// Ignore background errors - storage is best-effort
|
|
278
|
+
repoLogger.warn('Background storage failed', { packageName, repoId: repo.id, error: e instanceof Error ? e.message : String(e) });
|
|
279
|
+
}
|
|
280
|
+
})());
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Track metadata request (cache miss, from upstream)
|
|
284
|
+
const analytics = getAnalytics();
|
|
285
|
+
const requestId = c.get('requestId');
|
|
286
|
+
const packageCount = transformedData.packages?.[packageName] ? Object.keys(transformedData.packages[packageName]).length : 0;
|
|
287
|
+
analytics.trackPackageMetadataRequest({
|
|
288
|
+
requestId,
|
|
289
|
+
cacheHit: false,
|
|
290
|
+
packageCount,
|
|
291
|
+
});
|
|
292
|
+
const headers = new Headers();
|
|
293
|
+
headers.set('Content-Type', 'application/json');
|
|
294
|
+
headers.set('Last-Modified', new Date().toUTCString());
|
|
295
|
+
headers.set('Cache-Control', 'public, max-age=3600');
|
|
296
|
+
headers.set('X-Cache', 'MISS-UPSTREAM');
|
|
297
|
+
return new Response(JSON.stringify(transformedData), { status: 200, headers });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
const logger = getLogger();
|
|
302
|
+
logger.warn('Error fetching package from repo', { packageName, repoId: repo.id, error: error instanceof Error ? error.message : String(error) });
|
|
303
|
+
// Continue to next repository
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// Not found in any upstream repo - check if Packagist mirroring is enabled
|
|
308
|
+
const mirroringEnabled = await isPackagistMirroringEnabled(c.env.KV);
|
|
309
|
+
if (!mirroringEnabled) {
|
|
310
|
+
return c.json({
|
|
311
|
+
error: 'Not Found',
|
|
312
|
+
message: 'Package not found. Public Packagist mirroring is disabled.',
|
|
313
|
+
}, 404);
|
|
314
|
+
}
|
|
315
|
+
// Proxy to public Packagist
|
|
316
|
+
return proxyToPackagist(c, packageName);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Build aggregated packages.json from all repositories
|
|
320
|
+
* Uses providers-lazy-url for large repositories (lazy loading pattern)
|
|
321
|
+
*/
|
|
322
|
+
async function buildPackagesJson(c) {
|
|
323
|
+
const db = c.get('database');
|
|
324
|
+
const url = new URL(c.req.url);
|
|
325
|
+
const baseUrl = `${url.protocol}//${url.host}`;
|
|
326
|
+
// Check if we have any active Composer repositories
|
|
327
|
+
const activeComposerRepos = await db
|
|
328
|
+
.select()
|
|
329
|
+
.from(repositories)
|
|
330
|
+
.where(and(eq(repositories.status, 'active'), eq(repositories.vcs_type, 'composer')));
|
|
331
|
+
// Check if Packagist mirroring is enabled
|
|
332
|
+
const mirroringEnabled = await isPackagistMirroringEnabled(c.env.KV);
|
|
333
|
+
// Use lazy loading if:
|
|
334
|
+
// 1. We have active Composer repositories, OR
|
|
335
|
+
// 2. Packagist mirroring is enabled (so we can proxy public packages)
|
|
336
|
+
if (activeComposerRepos.length > 0 || mirroringEnabled) {
|
|
337
|
+
return {
|
|
338
|
+
'providers-lazy-url': `${baseUrl}/p2/%package%.json`,
|
|
339
|
+
'metadata-url': `${baseUrl}/p2/%package%.json`,
|
|
340
|
+
'mirrors': [
|
|
341
|
+
{
|
|
342
|
+
'dist-url': `${baseUrl}/dist/m/%package%/%version%.%type%`,
|
|
343
|
+
'preferred': true,
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
packages: {}, // Empty - packages loaded on-demand
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
// For Git repositories only (when no Composer repos and no Packagist mirroring), use direct packages
|
|
350
|
+
const allPackages = await db.select().from(packages);
|
|
351
|
+
// Build Composer packages.json structure
|
|
352
|
+
const packagesMap = {};
|
|
353
|
+
for (const pkg of allPackages) {
|
|
354
|
+
if (!packagesMap[pkg.name]) {
|
|
355
|
+
packagesMap[pkg.name] = {};
|
|
356
|
+
}
|
|
357
|
+
// Use dist_url (proxy URL) and transform to mirror format
|
|
358
|
+
// source_dist_url is the original external URL - don't expose it to clients
|
|
359
|
+
packagesMap[pkg.name][pkg.version] = {
|
|
360
|
+
name: pkg.name,
|
|
361
|
+
version: pkg.version,
|
|
362
|
+
dist: {
|
|
363
|
+
type: 'zip',
|
|
364
|
+
url: transformDistUrlToMirrorFormat(pkg.dist_url) || pkg.dist_url,
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
packages: packagesMap,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Build Composer 2 provider response for a single package from stored metadata
|
|
374
|
+
* Generates clean response with proper types from D1 stored data
|
|
375
|
+
*
|
|
376
|
+
* NOTE: Composer 2 (p2) format expects versions as an ARRAY, not a dict keyed by version.
|
|
377
|
+
* See: https://packagist.org/apidoc
|
|
378
|
+
*/
|
|
379
|
+
export function buildP2Response(packageName, packageVersions) {
|
|
380
|
+
const versions = [];
|
|
381
|
+
for (const pkg of packageVersions) {
|
|
382
|
+
// Build dist object from database columns (no metadata parse needed)
|
|
383
|
+
// Use dist_url (proxy URL) and transform to mirror format
|
|
384
|
+
// source_dist_url is the original external URL - don't expose it to clients
|
|
385
|
+
const dist = {
|
|
386
|
+
type: 'zip', // Default, can be overridden from metadata if needed
|
|
387
|
+
url: transformDistUrlToMirrorFormat(pkg.dist_url) || pkg.dist_url,
|
|
388
|
+
};
|
|
389
|
+
if (pkg.dist_reference) {
|
|
390
|
+
dist.reference = pkg.dist_reference;
|
|
391
|
+
}
|
|
392
|
+
// Build version object with required fields (from database columns)
|
|
393
|
+
const versionData = {
|
|
394
|
+
name: packageName,
|
|
395
|
+
version: pkg.version,
|
|
396
|
+
dist,
|
|
397
|
+
};
|
|
398
|
+
// Use database columns first (no JSON parsing needed)
|
|
399
|
+
if (pkg.description) {
|
|
400
|
+
versionData.description = pkg.description;
|
|
401
|
+
}
|
|
402
|
+
if (pkg.license) {
|
|
403
|
+
try {
|
|
404
|
+
// License is stored as JSON string (for array support)
|
|
405
|
+
const license = JSON.parse(pkg.license);
|
|
406
|
+
if (typeof license === 'string' || Array.isArray(license)) {
|
|
407
|
+
versionData.license = license;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
// If parsing fails, treat as plain string
|
|
412
|
+
versionData.license = pkg.license; // pkg.license is not null here due to if check
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (pkg.package_type) {
|
|
416
|
+
versionData.type = pkg.package_type;
|
|
417
|
+
}
|
|
418
|
+
if (pkg.homepage) {
|
|
419
|
+
versionData.homepage = pkg.homepage;
|
|
420
|
+
}
|
|
421
|
+
if (pkg.released_at) {
|
|
422
|
+
// Convert Unix timestamp to ISO 8601 string
|
|
423
|
+
versionData.time = new Date(pkg.released_at * 1000).toISOString();
|
|
424
|
+
}
|
|
425
|
+
// Only parse metadata if we need fields not in database columns
|
|
426
|
+
// This significantly reduces CPU usage for packages with many versions
|
|
427
|
+
// We parse metadata to get: source, require, autoload, and other dependency fields
|
|
428
|
+
if (pkg.metadata) {
|
|
429
|
+
try {
|
|
430
|
+
// Lazy parse: only extract fields we actually need
|
|
431
|
+
const fullMetadata = JSON.parse(pkg.metadata);
|
|
432
|
+
// Only extract essential fields that aren't in database columns
|
|
433
|
+
// Essential: source, require, autoload (needed for Composer resolution)
|
|
434
|
+
// Optional: require-dev, autoload-dev, conflict, replace, provide, suggest, extra, bin, keywords, authors
|
|
435
|
+
// Source (not in columns, but commonly needed)
|
|
436
|
+
if (fullMetadata.source !== null &&
|
|
437
|
+
fullMetadata.source !== undefined &&
|
|
438
|
+
fullMetadata.source !== '__unset' &&
|
|
439
|
+
typeof fullMetadata.source === 'object' &&
|
|
440
|
+
!Array.isArray(fullMetadata.source) &&
|
|
441
|
+
typeof fullMetadata.source.type === 'string' &&
|
|
442
|
+
typeof fullMetadata.source.url === 'string') {
|
|
443
|
+
versionData.source = {
|
|
444
|
+
type: fullMetadata.source.type,
|
|
445
|
+
url: fullMetadata.source.url,
|
|
446
|
+
...(fullMetadata.source.reference && { reference: fullMetadata.source.reference }),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
// Dist type and shasum (if not default)
|
|
450
|
+
if (fullMetadata.dist?.type && fullMetadata.dist.type !== 'zip') {
|
|
451
|
+
dist.type = fullMetadata.dist.type;
|
|
452
|
+
}
|
|
453
|
+
if (fullMetadata.dist?.shasum) {
|
|
454
|
+
dist.shasum = fullMetadata.dist.shasum;
|
|
455
|
+
}
|
|
456
|
+
// Dependencies (essential for Composer)
|
|
457
|
+
if (fullMetadata.require && typeof fullMetadata.require === 'object' && !Array.isArray(fullMetadata.require)) {
|
|
458
|
+
versionData.require = fullMetadata.require;
|
|
459
|
+
}
|
|
460
|
+
if (fullMetadata['require-dev'] && typeof fullMetadata['require-dev'] === 'object' && !Array.isArray(fullMetadata['require-dev'])) {
|
|
461
|
+
versionData['require-dev'] = fullMetadata['require-dev'];
|
|
462
|
+
}
|
|
463
|
+
if (fullMetadata.autoload && typeof fullMetadata.autoload === 'object' && !Array.isArray(fullMetadata.autoload)) {
|
|
464
|
+
versionData.autoload = fullMetadata.autoload;
|
|
465
|
+
}
|
|
466
|
+
if (fullMetadata['autoload-dev'] && typeof fullMetadata['autoload-dev'] === 'object' && !Array.isArray(fullMetadata['autoload-dev'])) {
|
|
467
|
+
versionData['autoload-dev'] = fullMetadata['autoload-dev'];
|
|
468
|
+
}
|
|
469
|
+
// Conflict/replace/provide (important for dependency resolution)
|
|
470
|
+
if (fullMetadata.conflict && typeof fullMetadata.conflict === 'object' && !Array.isArray(fullMetadata.conflict)) {
|
|
471
|
+
versionData.conflict = fullMetadata.conflict;
|
|
472
|
+
}
|
|
473
|
+
if (fullMetadata.replace && typeof fullMetadata.replace === 'object' && !Array.isArray(fullMetadata.replace)) {
|
|
474
|
+
versionData.replace = fullMetadata.replace;
|
|
475
|
+
}
|
|
476
|
+
if (fullMetadata.provide && typeof fullMetadata.provide === 'object' && !Array.isArray(fullMetadata.provide)) {
|
|
477
|
+
versionData.provide = fullMetadata.provide;
|
|
478
|
+
}
|
|
479
|
+
// Optional but commonly used fields
|
|
480
|
+
if (fullMetadata.suggest && typeof fullMetadata.suggest === 'object' && !Array.isArray(fullMetadata.suggest)) {
|
|
481
|
+
versionData.suggest = fullMetadata.suggest;
|
|
482
|
+
}
|
|
483
|
+
if (fullMetadata.extra && typeof fullMetadata.extra === 'object' && !Array.isArray(fullMetadata.extra)) {
|
|
484
|
+
versionData.extra = fullMetadata.extra;
|
|
485
|
+
}
|
|
486
|
+
if (fullMetadata.bin) {
|
|
487
|
+
versionData.bin = fullMetadata.bin;
|
|
488
|
+
}
|
|
489
|
+
if (fullMetadata.keywords && Array.isArray(fullMetadata.keywords)) {
|
|
490
|
+
versionData.keywords = fullMetadata.keywords;
|
|
491
|
+
}
|
|
492
|
+
if (fullMetadata.authors && Array.isArray(fullMetadata.authors)) {
|
|
493
|
+
versionData.authors = fullMetadata.authors;
|
|
494
|
+
}
|
|
495
|
+
if (fullMetadata['notification-url'] !== undefined) {
|
|
496
|
+
versionData['notification-url'] = fullMetadata['notification-url'];
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
// If metadata parse fails, we still have all essential fields from database columns
|
|
501
|
+
const logger = getLogger();
|
|
502
|
+
logger.warn('Failed to parse stored metadata', {
|
|
503
|
+
packageName: pkg.name,
|
|
504
|
+
version: pkg.version,
|
|
505
|
+
error: error instanceof Error ? error.message : String(error)
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// Update dist with any metadata overrides
|
|
510
|
+
versionData.dist = dist;
|
|
511
|
+
versions.push(versionData);
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
packages: {
|
|
515
|
+
[packageName]: versions,
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Ensure Packagist repository exists in database
|
|
521
|
+
* Creates it if it doesn't exist
|
|
522
|
+
*/
|
|
523
|
+
export async function ensurePackagistRepository(db, encryptionKey, kv) {
|
|
524
|
+
// Cache check to avoid D1 query on every request
|
|
525
|
+
const cacheKey = 'packagist_repo_exists';
|
|
526
|
+
if (kv) {
|
|
527
|
+
const cached = await kv.get(cacheKey);
|
|
528
|
+
if (cached === 'true') {
|
|
529
|
+
return; // Repository exists, skip D1 query
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
const [existing] = await db
|
|
533
|
+
.select()
|
|
534
|
+
.from(repositories)
|
|
535
|
+
.where(eq(repositories.id, 'packagist'))
|
|
536
|
+
.limit(1);
|
|
537
|
+
if (existing) {
|
|
538
|
+
// Cache the result for 1 hour
|
|
539
|
+
if (kv) {
|
|
540
|
+
await kv.put(cacheKey, 'true', { expirationTtl: 3600 });
|
|
541
|
+
}
|
|
542
|
+
return; // Repository already exists
|
|
543
|
+
}
|
|
544
|
+
// Create Packagist repository entry
|
|
545
|
+
// Encrypt empty credentials object since auth_credentials is NOT NULL
|
|
546
|
+
const emptyCredentials = await encryptCredentials('{}', encryptionKey);
|
|
547
|
+
await db.insert(repositories).values({
|
|
548
|
+
id: 'packagist',
|
|
549
|
+
url: 'https://repo.packagist.org',
|
|
550
|
+
vcs_type: 'composer',
|
|
551
|
+
credential_type: 'none',
|
|
552
|
+
auth_credentials: emptyCredentials,
|
|
553
|
+
composer_json_path: null,
|
|
554
|
+
package_filter: null,
|
|
555
|
+
status: 'active',
|
|
556
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
557
|
+
});
|
|
558
|
+
// Cache the result after creation
|
|
559
|
+
if (kv) {
|
|
560
|
+
await kv.put(cacheKey, 'true', { expirationTtl: 3600 });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Proxy request to public Packagist (for mirroring)
|
|
565
|
+
* Also stores package metadata in database for artifact downloads
|
|
566
|
+
*/
|
|
567
|
+
async function proxyToPackagist(c, packageName) {
|
|
568
|
+
const packagistUrl = `https://repo.packagist.org/p2/${packageName}.json`;
|
|
569
|
+
const logger = getLogger();
|
|
570
|
+
try {
|
|
571
|
+
// Add timeout to prevent hanging requests (Cloudflare Workers have 30s limit)
|
|
572
|
+
const controller = new AbortController();
|
|
573
|
+
const timeoutId = setTimeout(() => controller.abort(), 25000); // 25s timeout
|
|
574
|
+
let response;
|
|
575
|
+
try {
|
|
576
|
+
response = await fetch(packagistUrl, {
|
|
577
|
+
headers: {
|
|
578
|
+
'User-Agent': COMPOSER_USER_AGENT,
|
|
579
|
+
},
|
|
580
|
+
signal: controller.signal,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
catch (fetchError) {
|
|
584
|
+
clearTimeout(timeoutId);
|
|
585
|
+
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
|
586
|
+
logger.error('Timeout fetching from Packagist', { packageName, url: packagistUrl });
|
|
587
|
+
return c.json({
|
|
588
|
+
error: 'Gateway Timeout',
|
|
589
|
+
message: 'Request to Packagist timed out. Please try again.'
|
|
590
|
+
}, 504);
|
|
591
|
+
}
|
|
592
|
+
throw fetchError;
|
|
593
|
+
}
|
|
594
|
+
clearTimeout(timeoutId);
|
|
595
|
+
if (!response.ok) {
|
|
596
|
+
if (response.status === 404) {
|
|
597
|
+
return c.json({ error: 'Not Found', message: 'Package not found' }, 404);
|
|
598
|
+
}
|
|
599
|
+
if (response.status >= 500) {
|
|
600
|
+
logger.warn('Packagist server error', { packageName, status: response.status });
|
|
601
|
+
return c.json({
|
|
602
|
+
error: 'Upstream Error',
|
|
603
|
+
message: `Packagist returned error ${response.status}. Please try again later.`
|
|
604
|
+
}, 502);
|
|
605
|
+
}
|
|
606
|
+
return c.json({
|
|
607
|
+
error: 'Upstream Error',
|
|
608
|
+
message: `Failed to fetch from Packagist: ${response.status} ${response.statusText}`
|
|
609
|
+
}, 502);
|
|
610
|
+
}
|
|
611
|
+
let packageData;
|
|
612
|
+
try {
|
|
613
|
+
packageData = await response.json();
|
|
614
|
+
}
|
|
615
|
+
catch (parseError) {
|
|
616
|
+
logger.error('Failed to parse Packagist response', { packageName, error: parseError instanceof Error ? parseError.message : String(parseError) });
|
|
617
|
+
return c.json({
|
|
618
|
+
error: 'Upstream Error',
|
|
619
|
+
message: 'Invalid response from Packagist'
|
|
620
|
+
}, 502);
|
|
621
|
+
}
|
|
622
|
+
const url = new URL(c.req.url);
|
|
623
|
+
const baseUrl = `${url.protocol}//${url.host}`;
|
|
624
|
+
// Transform dist URLs in memory (lightweight, no D1 operations)
|
|
625
|
+
// This allows us to return the response immediately before hitting CPU limits
|
|
626
|
+
const transformedData = transformDistUrlsInMemory(packageData, 'packagist', baseUrl);
|
|
627
|
+
// Check if we should skip storage (for Free tier optimization)
|
|
628
|
+
const skipStorage = c.env.SKIP_PACKAGE_STORAGE === 'true';
|
|
629
|
+
// Store in D1 in background (doesn't block response)
|
|
630
|
+
// Priority: 1. Cloudflare Workflow (durable, high CPU limits)
|
|
631
|
+
// 2. waitUntil (best-effort, low CPU limits)
|
|
632
|
+
if (!skipStorage) {
|
|
633
|
+
const workflow = c.env.PACKAGE_STORAGE_WORKFLOW;
|
|
634
|
+
if (workflow) {
|
|
635
|
+
// Use Cloudflare Workflow for durable background processing
|
|
636
|
+
// This provides higher CPU limits and automatic retries
|
|
637
|
+
c.executionCtx.waitUntil((async () => {
|
|
638
|
+
try {
|
|
639
|
+
const instance = await workflow.create({
|
|
640
|
+
id: `pkg-${packageName.replace('/', '-')}-${Date.now()}`,
|
|
641
|
+
params: {
|
|
642
|
+
packageName,
|
|
643
|
+
packageData,
|
|
644
|
+
repoId: 'packagist',
|
|
645
|
+
proxyBaseUrl: baseUrl,
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
logger.debug('Workflow triggered for package storage', {
|
|
649
|
+
packageName,
|
|
650
|
+
instanceId: instance.id
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
catch (e) {
|
|
654
|
+
// Workflow creation failed - fall back to inline processing
|
|
655
|
+
logger.warn('Workflow creation failed, falling back to inline', {
|
|
656
|
+
packageName,
|
|
657
|
+
error: e instanceof Error ? e.message : String(e)
|
|
658
|
+
});
|
|
659
|
+
// Fallback to inline processing
|
|
660
|
+
try {
|
|
661
|
+
const db = c.get('database');
|
|
662
|
+
await ensurePackagistRepository(db, c.env.ENCRYPTION_KEY, c.env.KV);
|
|
663
|
+
await transformPackageDistUrls(packageData, 'packagist', baseUrl, db);
|
|
664
|
+
}
|
|
665
|
+
catch (fallbackError) {
|
|
666
|
+
logger.warn('Fallback storage also failed', {
|
|
667
|
+
packageName,
|
|
668
|
+
error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
})());
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
// Fallback to waitUntil (original behavior, may hit CPU limits)
|
|
676
|
+
c.executionCtx.waitUntil((async () => {
|
|
677
|
+
try {
|
|
678
|
+
const db = c.get('database');
|
|
679
|
+
await ensurePackagistRepository(db, c.env.ENCRYPTION_KEY, c.env.KV);
|
|
680
|
+
const { storedCount, errors } = await transformPackageDistUrls(packageData, 'packagist', baseUrl, db);
|
|
681
|
+
logger.info('Stored package versions from Packagist (background)', { packageName, storedCount, errorCount: errors.length });
|
|
682
|
+
if (errors.length > 0) {
|
|
683
|
+
logger.warn('Package storage errors (background)', { packageName, errors });
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
catch (e) {
|
|
687
|
+
// Ignore background errors - storage is best-effort
|
|
688
|
+
logger.warn('Background storage failed', { packageName, error: e instanceof Error ? e.message : String(e) });
|
|
689
|
+
}
|
|
690
|
+
})());
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// Track metadata request (cache miss, from Packagist)
|
|
694
|
+
const analytics = getAnalytics();
|
|
695
|
+
const requestId = c.get('requestId');
|
|
696
|
+
const packageCount = transformedData.packages?.[packageName] ? Object.keys(transformedData.packages[packageName]).length : 0;
|
|
697
|
+
analytics.trackPackageMetadataRequest({
|
|
698
|
+
requestId,
|
|
699
|
+
cacheHit: false,
|
|
700
|
+
packageCount,
|
|
701
|
+
});
|
|
702
|
+
// Return response immediately (fast path)
|
|
703
|
+
const headers = new Headers();
|
|
704
|
+
headers.set('Content-Type', 'application/json');
|
|
705
|
+
headers.set('Last-Modified', new Date().toUTCString());
|
|
706
|
+
headers.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=60');
|
|
707
|
+
headers.set('X-Cache', 'MISS-PACKAGIST');
|
|
708
|
+
return new Response(JSON.stringify(transformedData), { status: 200, headers });
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
logger.error('Error proxying to Packagist', {
|
|
712
|
+
packageName,
|
|
713
|
+
error: error instanceof Error ? error.message : String(error),
|
|
714
|
+
stack: error instanceof Error ? error.stack : undefined
|
|
715
|
+
});
|
|
716
|
+
// Determine appropriate error response
|
|
717
|
+
if (error instanceof Error) {
|
|
718
|
+
if (error.message.includes('timeout') || error.message.includes('aborted')) {
|
|
719
|
+
return c.json({
|
|
720
|
+
error: 'Gateway Timeout',
|
|
721
|
+
message: 'Request to Packagist timed out. Please try again.'
|
|
722
|
+
}, 504);
|
|
723
|
+
}
|
|
724
|
+
if (error.message.includes('network') || error.message.includes('fetch')) {
|
|
725
|
+
return c.json({
|
|
726
|
+
error: 'Service Unavailable',
|
|
727
|
+
message: 'Unable to reach Packagist. Please try again later.'
|
|
728
|
+
}, 503);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return c.json({
|
|
732
|
+
error: 'Upstream Error',
|
|
733
|
+
message: 'Failed to fetch from Packagist'
|
|
734
|
+
}, 502);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Sync all pending repositories
|
|
739
|
+
* Uses job processor which automatically chooses sync vs async execution
|
|
740
|
+
* @returns true if any repositories were synced
|
|
741
|
+
*/
|
|
742
|
+
async function syncPendingRepositories(c) {
|
|
743
|
+
const db = c.get('database');
|
|
744
|
+
// Get repositories that need sync (pending status)
|
|
745
|
+
const reposToSync = await db
|
|
746
|
+
.select()
|
|
747
|
+
.from(repositories)
|
|
748
|
+
.where(eq(repositories.status, 'pending'));
|
|
749
|
+
if (reposToSync.length === 0) {
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
// Determine proxy base URL from request
|
|
753
|
+
const url = new URL(c.req.url);
|
|
754
|
+
const proxyBaseUrl = `${url.protocol}//${url.host}`;
|
|
755
|
+
// Create job processor (auto-selects Queue or Sync based on availability)
|
|
756
|
+
const jobProcessor = createJobProcessor({
|
|
757
|
+
DB: c.env.DB,
|
|
758
|
+
KV: c.env.KV,
|
|
759
|
+
QUEUE: c.env.QUEUE,
|
|
760
|
+
ENCRYPTION_KEY: c.env.ENCRYPTION_KEY,
|
|
761
|
+
}, {
|
|
762
|
+
syncOptions: {
|
|
763
|
+
storage: c.var.storage,
|
|
764
|
+
proxyBaseUrl,
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
// Create sync jobs for all pending repositories
|
|
768
|
+
const syncJobs = reposToSync.map((repo) => ({
|
|
769
|
+
type: 'sync_repository',
|
|
770
|
+
repoId: repo.id,
|
|
771
|
+
}));
|
|
772
|
+
// Process all jobs (parallel for sync, queued for async)
|
|
773
|
+
await jobProcessor.enqueueAll(syncJobs);
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Transform stored dist URL to mirror format
|
|
778
|
+
* Converts /dist/{repoId}/package/version.zip -> /dist/m/package/version.zip
|
|
779
|
+
* Leaves mirror format and external URLs unchanged
|
|
780
|
+
*/
|
|
781
|
+
function transformDistUrlToMirrorFormat(url) {
|
|
782
|
+
if (!url) {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
// If already mirror format or external URL, return as-is
|
|
786
|
+
if (url.includes('/dist/m/') || url.startsWith('http://') || url.startsWith('https://')) {
|
|
787
|
+
return url;
|
|
788
|
+
}
|
|
789
|
+
// Extract package name and version from stored format: /dist/{repoId}/vendor/package/version.zip
|
|
790
|
+
const match = url.match(/\/dist\/[^/]+\/([^/]+\/[^/]+)\/([^/]+)\.zip$/);
|
|
791
|
+
if (match) {
|
|
792
|
+
const [, packageName, version] = match;
|
|
793
|
+
// Extract base URL
|
|
794
|
+
const baseUrl = url.substring(0, url.indexOf('/dist/'));
|
|
795
|
+
return `${baseUrl}/dist/m/${packageName}/${version}.zip`;
|
|
796
|
+
}
|
|
797
|
+
// If pattern doesn't match, return as-is (fallback)
|
|
798
|
+
return url;
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Transform dist URLs in memory (lightweight, no D1 storage)
|
|
802
|
+
* Used for fast response before background storage
|
|
803
|
+
*
|
|
804
|
+
* NOTE: Composer 2 (p2) format expects versions as an ARRAY, not a dict keyed by version.
|
|
805
|
+
* See: https://packagist.org/apidoc
|
|
806
|
+
*/
|
|
807
|
+
function transformDistUrlsInMemory(packageData, repoId, proxyBaseUrl) {
|
|
808
|
+
if (!packageData.packages) {
|
|
809
|
+
return packageData;
|
|
810
|
+
}
|
|
811
|
+
const result = { packages: {} };
|
|
812
|
+
for (const [pkgName, versions] of Object.entries(packageData.packages)) {
|
|
813
|
+
// Composer 2 p2 format: versions must be an ARRAY of version objects
|
|
814
|
+
result.packages[pkgName] = [];
|
|
815
|
+
// Sanitize metadata to remove __unset values that break Composer
|
|
816
|
+
const sanitizedVersions = sanitizeMetadata(versions);
|
|
817
|
+
const normalizedVersions = normalizePackageVersions(sanitizedVersions);
|
|
818
|
+
for (const { version, metadata } of normalizedVersions) {
|
|
819
|
+
// Use existing reference or generate simple one (no expensive crypto)
|
|
820
|
+
const distReference = metadata.dist?.reference || `${pkgName.replace('/', '-')}-${version}`.substring(0, 40);
|
|
821
|
+
// Build transformed version
|
|
822
|
+
const versionData = {
|
|
823
|
+
...metadata,
|
|
824
|
+
name: pkgName,
|
|
825
|
+
version,
|
|
826
|
+
dist: {
|
|
827
|
+
...metadata.dist,
|
|
828
|
+
type: metadata.dist?.type || 'zip',
|
|
829
|
+
url: `${proxyBaseUrl}/dist/m/${pkgName}/${version}.zip`,
|
|
830
|
+
reference: distReference,
|
|
831
|
+
},
|
|
832
|
+
};
|
|
833
|
+
// Clean invalid source field if present
|
|
834
|
+
if (versionData.source === '__unset' ||
|
|
835
|
+
versionData.source === null ||
|
|
836
|
+
(typeof versionData.source !== 'object' || Array.isArray(versionData.source))) {
|
|
837
|
+
delete versionData.source;
|
|
838
|
+
}
|
|
839
|
+
// Push to array (Composer 2 p2 format)
|
|
840
|
+
result.packages[pkgName].push(versionData);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return result;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Normalize package versions to handle both array format (Packagist p2) and object format (traditional repos)
|
|
847
|
+
* Returns array of { version: string, metadata: any }
|
|
848
|
+
*/
|
|
849
|
+
function normalizePackageVersions(versions) {
|
|
850
|
+
if (Array.isArray(versions)) {
|
|
851
|
+
// Packagist p2 format: [{version: "3.9.0", ...}, {version: "3.8.1", ...}]
|
|
852
|
+
return versions.map((metadata) => ({
|
|
853
|
+
version: metadata.version || String(metadata),
|
|
854
|
+
metadata,
|
|
855
|
+
}));
|
|
856
|
+
}
|
|
857
|
+
else if (typeof versions === 'object' && versions !== null) {
|
|
858
|
+
// Traditional Composer repo format: {"3.9.0": {...}, "3.8.1": {...}}
|
|
859
|
+
return Object.entries(versions).map(([key, val]) => ({
|
|
860
|
+
version: val?.version || key,
|
|
861
|
+
metadata: val,
|
|
862
|
+
}));
|
|
863
|
+
}
|
|
864
|
+
return [];
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Transform package dist URLs to proxy URLs and store in database
|
|
868
|
+
* Waits for all database writes to complete before returning
|
|
869
|
+
* Returns transformed data along with storage success metrics
|
|
870
|
+
*/
|
|
871
|
+
export async function transformPackageDistUrls(packageData, repoId, proxyBaseUrl, db) {
|
|
872
|
+
if (!packageData.packages) {
|
|
873
|
+
return { transformed: packageData, storedCount: 0, errors: [] };
|
|
874
|
+
}
|
|
875
|
+
// NOTE: Composer 2 (p2) format expects versions as an ARRAY, not a dict keyed by version.
|
|
876
|
+
const transformed = { packages: {} };
|
|
877
|
+
const packagesToStore = [];
|
|
878
|
+
// Step 1: Transform URLs and collect package data
|
|
879
|
+
for (const [pkgName, versions] of Object.entries(packageData.packages)) {
|
|
880
|
+
// Composer 2 p2 format: versions must be an ARRAY of version objects
|
|
881
|
+
transformed.packages[pkgName] = [];
|
|
882
|
+
// Normalize versions to handle both array and object formats
|
|
883
|
+
// Normalize versions to handle both array and object formats
|
|
884
|
+
// Sanitize metadata to remove __unset values that break Composer
|
|
885
|
+
const sanitizedVersions = sanitizeMetadata(versions);
|
|
886
|
+
const normalizedVersions = normalizePackageVersions(sanitizedVersions);
|
|
887
|
+
for (const { version, metadata } of normalizedVersions) {
|
|
888
|
+
const proxyDistUrl = `${proxyBaseUrl}/dist/${repoId}/${pkgName}/${version}.zip`;
|
|
889
|
+
const sourceDistUrl = metadata.dist?.url || null;
|
|
890
|
+
// Use existing reference or generate simple one (no expensive crypto)
|
|
891
|
+
// Most Packagist packages already have dist.reference, so this is rarely needed
|
|
892
|
+
const distReference = metadata.dist?.reference || `${pkgName.replace('/', '-')}-${version}`.substring(0, 40);
|
|
893
|
+
// Store RAW metadata (complete upstream package version object)
|
|
894
|
+
// We'll generate clean responses from stored data, not transform on ingestion
|
|
895
|
+
// Only ensure name field is present for storage (if missing)
|
|
896
|
+
const rawMetadata = { ...metadata };
|
|
897
|
+
if (!rawMetadata.name) {
|
|
898
|
+
rawMetadata.name = pkgName;
|
|
899
|
+
}
|
|
900
|
+
// Push to array (Composer 2 p2 format)
|
|
901
|
+
transformed.packages[pkgName].push(rawMetadata);
|
|
902
|
+
packagesToStore.push({
|
|
903
|
+
pkgName,
|
|
904
|
+
version,
|
|
905
|
+
metadata: rawMetadata, // Store raw upstream metadata
|
|
906
|
+
proxyDistUrl,
|
|
907
|
+
sourceDistUrl,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
// Step 2: Batch store packages to reduce D1 operations
|
|
912
|
+
// Verify repository exists before storing packages
|
|
913
|
+
const [repoExists] = await db
|
|
914
|
+
.select()
|
|
915
|
+
.from(repositories)
|
|
916
|
+
.where(eq(repositories.id, repoId))
|
|
917
|
+
.limit(1);
|
|
918
|
+
if (!repoExists) {
|
|
919
|
+
const logger = getLogger();
|
|
920
|
+
logger.error('Repository not found - cannot store packages', { repoId });
|
|
921
|
+
return { transformed, storedCount: 0, errors: [`Repository ${repoId} not found`] };
|
|
922
|
+
}
|
|
923
|
+
if (packagesToStore.length === 0) {
|
|
924
|
+
return { transformed, storedCount: 0, errors: [] };
|
|
925
|
+
}
|
|
926
|
+
// Batch check existing packages - use optimized approach to avoid SQL variable limit
|
|
927
|
+
// Strategy: Query by package name only (single condition), then filter in memory
|
|
928
|
+
// This avoids the OR clause with many conditions that hits SQLite's variable limit
|
|
929
|
+
const packageKeys = packagesToStore.map(p => ({ name: p.pkgName, version: p.version }));
|
|
930
|
+
const existingMap = new Map();
|
|
931
|
+
// Get unique package names
|
|
932
|
+
const uniquePackageNames = [...new Set(packageKeys.map(k => k.name))];
|
|
933
|
+
// Query all versions for these packages in a single query (much more efficient)
|
|
934
|
+
// This uses a single IN clause instead of many OR conditions
|
|
935
|
+
if (uniquePackageNames.length > 0) {
|
|
936
|
+
// Process package names in chunks to avoid variable limit on IN clause
|
|
937
|
+
const nameChunkSize = 500; // IN clause can handle more items than OR clauses
|
|
938
|
+
for (let i = 0; i < uniquePackageNames.length; i += nameChunkSize) {
|
|
939
|
+
const nameChunk = uniquePackageNames.slice(i, i + nameChunkSize);
|
|
940
|
+
const allVersions = await db
|
|
941
|
+
.select()
|
|
942
|
+
.from(packages)
|
|
943
|
+
.where(inArray(packages.name, nameChunk));
|
|
944
|
+
// Add all results to map
|
|
945
|
+
for (const existing of allVersions) {
|
|
946
|
+
existingMap.set(`${existing.name}:${existing.version}`, existing);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const now = Math.floor(Date.now() / 1000);
|
|
951
|
+
const errors = [];
|
|
952
|
+
let storedCount = 0;
|
|
953
|
+
// Prepare batch insert data
|
|
954
|
+
const insertData = [];
|
|
955
|
+
for (const { pkgName, version, metadata, proxyDistUrl, sourceDistUrl } of packagesToStore) {
|
|
956
|
+
try {
|
|
957
|
+
const key = `${pkgName}:${version}`;
|
|
958
|
+
const existing = existingMap.get(key);
|
|
959
|
+
const releasedAt = metadata.time ? Math.floor(new Date(metadata.time).getTime() / 1000) : now;
|
|
960
|
+
// Use existing reference or generate simple one (no expensive crypto)
|
|
961
|
+
// Most Packagist packages already have dist.reference, so this is rarely needed
|
|
962
|
+
const distReference = metadata.dist?.reference || `${pkgName.replace('/', '-')}-${version}`.substring(0, 40);
|
|
963
|
+
// Clean metadata before storing - remove invalid source values
|
|
964
|
+
const cleanedMetadata = { ...metadata };
|
|
965
|
+
if (cleanedMetadata.source === '__unset' ||
|
|
966
|
+
cleanedMetadata.source === null ||
|
|
967
|
+
(typeof cleanedMetadata.source !== 'object' || Array.isArray(cleanedMetadata.source))) {
|
|
968
|
+
// Remove invalid source field
|
|
969
|
+
delete cleanedMetadata.source;
|
|
970
|
+
}
|
|
971
|
+
const packageData = {
|
|
972
|
+
id: existing?.id || nanoid(),
|
|
973
|
+
repo_id: repoId,
|
|
974
|
+
name: pkgName,
|
|
975
|
+
version: version,
|
|
976
|
+
dist_url: proxyDistUrl,
|
|
977
|
+
source_dist_url: sourceDistUrl,
|
|
978
|
+
dist_reference: distReference,
|
|
979
|
+
description: metadata.description || null,
|
|
980
|
+
license: metadata.license ? JSON.stringify(metadata.license) : null,
|
|
981
|
+
package_type: metadata.type || null,
|
|
982
|
+
homepage: metadata.homepage || null,
|
|
983
|
+
released_at: releasedAt,
|
|
984
|
+
readme_content: metadata.readme || null,
|
|
985
|
+
metadata: JSON.stringify(cleanedMetadata),
|
|
986
|
+
created_at: existing?.created_at || now,
|
|
987
|
+
};
|
|
988
|
+
insertData.push(packageData);
|
|
989
|
+
}
|
|
990
|
+
catch (error) {
|
|
991
|
+
const errorMsg = `${pkgName}@${version}: ${error instanceof Error ? error.message : String(error)}`;
|
|
992
|
+
errors.push(errorMsg);
|
|
993
|
+
const logger = getLogger();
|
|
994
|
+
logger.error('Error preparing package for batch insert', { pkgName, version, repoId }, error instanceof Error ? error : new Error(String(error)));
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
// Batch insert/update using onConflictDoUpdate
|
|
998
|
+
// Note: Drizzle's onConflictDoUpdate with batch inserts requires careful handling
|
|
999
|
+
// We'll process in smaller batches to ensure reliability
|
|
1000
|
+
if (insertData.length > 0) {
|
|
1001
|
+
try {
|
|
1002
|
+
// Process in chunks of 50 to balance performance and reliability
|
|
1003
|
+
const chunkSize = 50;
|
|
1004
|
+
for (let i = 0; i < insertData.length; i += chunkSize) {
|
|
1005
|
+
const chunk = insertData.slice(i, i + chunkSize);
|
|
1006
|
+
// Use individual upserts within chunk for proper conflict handling
|
|
1007
|
+
// This is still much better than one-by-one for all packages
|
|
1008
|
+
await Promise.allSettled(chunk.map(async (pkgData) => {
|
|
1009
|
+
try {
|
|
1010
|
+
await db
|
|
1011
|
+
.insert(packages)
|
|
1012
|
+
.values(pkgData)
|
|
1013
|
+
.onConflictDoUpdate({
|
|
1014
|
+
target: [packages.name, packages.version],
|
|
1015
|
+
set: {
|
|
1016
|
+
repo_id: pkgData.repo_id,
|
|
1017
|
+
dist_url: pkgData.dist_url,
|
|
1018
|
+
source_dist_url: pkgData.source_dist_url,
|
|
1019
|
+
dist_reference: pkgData.dist_reference,
|
|
1020
|
+
description: pkgData.description,
|
|
1021
|
+
license: pkgData.license,
|
|
1022
|
+
package_type: pkgData.package_type,
|
|
1023
|
+
homepage: pkgData.homepage,
|
|
1024
|
+
released_at: pkgData.released_at,
|
|
1025
|
+
metadata: pkgData.metadata,
|
|
1026
|
+
},
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
catch (error) {
|
|
1030
|
+
const logger = getLogger();
|
|
1031
|
+
logger.error('Error upserting package in batch', {
|
|
1032
|
+
packageName: pkgData.name,
|
|
1033
|
+
version: pkgData.version,
|
|
1034
|
+
repoId
|
|
1035
|
+
}, error instanceof Error ? error : new Error(String(error)));
|
|
1036
|
+
throw error;
|
|
1037
|
+
}
|
|
1038
|
+
}));
|
|
1039
|
+
}
|
|
1040
|
+
storedCount = insertData.length;
|
|
1041
|
+
}
|
|
1042
|
+
catch (error) {
|
|
1043
|
+
const logger = getLogger();
|
|
1044
|
+
logger.error('Error in batch insert', { repoId, packageCount: insertData.length }, error instanceof Error ? error : new Error(String(error)));
|
|
1045
|
+
// Fall back to individual inserts if batch fails
|
|
1046
|
+
const fallbackResults = await Promise.allSettled(packagesToStore.map(({ pkgName, version, metadata, proxyDistUrl, sourceDistUrl }) => storePackageInDB(db, pkgName, version, metadata, repoId, proxyDistUrl, sourceDistUrl)));
|
|
1047
|
+
storedCount = fallbackResults.filter(r => r.status === 'fulfilled').length;
|
|
1048
|
+
fallbackResults.forEach((result, index) => {
|
|
1049
|
+
if (result.status === 'rejected') {
|
|
1050
|
+
const { pkgName, version } = packagesToStore[index];
|
|
1051
|
+
errors.push(`${pkgName}@${version}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
return { transformed, storedCount, errors };
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Store package metadata in database (synchronous)
|
|
1060
|
+
*/
|
|
1061
|
+
export async function storeLazyPackageMetadata(db, repoId, packageName, packageData, proxyBaseUrl) {
|
|
1062
|
+
if (!packageData.packages?.[packageName]) {
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const versions = packageData.packages[packageName];
|
|
1066
|
+
// Normalize versions to handle both array and object formats
|
|
1067
|
+
const normalizedVersions = normalizePackageVersions(versions);
|
|
1068
|
+
for (const { version, metadata } of normalizedVersions) {
|
|
1069
|
+
const proxyDistUrl = `${proxyBaseUrl}/dist/${repoId}/${packageName}/${version}.zip`;
|
|
1070
|
+
const sourceDistUrl = metadata.dist?.url || null;
|
|
1071
|
+
await storePackageInDB(db, packageName, version, metadata, repoId, proxyDistUrl, sourceDistUrl);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Store a single package in database
|
|
1076
|
+
* Throws errors for caller to handle
|
|
1077
|
+
*/
|
|
1078
|
+
export async function storePackageInDB(db, packageName, version, metadata, repoId, proxyDistUrl, sourceDistUrl) {
|
|
1079
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1080
|
+
const releasedAt = metadata.time ? Math.floor(new Date(metadata.time).getTime() / 1000) : now;
|
|
1081
|
+
// Check if package already exists
|
|
1082
|
+
const [existing] = await db
|
|
1083
|
+
.select()
|
|
1084
|
+
.from(packages)
|
|
1085
|
+
.where(and(eq(packages.name, packageName), eq(packages.version, version)))
|
|
1086
|
+
.limit(1);
|
|
1087
|
+
// Use existing reference or generate simple one (no expensive crypto)
|
|
1088
|
+
// Most Packagist packages already have dist.reference, so this is rarely needed
|
|
1089
|
+
const distReference = metadata.dist?.reference || `${packageName.replace('/', '-')}-${version}`.substring(0, 40);
|
|
1090
|
+
// Clean metadata before storing - remove invalid source values
|
|
1091
|
+
const cleanedMetadata = { ...metadata };
|
|
1092
|
+
if (cleanedMetadata.source === '__unset' ||
|
|
1093
|
+
cleanedMetadata.source === null ||
|
|
1094
|
+
(typeof cleanedMetadata.source !== 'object' || Array.isArray(cleanedMetadata.source))) {
|
|
1095
|
+
// Remove invalid source field
|
|
1096
|
+
delete cleanedMetadata.source;
|
|
1097
|
+
}
|
|
1098
|
+
const packageData = {
|
|
1099
|
+
id: existing?.id || nanoid(),
|
|
1100
|
+
repo_id: repoId,
|
|
1101
|
+
name: packageName,
|
|
1102
|
+
version: version,
|
|
1103
|
+
dist_url: proxyDistUrl,
|
|
1104
|
+
source_dist_url: sourceDistUrl,
|
|
1105
|
+
dist_reference: distReference,
|
|
1106
|
+
description: metadata.description || null,
|
|
1107
|
+
license: metadata.license ? JSON.stringify(metadata.license) : null,
|
|
1108
|
+
package_type: metadata.type || null,
|
|
1109
|
+
homepage: metadata.homepage || null,
|
|
1110
|
+
released_at: releasedAt,
|
|
1111
|
+
readme_content: metadata.readme || null,
|
|
1112
|
+
metadata: JSON.stringify(cleanedMetadata), // Store cleaned upstream metadata as JSON
|
|
1113
|
+
created_at: existing?.created_at || now,
|
|
1114
|
+
};
|
|
1115
|
+
if (existing) {
|
|
1116
|
+
await db
|
|
1117
|
+
.update(packages)
|
|
1118
|
+
.set({
|
|
1119
|
+
repo_id: repoId, // Update repo_id in case it changed
|
|
1120
|
+
dist_url: proxyDistUrl,
|
|
1121
|
+
source_dist_url: sourceDistUrl,
|
|
1122
|
+
dist_reference: distReference,
|
|
1123
|
+
description: packageData.description,
|
|
1124
|
+
license: packageData.license,
|
|
1125
|
+
package_type: packageData.package_type,
|
|
1126
|
+
homepage: packageData.homepage,
|
|
1127
|
+
released_at: releasedAt,
|
|
1128
|
+
metadata: packageData.metadata, // Update metadata
|
|
1129
|
+
})
|
|
1130
|
+
.where(and(eq(packages.name, packageName), eq(packages.version, version)));
|
|
1131
|
+
}
|
|
1132
|
+
else {
|
|
1133
|
+
await db.insert(packages).values(packageData);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Sanitize package metadata to handle Packagist's minification artifacts
|
|
1138
|
+
* Specifically handles "__unset" string values which cause Composer to crash
|
|
1139
|
+
* when it expects an array or object
|
|
1140
|
+
*/
|
|
1141
|
+
function sanitizeMetadata(metadata) {
|
|
1142
|
+
if (!metadata || typeof metadata !== 'object') {
|
|
1143
|
+
return metadata;
|
|
1144
|
+
}
|
|
1145
|
+
// Handle array input (recurse)
|
|
1146
|
+
if (Array.isArray(metadata)) {
|
|
1147
|
+
return metadata.map(item => sanitizeMetadata(item));
|
|
1148
|
+
}
|
|
1149
|
+
const sanitized = {};
|
|
1150
|
+
for (const key of Object.keys(metadata)) {
|
|
1151
|
+
const value = metadata[key];
|
|
1152
|
+
// Handle "__unset" value
|
|
1153
|
+
if (value === '__unset') {
|
|
1154
|
+
// For fields that are expected to be arrays/objects, replace with empty array
|
|
1155
|
+
// This is safe for Composer's foreach loops and array checks
|
|
1156
|
+
if ([
|
|
1157
|
+
'require', 'require-dev', 'suggest', 'provide', 'replace', 'conflict',
|
|
1158
|
+
'autoload', 'autoload-dev', 'extra', 'bin', 'license', 'authors',
|
|
1159
|
+
'keywords', 'repositories', 'include-path'
|
|
1160
|
+
].includes(key)) {
|
|
1161
|
+
sanitized[key] = [];
|
|
1162
|
+
}
|
|
1163
|
+
else {
|
|
1164
|
+
// For other fields, just omit them (equivalent to unset)
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
else if (typeof value === 'object' && value !== null) {
|
|
1169
|
+
// Recurse into objects
|
|
1170
|
+
sanitized[key] = sanitizeMetadata(value);
|
|
1171
|
+
}
|
|
1172
|
+
else {
|
|
1173
|
+
// Copy primitive values as-is
|
|
1174
|
+
sanitized[key] = value;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
return sanitized;
|
|
1178
|
+
}
|
|
1179
|
+
//# sourceMappingURL=composer.js.map
|