@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.
Files changed (217) hide show
  1. package/dist/cache/index.d.ts +2 -0
  2. package/dist/cache/index.d.ts.map +1 -0
  3. package/dist/cache/index.js +2 -0
  4. package/dist/cache/index.js.map +1 -0
  5. package/dist/cache/memory-driver.d.ts +15 -0
  6. package/dist/cache/memory-driver.d.ts.map +1 -0
  7. package/dist/cache/memory-driver.js +56 -0
  8. package/dist/cache/memory-driver.js.map +1 -0
  9. package/dist/db/d1-driver.d.ts +3 -0
  10. package/dist/db/d1-driver.d.ts.map +1 -0
  11. package/dist/db/d1-driver.js +7 -0
  12. package/dist/db/d1-driver.js.map +1 -0
  13. package/dist/db/index.d.ts +5 -0
  14. package/dist/db/index.d.ts.map +1 -0
  15. package/dist/db/index.js +4 -0
  16. package/dist/db/index.js.map +1 -0
  17. package/dist/db/schema.d.ts +696 -0
  18. package/dist/db/schema.d.ts.map +1 -0
  19. package/dist/db/schema.js +99 -0
  20. package/dist/db/schema.js.map +1 -0
  21. package/dist/factory.d.ts +34 -0
  22. package/dist/factory.d.ts.map +1 -0
  23. package/dist/factory.js +121 -0
  24. package/dist/factory.js.map +1 -0
  25. package/dist/index.d.ts +16 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +17 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/jobs/index.d.ts +2 -0
  30. package/dist/jobs/index.d.ts.map +1 -0
  31. package/dist/jobs/index.js +7 -0
  32. package/dist/jobs/index.js.map +1 -0
  33. package/dist/jobs/processor.d.ts +49 -0
  34. package/dist/jobs/processor.d.ts.map +1 -0
  35. package/dist/jobs/processor.js +118 -0
  36. package/dist/jobs/processor.js.map +1 -0
  37. package/dist/middleware/auth.d.ts +52 -0
  38. package/dist/middleware/auth.d.ts.map +1 -0
  39. package/dist/middleware/auth.js +300 -0
  40. package/dist/middleware/auth.js.map +1 -0
  41. package/dist/middleware/composer-version.d.ts +7 -0
  42. package/dist/middleware/composer-version.d.ts.map +1 -0
  43. package/dist/middleware/composer-version.js +18 -0
  44. package/dist/middleware/composer-version.js.map +1 -0
  45. package/dist/middleware/error-handler.d.ts +7 -0
  46. package/dist/middleware/error-handler.d.ts.map +1 -0
  47. package/dist/middleware/error-handler.js +45 -0
  48. package/dist/middleware/error-handler.js.map +1 -0
  49. package/dist/middleware/index.d.ts +5 -0
  50. package/dist/middleware/index.d.ts.map +1 -0
  51. package/dist/middleware/index.js +6 -0
  52. package/dist/middleware/index.js.map +1 -0
  53. package/dist/middleware/request-id.d.ts +9 -0
  54. package/dist/middleware/request-id.d.ts.map +1 -0
  55. package/dist/middleware/request-id.js +36 -0
  56. package/dist/middleware/request-id.js.map +1 -0
  57. package/dist/ports.d.ts +32 -0
  58. package/dist/ports.d.ts.map +1 -0
  59. package/dist/ports.js +4 -0
  60. package/dist/ports.js.map +1 -0
  61. package/dist/queue/consumer.d.ts +18 -0
  62. package/dist/queue/consumer.d.ts.map +1 -0
  63. package/dist/queue/consumer.js +82 -0
  64. package/dist/queue/consumer.js.map +1 -0
  65. package/dist/queue/index.d.ts +2 -0
  66. package/dist/queue/index.d.ts.map +1 -0
  67. package/dist/queue/index.js +2 -0
  68. package/dist/queue/index.js.map +1 -0
  69. package/dist/queue/memory-driver.d.ts +13 -0
  70. package/dist/queue/memory-driver.d.ts.map +1 -0
  71. package/dist/queue/memory-driver.js +22 -0
  72. package/dist/queue/memory-driver.js.map +1 -0
  73. package/dist/queue/types.d.ts +19 -0
  74. package/dist/queue/types.d.ts.map +1 -0
  75. package/dist/queue/types.js +3 -0
  76. package/dist/queue/types.js.map +1 -0
  77. package/dist/routes/api/artifacts.d.ts +25 -0
  78. package/dist/routes/api/artifacts.d.ts.map +1 -0
  79. package/dist/routes/api/artifacts.js +57 -0
  80. package/dist/routes/api/artifacts.js.map +1 -0
  81. package/dist/routes/api/auth.d.ts +50 -0
  82. package/dist/routes/api/auth.d.ts.map +1 -0
  83. package/dist/routes/api/auth.js +268 -0
  84. package/dist/routes/api/auth.js.map +1 -0
  85. package/dist/routes/api/index.d.ts +9 -0
  86. package/dist/routes/api/index.d.ts.map +1 -0
  87. package/dist/routes/api/index.js +10 -0
  88. package/dist/routes/api/index.js.map +1 -0
  89. package/dist/routes/api/packages.d.ts +47 -0
  90. package/dist/routes/api/packages.d.ts.map +1 -0
  91. package/dist/routes/api/packages.js +671 -0
  92. package/dist/routes/api/packages.js.map +1 -0
  93. package/dist/routes/api/repositories.d.ts +56 -0
  94. package/dist/routes/api/repositories.d.ts.map +1 -0
  95. package/dist/routes/api/repositories.js +317 -0
  96. package/dist/routes/api/repositories.js.map +1 -0
  97. package/dist/routes/api/settings.d.ts +28 -0
  98. package/dist/routes/api/settings.d.ts.map +1 -0
  99. package/dist/routes/api/settings.js +81 -0
  100. package/dist/routes/api/settings.js.map +1 -0
  101. package/dist/routes/api/stats.d.ts +21 -0
  102. package/dist/routes/api/stats.d.ts.map +1 -0
  103. package/dist/routes/api/stats.js +52 -0
  104. package/dist/routes/api/stats.js.map +1 -0
  105. package/dist/routes/api/tokens.d.ts +39 -0
  106. package/dist/routes/api/tokens.d.ts.map +1 -0
  107. package/dist/routes/api/tokens.js +191 -0
  108. package/dist/routes/api/tokens.js.map +1 -0
  109. package/dist/routes/api/users.d.ts +5 -0
  110. package/dist/routes/api/users.d.ts.map +1 -0
  111. package/dist/routes/api/users.js +125 -0
  112. package/dist/routes/api/users.js.map +1 -0
  113. package/dist/routes/composer.d.ts +133 -0
  114. package/dist/routes/composer.d.ts.map +1 -0
  115. package/dist/routes/composer.js +1179 -0
  116. package/dist/routes/composer.js.map +1 -0
  117. package/dist/routes/dist.d.ts +32 -0
  118. package/dist/routes/dist.d.ts.map +1 -0
  119. package/dist/routes/dist.js +761 -0
  120. package/dist/routes/dist.js.map +1 -0
  121. package/dist/routes/health.d.ts +7 -0
  122. package/dist/routes/health.d.ts.map +1 -0
  123. package/dist/routes/health.js +22 -0
  124. package/dist/routes/health.js.map +1 -0
  125. package/dist/routes/index.d.ts +5 -0
  126. package/dist/routes/index.d.ts.map +1 -0
  127. package/dist/routes/index.js +6 -0
  128. package/dist/routes/index.js.map +1 -0
  129. package/dist/services/EmailService.d.ts +20 -0
  130. package/dist/services/EmailService.d.ts.map +1 -0
  131. package/dist/services/EmailService.js +27 -0
  132. package/dist/services/EmailService.js.map +1 -0
  133. package/dist/services/UserService.d.ts +27 -0
  134. package/dist/services/UserService.d.ts.map +1 -0
  135. package/dist/services/UserService.js +164 -0
  136. package/dist/services/UserService.js.map +1 -0
  137. package/dist/storage/driver.d.ts +65 -0
  138. package/dist/storage/driver.d.ts.map +1 -0
  139. package/dist/storage/driver.js +59 -0
  140. package/dist/storage/driver.js.map +1 -0
  141. package/dist/storage/index.d.ts +4 -0
  142. package/dist/storage/index.d.ts.map +1 -0
  143. package/dist/storage/index.js +5 -0
  144. package/dist/storage/index.js.map +1 -0
  145. package/dist/storage/r2-driver.d.ts +16 -0
  146. package/dist/storage/r2-driver.d.ts.map +1 -0
  147. package/dist/storage/r2-driver.js +28 -0
  148. package/dist/storage/r2-driver.js.map +1 -0
  149. package/dist/storage/s3-driver.d.ts +22 -0
  150. package/dist/storage/s3-driver.d.ts.map +1 -0
  151. package/dist/storage/s3-driver.js +66 -0
  152. package/dist/storage/s3-driver.js.map +1 -0
  153. package/dist/sync/github-sync.d.ts +15 -0
  154. package/dist/sync/github-sync.d.ts.map +1 -0
  155. package/dist/sync/github-sync.js +39 -0
  156. package/dist/sync/github-sync.js.map +1 -0
  157. package/dist/sync/index.d.ts +5 -0
  158. package/dist/sync/index.d.ts.map +1 -0
  159. package/dist/sync/index.js +6 -0
  160. package/dist/sync/index.js.map +1 -0
  161. package/dist/sync/repository-sync.d.ts +18 -0
  162. package/dist/sync/repository-sync.d.ts.map +1 -0
  163. package/dist/sync/repository-sync.js +214 -0
  164. package/dist/sync/repository-sync.js.map +1 -0
  165. package/dist/sync/strategies/composer-repo.d.ts +11 -0
  166. package/dist/sync/strategies/composer-repo.d.ts.map +1 -0
  167. package/dist/sync/strategies/composer-repo.js +269 -0
  168. package/dist/sync/strategies/composer-repo.js.map +1 -0
  169. package/dist/sync/strategies/github-api.d.ts +6 -0
  170. package/dist/sync/strategies/github-api.d.ts.map +1 -0
  171. package/dist/sync/strategies/github-api.js +137 -0
  172. package/dist/sync/strategies/github-api.js.map +1 -0
  173. package/dist/sync/strategies/github-packages.d.ts +7 -0
  174. package/dist/sync/strategies/github-packages.d.ts.map +1 -0
  175. package/dist/sync/strategies/github-packages.js +66 -0
  176. package/dist/sync/strategies/github-packages.js.map +1 -0
  177. package/dist/sync/strategies/index.d.ts +4 -0
  178. package/dist/sync/strategies/index.d.ts.map +1 -0
  179. package/dist/sync/strategies/index.js +5 -0
  180. package/dist/sync/strategies/index.js.map +1 -0
  181. package/dist/sync/types.d.ts +60 -0
  182. package/dist/sync/types.d.ts.map +1 -0
  183. package/dist/sync/types.js +3 -0
  184. package/dist/sync/types.js.map +1 -0
  185. package/dist/utils/analytics.d.ts +142 -0
  186. package/dist/utils/analytics.d.ts.map +1 -0
  187. package/dist/utils/analytics.js +229 -0
  188. package/dist/utils/analytics.js.map +1 -0
  189. package/dist/utils/download.d.ts +10 -0
  190. package/dist/utils/download.d.ts.map +1 -0
  191. package/dist/utils/download.js +34 -0
  192. package/dist/utils/download.js.map +1 -0
  193. package/dist/utils/encryption.d.ts +20 -0
  194. package/dist/utils/encryption.d.ts.map +1 -0
  195. package/dist/utils/encryption.js +76 -0
  196. package/dist/utils/encryption.js.map +1 -0
  197. package/dist/utils/index.d.ts +5 -0
  198. package/dist/utils/index.d.ts.map +1 -0
  199. package/dist/utils/index.js +6 -0
  200. package/dist/utils/index.js.map +1 -0
  201. package/dist/utils/logger.d.ts +78 -0
  202. package/dist/utils/logger.d.ts.map +1 -0
  203. package/dist/utils/logger.js +134 -0
  204. package/dist/utils/logger.js.map +1 -0
  205. package/dist/utils/upstream-fetch.d.ts +15 -0
  206. package/dist/utils/upstream-fetch.d.ts.map +1 -0
  207. package/dist/utils/upstream-fetch.js +108 -0
  208. package/dist/utils/upstream-fetch.js.map +1 -0
  209. package/dist/workflows/index.d.ts +3 -0
  210. package/dist/workflows/index.d.ts.map +1 -0
  211. package/dist/workflows/index.js +8 -0
  212. package/dist/workflows/index.js.map +1 -0
  213. package/dist/workflows/package-storage.d.ts +47 -0
  214. package/dist/workflows/package-storage.d.ts.map +1 -0
  215. package/dist/workflows/package-storage.js +136 -0
  216. package/dist/workflows/package-storage.js.map +1 -0
  217. 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