@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.41

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 (312) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +2 -0
  6. package/client/dependency-paths.js +24 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +112 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +522 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-metadata.d.ts +14 -0
  17. package/common/bundle-metadata.js +226 -0
  18. package/common/bundle-pkg-of.d.ts +1 -0
  19. package/common/bundle-pkg-of.js +90 -0
  20. package/common/bundle-reasons.d.ts +2 -0
  21. package/common/bundle-reasons.js +21 -0
  22. package/common/bundle-source-links.d.ts +6 -0
  23. package/common/bundle-source-links.js +216 -0
  24. package/common/bundle-sourcemap.d.ts +10 -0
  25. package/common/bundle-sourcemap.js +100 -0
  26. package/common/bundle-sources.d.ts +7 -0
  27. package/common/bundle-sources.js +310 -0
  28. package/common/bundle-stats.js +41 -0
  29. package/common/bundle-tabs.js +1 -0
  30. package/common/code-language.js +65 -0
  31. package/common/default-scan-models.ts +31 -0
  32. package/common/dependency-reviews.d.ts +23 -0
  33. package/common/dependency-reviews.js +81 -0
  34. package/common/finding-duplicates.js +58 -0
  35. package/common/finding-filters.js +274 -0
  36. package/common/finding-id.js +47 -0
  37. package/common/github-issue-labels.d.ts +1 -0
  38. package/common/github-issue-labels.js +11 -0
  39. package/common/github-pr.ts +56 -0
  40. package/common/gzip.js +59 -0
  41. package/common/line-diff.d.ts +8 -0
  42. package/common/line-diff.js +56 -0
  43. package/common/linked-workspace-groups.js +63 -0
  44. package/common/managed/comments.ts +34 -0
  45. package/common/managed/npm-packages.d.ts +5 -0
  46. package/common/managed/npm-packages.js +34 -0
  47. package/common/managed/permissions.ts +31 -0
  48. package/common/managed/report-content.ts +72 -0
  49. package/common/managed/report-filter.ts +108 -0
  50. package/common/managed/repository-alias.ts +65 -0
  51. package/common/managed/roles.ts +28 -0
  52. package/common/managed/routes.d.ts +2 -0
  53. package/common/managed/routes.js +254 -0
  54. package/common/managed/scan-models.ts +6 -0
  55. package/common/managed/team-app.d.ts +2 -0
  56. package/common/managed/team-app.js +31 -0
  57. package/common/managed/triage.ts +83 -0
  58. package/common/managed/upload-seal.ts +126 -0
  59. package/common/pretty-print.d.ts +5 -0
  60. package/common/pretty-print.js +24 -0
  61. package/common/revalidation-conflicts.js +48 -0
  62. package/common/revalidation-input-groups.js +56 -0
  63. package/common/save-error-reason.ts +20 -7
  64. package/common/scan-server.ts +13 -0
  65. package/common/server-info.ts +35 -0
  66. package/common/utf8.d.ts +3 -0
  67. package/common/utf8.js +45 -0
  68. package/common/workspace-app.js +23 -0
  69. package/common/workspace-groups.js +176 -0
  70. package/out/brotli-fallback.js +3 -3
  71. package/out/chunk-2AFQKRAM.js +51 -0
  72. package/out/chunk-2KBF5TZ7.js +2 -0
  73. package/out/chunk-2XUEZHIP.js +28 -0
  74. package/out/chunk-35VE6CDH.js +2 -0
  75. package/out/chunk-3BZ7JERB.js +1 -0
  76. package/out/chunk-4F6JSF57.js +3 -0
  77. package/out/chunk-4XCDFGDP.js +10 -0
  78. package/out/chunk-C4Z6ZBXV.js +10 -0
  79. package/out/chunk-CMQA4DM6.js +1 -0
  80. package/out/chunk-CVVJAXVY.js +2 -0
  81. package/out/chunk-DIJJNTRH.js +10 -0
  82. package/out/chunk-GJ4IUCNS.js +1 -0
  83. package/out/chunk-GNXOKIGP.js +80 -0
  84. package/out/chunk-GZ4MS3TE.js +13 -0
  85. package/out/chunk-IU3CCINR.js +3 -0
  86. package/out/chunk-K3X5EJBQ.js +2 -0
  87. package/out/chunk-KEX2ATEW.js +2 -0
  88. package/out/chunk-KVEUXIEU.js +1 -0
  89. package/out/chunk-KYCNJVB7.js +1 -0
  90. package/out/chunk-NRPC74AM.js +1 -0
  91. package/out/chunk-OOJM4CTU.js +1 -0
  92. package/out/chunk-PNHBZGDD.js +1 -0
  93. package/out/chunk-QZ3A376W.js +1 -0
  94. package/out/chunk-R5O6FAW4.js +2 -0
  95. package/out/chunk-RFZQQXSG.js +17 -0
  96. package/out/chunk-RSHMVYNS.js +1 -0
  97. package/out/chunk-TUII34K2.js +1 -0
  98. package/out/chunk-TY4RZD3Z.js +226 -0
  99. package/out/chunk-TZ5KB2Y5.js +10 -0
  100. package/out/chunk-U6OPEXIA.js +1 -0
  101. package/out/chunk-UNTRGE26.js +2 -0
  102. package/out/chunk-UXNUAIIC.js +33 -0
  103. package/out/chunk-UYAXVRVL.js +1 -0
  104. package/out/chunk-UZAZM2SX.js +2 -0
  105. package/out/chunk-V3ME6FI5.js +2 -0
  106. package/out/chunk-V5O7M2YT.js +1 -0
  107. package/out/chunk-VIHGO77Y.js +1 -0
  108. package/out/chunk-VQJCXGIY.js +7 -0
  109. package/out/chunk-W4J3MGLB.js +1 -0
  110. package/out/chunk-WGG7KQUH.js +1 -0
  111. package/out/chunk-WZDTZE64.js +1 -0
  112. package/out/chunk-XL35ZV6R.js +1 -0
  113. package/out/chunk-XYFXXB2G.js +10 -0
  114. package/out/chunk-YVZH5SHP.js +1 -0
  115. package/out/chunk-ZBBOBQ43.js +1 -0
  116. package/out/chunk-ZMQQCGF6.js +2 -0
  117. package/out/client-managed-import.js +4 -0
  118. package/out/client-managed.js +37 -0
  119. package/out/client-sync.js +10 -13
  120. package/out/github-issue-authorized.html +3 -0
  121. package/out/github-issue-authorized.js +1 -0
  122. package/out/graph.js +17 -4
  123. package/out/index.html +90 -8
  124. package/out/prism.js +6 -2
  125. package/out/stasis.svg +20 -0
  126. package/out/terminal.js +365 -39
  127. package/out/view.css +1 -1
  128. package/out/view.js +83 -117
  129. package/package.json +298 -59
  130. package/risk/files/characters.js +196 -0
  131. package/risk/files/hosts.js +205 -0
  132. package/risk/files/readability.js +171 -0
  133. package/risk/files/runs-code.js +595 -0
  134. package/risk/files/script.js +114 -0
  135. package/risk/files/stamps.js +46 -0
  136. package/risk/index.d.ts +156 -0
  137. package/risk/index.js +7 -0
  138. package/risk/npm/assess.js +223 -0
  139. package/risk/npm/combined.js +65 -0
  140. package/risk/npm/compare.js +228 -0
  141. package/risk/npm/dependencies.js +153 -0
  142. package/risk/npm/diff.js +43 -0
  143. package/risk/npm/downloads.js +84 -0
  144. package/risk/npm/files.js +34 -0
  145. package/risk/npm/held.js +60 -0
  146. package/risk/npm/install-scripts.js +34 -0
  147. package/risk/npm/malware.js +36 -0
  148. package/risk/npm/manifest.js +65 -0
  149. package/risk/npm/new-dependency.js +30 -0
  150. package/risk/npm/old-version.js +108 -0
  151. package/risk/npm/registry.js +282 -0
  152. package/risk/npm/release.js +85 -0
  153. package/risk/npm/score.js +59 -0
  154. package/risk/npm/specs.js +124 -0
  155. package/risk/npm/tarball.js +113 -0
  156. package/server-common/database-config.ts +16 -0
  157. package/server-common/initialize.ts +18 -0
  158. package/server-common/npm-advisories.ts +101 -0
  159. package/{server → server-common}/origin.ts +5 -5
  160. package/server-common/reap.ts +48 -0
  161. package/server-common/scan-config.ts +19 -0
  162. package/server-common/standalone.ts +29 -0
  163. package/server-common/storage-crypto.ts +182 -0
  164. package/server-common/storage-log.ts +38 -0
  165. package/server-common/vercel-blob.ts +103 -0
  166. package/server-e2e/app.ts +486 -0
  167. package/{server → server-e2e}/auth.ts +5 -1
  168. package/{server → server-e2e}/bus-receiver.ts +2 -1
  169. package/{server → server-e2e}/cli.js +9 -4
  170. package/{server → server-e2e}/config.ts +56 -35
  171. package/{server → server-e2e}/db-neon.ts +2 -2
  172. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  173. package/{server → server-e2e}/db-stmt.ts +2 -2
  174. package/{server → server-e2e}/db.ts +7 -7
  175. package/{server → server-e2e}/http.ts +116 -18
  176. package/{server → server-e2e}/hub.ts +40 -9
  177. package/server-e2e/index.ts +17 -0
  178. package/server-e2e/lifecycle.ts +95 -0
  179. package/{server → server-e2e}/neon-driver.ts +2 -2
  180. package/{server → server-e2e}/npm-proxy.ts +12 -145
  181. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  182. package/{server → server-e2e}/objstore/blob.ts +1 -1
  183. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  184. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  185. package/{server → server-e2e}/objstore/init.ts +35 -18
  186. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  187. package/server-e2e/objstore/rest-deny.ts +28 -0
  188. package/server-e2e/objstore/rest-mint.ts +224 -0
  189. package/{server → server-e2e}/objstore/rest.ts +27 -26
  190. package/{server → server-e2e}/objstore/sign.ts +105 -0
  191. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  192. package/{server → server-e2e}/objstore/store.ts +7 -7
  193. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  194. package/{server → server-e2e}/pubsub.ts +12 -9
  195. package/{server → server-e2e}/sign.ts +4 -4
  196. package/{server → server-e2e}/sse-server.ts +102 -68
  197. package/{server → server-e2e}/sse-session.ts +28 -8
  198. package/{server → server-e2e}/static.ts +31 -21
  199. package/server-e2e/sync-handlers.ts +408 -0
  200. package/{server → server-e2e}/ws-server.ts +20 -9
  201. package/server-managed/activity.ts +245 -0
  202. package/server-managed/avatar-store.ts +12 -0
  203. package/server-managed/blob-store.ts +17 -0
  204. package/server-managed/brotli.ts +10 -0
  205. package/server-managed/bundle-advisories.ts +120 -0
  206. package/server-managed/bundle-advisory-inventory.ts +77 -0
  207. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  208. package/server-managed/bundle-build-diagnostics.js +23 -0
  209. package/server-managed/bundle-build-leases.ts +41 -0
  210. package/server-managed/bundle-build-worker.js +111 -0
  211. package/server-managed/bundle-build.ts +198 -0
  212. package/server-managed/bundle-cache.ts +199 -0
  213. package/server-managed/bundle-catalog.ts +29 -0
  214. package/server-managed/bundle-commits.ts +140 -0
  215. package/server-managed/bundle-store.ts +24 -0
  216. package/server-managed/bundle-summary-cache.ts +130 -0
  217. package/server-managed/bundle.ts +120 -0
  218. package/server-managed/cache-storage.ts +19 -0
  219. package/server-managed/cli.js +21 -0
  220. package/server-managed/combined.ts +46 -0
  221. package/server-managed/comments.ts +151 -0
  222. package/server-managed/config.ts +158 -0
  223. package/server-managed/content-access.ts +15 -0
  224. package/server-managed/crypto.ts +25 -0
  225. package/server-managed/db-methods.ts +1725 -0
  226. package/server-managed/db-neon.ts +367 -0
  227. package/server-managed/db-schema.ts +227 -0
  228. package/server-managed/db-table-names.ts +22 -0
  229. package/server-managed/db.ts +132 -0
  230. package/server-managed/dependency-reviews.ts +248 -0
  231. package/server-managed/github-app.ts +391 -0
  232. package/server-managed/github-errors.ts +68 -0
  233. package/server-managed/github-issue-links.ts +127 -0
  234. package/server-managed/github-issue-oauth.ts +47 -0
  235. package/server-managed/github-issues.ts +152 -0
  236. package/server-managed/github-metadata.ts +213 -0
  237. package/server-managed/github-oauth.ts +235 -0
  238. package/server-managed/github-pulls.ts +227 -0
  239. package/server-managed/http-response.ts +50 -0
  240. package/server-managed/http.ts +3472 -0
  241. package/server-managed/import-triage.ts +51 -0
  242. package/server-managed/index.ts +187 -0
  243. package/server-managed/kept.ts +28 -0
  244. package/server-managed/link-reports.ts +158 -0
  245. package/server-managed/locked-packages.ts +85 -0
  246. package/server-managed/lru.ts +30 -0
  247. package/server-managed/managed-issues.ts +75 -0
  248. package/server-managed/management-catalog.ts +45 -0
  249. package/server-managed/management.ts +99 -0
  250. package/server-managed/npm-insights.ts +440 -0
  251. package/server-managed/npm-kept.ts +65 -0
  252. package/server-managed/npm-loads.ts +167 -0
  253. package/server-managed/npm-manifest.ts +115 -0
  254. package/server-managed/npm-packages.ts +378 -0
  255. package/server-managed/npm-provenance.ts +123 -0
  256. package/server-managed/npm-risk.ts +177 -0
  257. package/server-managed/object-storage-disk.ts +148 -0
  258. package/server-managed/object-storage-vercel.ts +67 -0
  259. package/server-managed/object-storage.ts +74 -0
  260. package/server-managed/package-entry-points.ts +51 -0
  261. package/server-managed/pretty-print.ts +352 -0
  262. package/server-managed/public-workspace.ts +220 -0
  263. package/server-managed/rename-diff-worker.js +26 -0
  264. package/server-managed/rename-diff.ts +270 -0
  265. package/server-managed/repo-path.ts +21 -0
  266. package/server-managed/report-migration.ts +35 -0
  267. package/server-managed/report-query.ts +10 -0
  268. package/server-managed/report-response.ts +16 -0
  269. package/server-managed/report-sources.ts +178 -0
  270. package/server-managed/report-store.ts +47 -0
  271. package/server-managed/repository-aliases.ts +91 -0
  272. package/server-managed/repository-browser.ts +153 -0
  273. package/server-managed/repository-discovery.ts +82 -0
  274. package/server-managed/repository-policy.ts +27 -0
  275. package/server-managed/repository-risk.ts +420 -0
  276. package/server-managed/revisions.ts +35 -0
  277. package/server-managed/session.ts +92 -0
  278. package/server-managed/slugs.ts +38 -0
  279. package/server-managed/solidity-entry-points.ts +69 -0
  280. package/server-managed/sql-postgres.ts +31 -0
  281. package/server-managed/sql.ts +76 -0
  282. package/server-managed/static.ts +28 -0
  283. package/server-managed/storage-command.ts +16 -0
  284. package/server-managed/storage-db.ts +175 -0
  285. package/server-managed/storage-encryption.ts +279 -0
  286. package/server-managed/storage-maintenance.ts +256 -0
  287. package/server-managed/storage-payload.ts +103 -0
  288. package/server-managed/storage-stores.ts +85 -0
  289. package/server-managed/storage.ts +78 -0
  290. package/server-managed/team-app.ts +94 -0
  291. package/server-managed/team-catalog.ts +7 -0
  292. package/server-managed/team-feed.ts +173 -0
  293. package/server-managed/team-npm-scopes.ts +77 -0
  294. package/server-managed/team-reports.ts +228 -0
  295. package/server-managed/triage-response.ts +16 -0
  296. package/server-managed/type-only-worker.js +19 -0
  297. package/server-managed/type-only.ts +103 -0
  298. package/server-managed/uploads.ts +86 -0
  299. package/server-managed/upstream-cache.ts +215 -0
  300. package/server-managed/view-as.ts +58 -0
  301. package/server-managed/workspace-shares.ts +166 -0
  302. package/server.ts +50 -0
  303. package/server/index.ts +0 -477
  304. package/server/lifecycle.ts +0 -204
  305. package/server/sync-handlers.ts +0 -320
  306. /package/{LICENSE → LICENSE-MIT} +0 -0
  307. /package/{server → server-e2e}/config.example.json +0 -0
  308. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  309. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  310. /package/{server → server-e2e}/peer.ts +0 -0
  311. /package/{server → server-e2e}/util.ts +0 -0
  312. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,111 @@
1
+ import { parentPort, workerData } from 'node:worker_threads'
2
+ import { Buffer } from 'node:buffer'
3
+ import { posix } from 'node:path'
4
+ import { brotliCompressSync } from 'node:zlib'
5
+ import { buildGitHubBundle, setCacheDir } from '@exodus/stasis/vfs-bundle'
6
+ import { brotliOptions } from '@exodus/stasis-core/brotli'
7
+ import { HttpError, createClient } from '@preventive/upstream/github.js'
8
+ import { githubBundleFilename } from './bundle-build.ts'
9
+ import { bundleBuildDiagnostic } from './bundle-build-diagnostics.js'
10
+
11
+ const regularFile = entry => entry.type === 'blob' && ['100644', '100755'].includes(entry.mode)
12
+
13
+ async function projectDirectory(input, github, client) {
14
+ // Input validation permits either JS/TS entries or Solidity entries, never a mix.
15
+ const manifests = input.entries[0].endsWith('.sol') ? ['foundry.toml', 'soldeer.toml', 'soldeer.lock'] : ['package.json']
16
+ if (!input.directory) return ''
17
+ for (let directory = input.directory; ; directory = posix.dirname(directory).replace(/^\.$/u, '')) {
18
+ const entries = await client.listRepoDir({ repo: github, sha: input.commit, directory: directory || undefined })
19
+ if (entries.some(entry => manifests.includes(entry.path) && regularFile(entry))) return directory
20
+ if (!directory) return input.directory
21
+ }
22
+ }
23
+
24
+ // The name Stasis read from the package.json in the project directory we
25
+ // provide, where its module holds bundled files. JS builds only: a Solidity
26
+ // build records `solidity-bundle` for a package.json without name and version.
27
+ async function packageName(input, github, project, bundle, client) {
28
+ if (!project || input.entries[0].endsWith('.sol')) return null
29
+ const entries = await client.listRepoDir({ repo: github, sha: input.commit, directory: project })
30
+ if (!entries.some(entry => entry.path === 'package.json' && regularFile(entry))) return null
31
+ const name = bundle.modules.get(posix.relative(bundle.repo?.directory ?? '', project) || '.')?.name
32
+ return typeof name === 'string' ? name : null
33
+ }
34
+
35
+ // Prisma Client is what `prisma generate` writes, not what the repo holds: for
36
+ // a JS project whose package.json depends on a @prisma/ package, Stasis
37
+ // generates it before the scan (generate: ['prisma']).
38
+ async function prismaGenerate(input, github, project, client) {
39
+ if (input.entries[0].endsWith('.sol')) return []
40
+ const listing = await client.listRepoDir({ repo: github, sha: input.commit, directory: project || undefined })
41
+ if (!listing.some(entry => entry.path === 'package.json' && regularFile(entry))) return []
42
+ const text = await client.getRepoFile({ repo: github, path: posix.join(project, 'package.json'), ref: input.commit })
43
+ let manifest
44
+ try { manifest = JSON.parse(text) } catch { return [] }
45
+ const named = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
46
+ .flatMap(field => manifest?.[field] !== null && typeof manifest?.[field] === 'object' ? Object.keys(manifest[field]) : [])
47
+ return named.some(name => name.startsWith('@prisma/')) ? ['prisma'] : []
48
+ }
49
+
50
+ export async function buildStasisBundle({ input, github, token, maxBytes, scopes, cacheDir = null }, client = createClient({ token }), progress = () => {}) {
51
+ const allowed = path => scopes.some(scope => !scope || path === scope || path.startsWith(scope + '/'))
52
+ // Reuse these immutable listings when Stasis discovers the lockfile root.
53
+ const listings = new Map()
54
+ const buildClient = { ...client, listRepoDir(options) {
55
+ const key = JSON.stringify([options.repo, options.sha, options.directory ?? ''])
56
+ if (!listings.has(key)) listings.set(key, client.listRepoDir(options))
57
+ return listings.get(key)
58
+ } }
59
+ // Upstream's disk cache keeps the npm tarballs and version documents, and
60
+ // the repo's tree, in `cacheDir`; without one (Vercel), nothing is kept on
61
+ // disk. This fresh worker inherits no application credentials but NPM_TOKEN.
62
+ setCacheDir(cacheDir ?? false)
63
+ progress('build')
64
+ const project = await projectDirectory(input, github, buildClient)
65
+ if (!allowed(project)) throw new Error('build-scope')
66
+ const generate = await prismaGenerate(input, github, project, buildClient)
67
+ const { bundle } = await buildGitHubBundle({
68
+ github, sha: input.commit, directory: project || undefined,
69
+ entries: input.entries.map(entry => posix.relative(project || '.', entry)),
70
+ ...input.options, client: buildClient, ...(cacheDir === null && { cache: false }), ...(generate.length > 0 && { generate }),
71
+ })
72
+ const directory = bundle.repo?.directory ?? ''
73
+ progress('scope')
74
+ // Imports outside the selected package may widen the root. Never publish it under a
75
+ // narrower team grant: the storage location must cover the actual bundle.
76
+ if (!allowed(directory)) throw new Error('build-scope')
77
+ progress('serialize')
78
+ const serialized = bundle.serialize()
79
+ if (Buffer.byteLength(serialized) > 200 * 1024 * 1024) throw new Error('too-large')
80
+ progress('compress')
81
+ const bytes = brotliCompressSync(serialized, brotliOptions())
82
+ if (bytes.length > maxBytes) throw new Error('too-large')
83
+ const name = await packageName(input, github, project, bundle, buildClient)
84
+ return { bytes, directory, filename: githubBundleFilename(github, project, input.commit, name) }
85
+ }
86
+
87
+ // The code a failed build answers with. Never upstream bodies or paths outside
88
+ // the caller's grants: those stay in the diagnostic. A missing or ambiguous
89
+ // lockfile, or the named one missing, is the lockfile's.
90
+ export function buildErrorCode(error, message) {
91
+ if (['build-scope', 'too-large'].includes(message)) return message
92
+ if (error instanceof HttpError) return error.status === 429 ? 'github-rate-limited' : 'github-build-failed'
93
+ return /no packageManager given|lockfile installs|no (?:pnpm-lock\.yaml|yarn\.lock|package-lock\.json|soldeer\.lock) found in /u.test(message) ? 'build-lockfile' : 'build-failed'
94
+ }
95
+
96
+ if (parentPort && workerData?.type === 'managed-bundle-build') {
97
+ // Send diagnostics to the request's parent thread, which writes them before
98
+ // returning the HTTP response. Worker stdout can be lost on termination.
99
+ const send = message => {
100
+ // Node worker messages have no browser target origin.
101
+ // oxlint-disable-next-line unicorn/require-post-message-target-origin
102
+ parentPort.postMessage(message)
103
+ }
104
+ try {
105
+ send(await buildStasisBundle(workerData, undefined, stage => send({ type: 'progress', stage })))
106
+ } catch (error) {
107
+ const diagnostic = bundleBuildDiagnostic(error, workerData.token)
108
+ const code = buildErrorCode(error, diagnostic.message)
109
+ send({ error: code, status: code === 'too-large' ? 413 : code === 'build-scope' ? 403 : 422, diagnostic })
110
+ }
111
+ }
@@ -0,0 +1,198 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+ import { posix } from 'node:path'
4
+ import { Worker } from 'node:worker_threads'
5
+ import { BUNDLE_BUILD_TIMEOUT_MS, type BundleBuildLeaseStore } from './bundle-build-leases.ts'
6
+ import { type BundleBuildDiagnostic, type BundleBuildStage, bundleBuildDiagnostic } from './bundle-build-diagnostics.js'
7
+ import type { BundleBuildConditions } from './db-methods.ts'
8
+
9
+ export class BundleBuildError extends Error {
10
+ status: number
11
+ code: string
12
+ constructor(status: number, code: string) { super(code); this.status = status; this.code = code }
13
+ }
14
+
15
+ export interface BundleBuildInput {
16
+ repoId: number
17
+ commit: string
18
+ entries: string[]
19
+ directory: string
20
+ options: { conditions?: string[]; mainFields?: string[]; metro?: boolean; platforms?: string[]; jsx?: boolean; packageManager?: 'soldeer' }
21
+ // What the stored bundle records; null for Solidity, which takes no conditions.
22
+ conditions: BundleBuildConditions | null
23
+ }
24
+
25
+ function path(value: unknown): value is string {
26
+ return typeof value === 'string' && value.length > 0 && value.length <= 1024 && !/[\\\p{Cc}]/u.test(value)
27
+ && value.split('/').every(part => part !== '' && part !== '.' && part !== '..')
28
+ }
29
+
30
+ export function parseBundleBuild(value: unknown): BundleBuildInput {
31
+ const fail = (code = 'bad-request'): never => { throw new BundleBuildError(400, code) }
32
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) return fail()
33
+ const { repoId, commit, entries, conditions } = value as Record<string, unknown>
34
+ if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || repoId <= 0
35
+ || typeof commit !== 'string' || !/^[a-f\d]{40}$/u.test(commit)
36
+ || !Array.isArray(entries) || entries.length === 0 || entries.length > 100 || !entries.every(path)) return fail()
37
+ const selected = [...new Set(entries as string[])]
38
+ const scripts = selected.every(entry => /\.[cm]?[tj]sx?$/iu.test(entry))
39
+ if (!scripts && !selected.every(entry => entry.endsWith('.sol'))) return fail('unsupported-entries')
40
+ const parts = posix.dirname(selected[0]!).split('/').filter(part => part !== '.')
41
+ for (const entry of selected) {
42
+ const parent = posix.dirname(entry).split('/')
43
+ while (parts.some((part, i) => parent[i] !== part)) parts.pop()
44
+ }
45
+ // Stasis builds Solidity with Soldeer alone. Named, a JS lockfile beside or
46
+ // above the project no longer leaves it unable to tell which one installs it.
47
+ const options: BundleBuildInput['options'] = scripts ? {} : { packageManager: 'soldeer' }
48
+ let recorded: BundleBuildConditions | null = null
49
+ if (scripts) {
50
+ if (conditions == null || typeof conditions !== 'object' || Array.isArray(conditions)) return fail('bad-conditions')
51
+ const { preset, conditions: names, platforms } = conditions as Record<string, unknown>
52
+ if (typeof preset !== 'string' || !['node', 'browser', 'metro'].includes(preset) || !Array.isArray(names) || names.length > 16
53
+ || !names.every(name => typeof name === 'string' && name.length > 0 && name.length <= 64
54
+ && !/^\.|^\d+$|[\s,/\\\p{Cc}]/u.test(name) && !['default', 'import', 'require'].includes(name))) return fail('bad-conditions')
55
+ if (preset === 'metro') {
56
+ if (names.length !== 1 || names[0] !== 'react-native') return fail('metro-conditions')
57
+ if (!Array.isArray(platforms) || platforms.length === 0 || platforms.length > 2
58
+ || !platforms.every(platform => ['ios', 'android'].includes(platform))) return fail('bad-conditions')
59
+ // React Native's convention puts JSX in .js files, which Stasis parses as
60
+ // JSX only on request. TypeScript is left to Stasis to detect.
61
+ Object.assign(options, { metro: true, platforms: [...new Set(platforms)], jsx: true })
62
+ } else {
63
+ options.conditions = [...new Set(names)]
64
+ if (preset === 'browser') options.mainFields = ['browser', 'module', 'main']
65
+ }
66
+ recorded = { preset: preset as BundleBuildConditions['preset'], conditions: [...new Set(names as string[])],
67
+ platforms: options.platforms ?? [] }
68
+ }
69
+ return { repoId, commit, entries: selected, directory: parts.join('/'), options, conditions: recorded }
70
+ }
71
+
72
+ // A package name as a filename base: `@scope/name` as `scope-name`, or as
73
+ // `name` alone where it is `scope` or already starts with `scope-`. Null for
74
+ // a name npm could not publish by its length or leading dot.
75
+ function packageBase(name: string): string | null {
76
+ const match = name.length <= 214 ? /^(?:@([^/.][^/]*)\/)?([^/.][^/]*)$/u.exec(name) : null
77
+ if (!match) return null
78
+ const base = match[2]!, scope = match[1]
79
+ return scope === undefined || base === scope || base.startsWith(`${scope}-`) ? base : `${scope}-${base}`
80
+ }
81
+
82
+ // Same portable name and 255-character limit as Stasis's githubBundleFile
83
+ // (src/cmd/github-bundle.js), which is not a public package export, except
84
+ // that a usable `packageName` replaces both the repo and the directory.
85
+ export function githubBundleFilename(github: string, directory: string, commit: string, packageName: string | null = null): string {
86
+ const portable = (value: string) => value.replaceAll(/[^\w.-]/gu, '_')
87
+ const tail = `.${portable(commit.slice(0, 7))}.stasis.code.br`
88
+ const named = packageName === null ? null : packageBase(packageName)
89
+ if (named !== null) return `${portable(named)}${tail}`
90
+ const repo = portable(github.replace('/', '-'))
91
+ if (!directory) return `${repo}${tail}`
92
+ const room = 255 - `${repo}.${tail}`.length
93
+ let dir = portable(directory.replaceAll('/', '-'))
94
+ if (dir.length > room) dir = `${dir.slice(0, room - 9)}_${createHash('sha256').update(directory).digest('hex').slice(0, 8)}`
95
+ return `${repo}.${dir}${tail}`
96
+ }
97
+
98
+ export interface BuiltBundle { bytes: Uint8Array; directory: string; filename: string }
99
+ export interface BuildRequest {
100
+ input: BundleBuildInput; github: string; token: string | null; maxBytes: number; scopes: (string | null)[]
101
+ // Upstream's disk cache for what a build fetches (setCacheDir), or null for
102
+ // none, as on Vercel. Resolved here: the worker's env holds NPM_TOKEN alone.
103
+ cacheDir: string | null
104
+ }
105
+ // An extra per-process ceiling; admission must also hold the shared DB lease.
106
+ const active = new Set<string>()
107
+
108
+ export async function withBundleBuildLease<T>(db: BundleBuildLeaseStore, userId: string, signal: AbortSignal,
109
+ work: (signal: AbortSignal) => Promise<T>): Promise<T> {
110
+ signal.throwIfAborted()
111
+ const owner = randomUUID(), started = performance.now(), timeout = new AbortController()
112
+ const expired = () => timeout.abort(new BundleBuildError(504, 'build-timeout'))
113
+ const timer = setTimeout(expired, BUNDLE_BUILD_TIMEOUT_MS)
114
+ const combined = AbortSignal.any([signal, timeout.signal])
115
+ let claimed = false
116
+ try {
117
+ claimed = await db.claimBundleBuildLease(userId, owner)
118
+ if (!claimed) throw new BundleBuildError(429, 'build-busy')
119
+ // Account for a slow/lost claim response or a suspended invocation before
120
+ // starting work. The three-minute budget starts before lease acquisition.
121
+ if (performance.now() - started >= BUNDLE_BUILD_TIMEOUT_MS) expired()
122
+ combined.throwIfAborted()
123
+ return await work(combined)
124
+ } catch (error) {
125
+ if (timeout.signal.aborted && !signal.aborted) throw timeout.signal.reason
126
+ throw error
127
+ } finally {
128
+ clearTimeout(timer)
129
+ // buildRepositoryBundle waits for worker termination before settling.
130
+ // Unknown claim/release outcomes expire safely without starting more work.
131
+ if (claimed) await db.releaseBundleBuildLease(owner)
132
+ }
133
+ }
134
+
135
+ // Parsing repositories and compressing bundles stays off the HTTP event loop.
136
+ // Cap concurrent builds, terminate disconnected/timed-out work, and share no
137
+ // credentials or mutable Stasis state between requests.
138
+ export async function buildRepositoryBundle(userId: string, request: BuildRequest, signal: AbortSignal): Promise<BuiltBundle> {
139
+ if (active.has(userId) || active.size >= 2) throw new BundleBuildError(429, 'build-busy')
140
+ signal.throwIfAborted()
141
+ active.add(userId)
142
+ let worker: Worker | undefined
143
+ let workerUrl: URL | undefined
144
+ let stage: BundleBuildStage = 'worker-start'
145
+ const buildId = randomUUID(), started = performance.now()
146
+ const context = { buildId, repoId: request.input.repoId, github: request.github, commit: request.input.commit,
147
+ directory: request.input.directory, entryCount: request.input.entries.length, node: process.version }
148
+ const log = (event: string, details: Record<string, unknown> = {}) => {
149
+ const write = event === 'failed' ? console.error : console.info
150
+ write('managed-bundle-build:', JSON.stringify({ ...context, workerUrl: workerUrl?.href, event, stage, elapsedMs: Math.round(performance.now() - started), ...details }))
151
+ }
152
+ try {
153
+ workerUrl = new URL('./bundle-build-worker.js', import.meta.url)
154
+ log('started')
155
+ // Upstream reads NPM_TOKEN from the env for scoped packages, which npm
156
+ // answers with 404 when private and asked without it. Nothing else passes.
157
+ const npmToken = process.env['NPM_TOKEN']
158
+ const env = npmToken ? { NPM_TOKEN: npmToken } : {}
159
+ worker = new Worker(workerUrl, {
160
+ workerData: { ...request, type: 'managed-bundle-build' }, env, execArgv: [], resourceLimits: { maxOldGenerationSizeMb: 512 },
161
+ })
162
+ const running = worker
163
+ return await new Promise((resolve, reject) => {
164
+ let settled = false
165
+ const cleanup = () => { clearTimeout(timer); signal.removeEventListener('abort', abort) }
166
+ const fail = (status: number, code: string, details: Record<string, unknown> = {}) => {
167
+ if (settled) return
168
+ settled = true
169
+ cleanup()
170
+ log(code === 'build-cancelled' ? 'cancelled' : 'failed', { code, ...details })
171
+ reject(new BundleBuildError(status, code))
172
+ }
173
+ const timer = setTimeout(() => fail(504, 'build-timeout'), BUNDLE_BUILD_TIMEOUT_MS)
174
+ const abort = () => fail(signal.reason instanceof BundleBuildError ? signal.reason.status : 499,
175
+ signal.reason instanceof BundleBuildError ? signal.reason.code : 'build-cancelled')
176
+ signal.addEventListener('abort', abort, { once: true })
177
+ running.on('message', (message: BuiltBundle & { type?: string; stage?: BundleBuildStage; error?: string; status?: number; diagnostic?: BundleBuildDiagnostic }) => {
178
+ if (settled) return
179
+ if (message.type === 'progress' && message.stage) { stage = message.stage; log('progress'); return }
180
+ if (message.error) { fail(message.status ?? 422, message.error, { diagnostic: message.diagnostic }); return }
181
+ settled = true
182
+ cleanup()
183
+ log('completed', { byteSize: message.bytes.length })
184
+ resolve({ bytes: Buffer.from(message.bytes), directory: message.directory, filename: message.filename })
185
+ })
186
+ running.once('error', error => fail(422, 'build-failed', { diagnostic: bundleBuildDiagnostic(error, request.token) }))
187
+ running.once('exit', exitCode => fail(422, 'build-failed', { exitCode }))
188
+ if (signal.aborted) abort()
189
+ })
190
+ } catch (error) {
191
+ if (!(error instanceof BundleBuildError)) {
192
+ log('failed', { code: 'build-failed', diagnostic: bundleBuildDiagnostic(error, request.token) })
193
+ }
194
+ throw error
195
+ } finally {
196
+ try { await worker?.terminate() } finally { active.delete(userId) }
197
+ }
198
+ }
@@ -0,0 +1,199 @@
1
+ // Metadata is cached as Brotli. Contents use the stored Brotli bytes directly:
2
+ // unchanged Stasis uploads or sourcemaps compressed once at upload.
3
+ import { Buffer } from 'node:buffer'
4
+ import { brotliDecompress } from 'node:zlib'
5
+ import { promisify } from 'node:util'
6
+ import { bundleEdges } from '@preventive/sourcemap/edges.js'
7
+ import { BUNDLE_METADATA_VERSION, type BundleDetails, createBundleMetadata, createBundleSummary, parseBundleContents, parseBundleMetadata } from '../common/bundle-metadata.js'
8
+ import { bundleReasons } from '../common/bundle-reasons.js'
9
+ import { sourcemapEdges } from '../common/bundle-sourcemap.js'
10
+ import { type BundleAdvisoryInventory, bundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
11
+ import { decodeUtf8 } from '../common/utf8.js'
12
+ import type { OpenedBlob } from './blob-store.ts'
13
+ import type { BundleStore } from './bundle-store.ts'
14
+ import { encodeBrotli } from './brotli.ts'
15
+ import type { ManagedBundle, ManagedDb } from './db.ts'
16
+ import { CacheMissError } from './cache-storage.ts'
17
+ import { SUMMARY_FILENAME, createBundleSummaryCache } from './bundle-summary-cache.ts'
18
+
19
+ const decompress = promisify(brotliDecompress)
20
+ const MAX_DECODED_BYTES = 512 * 1024 * 1024
21
+ export const MAX_PACKAGE_INVENTORY_BYTES = 1024 * 1024
22
+ export type BundleCachePart = 'metadata' | 'contents'
23
+ export type BundleCacheRecord = Pick<ManagedBundle, 'id' | 'integrity' | 'filename' | 'kind' | 'byteSize'>
24
+ export interface BundleSummary { files: number; codeFiles: number; lines: number; commit?: string; stasisVersion?: number; versionedPackages?: number }
25
+
26
+ export async function readBundleDetails(record: BundleCacheRecord, store: BundleStore) {
27
+ const bytes = await store.get(record.id, record.kind)
28
+ if (!bytes) return null
29
+ const decoded = record.kind === 'stasis' || record.kind === 'sourcemap' ? await decompress(bytes, { maxOutputLength: MAX_DECODED_BYTES }) : bytes
30
+ if (decoded.length > MAX_DECODED_BYTES) throw new Error('Decoded bundle too large')
31
+ return parseBundleContents(decodeUtf8(decoded), { integrity: record.integrity, kind: record.kind, size: record.byteSize })
32
+ }
33
+
34
+ export interface BundleCacheStorage {
35
+ exists(id: string, file: string): Promise<boolean>
36
+ put(id: string, file: string, bytes: Buffer): Promise<void>
37
+ open(id: string, file: string): Promise<OpenedBlob>
38
+ // Remove all cached versions for this bundle, including legacy derivatives.
39
+ delete(id: string): Promise<void>
40
+ }
41
+
42
+ const filename = `v${BUNDLE_METADATA_VERSION}-metadata.json.br`
43
+ const packagesFilename = 'v6-advisory-inventory.json'
44
+
45
+ // All scopes share one bounded derivative. Never decode full bundle metadata
46
+ // on advisory requests, including when selecting a reason. Persist null when
47
+ // the combined inventory exceeds the budget, without disabling metadata.
48
+ function encodePackageInventory(details: BundleDetails): Buffer {
49
+ const parts: string[] = []
50
+ let size = 0
51
+ function append(part: string) {
52
+ size += Buffer.byteLength(part)
53
+ if (size > MAX_PACKAGE_INVENTORY_BYTES) return false
54
+ parts.push(part)
55
+ return true
56
+ }
57
+ function entries(rows: unknown[]) {
58
+ if (!append('[')) return false
59
+ let first = true
60
+ for (const row of rows) {
61
+ if (!append(`${first ? '' : ','}${JSON.stringify(row)}`)) return false
62
+ first = false
63
+ }
64
+ return append(']')
65
+ }
66
+ function inventory(paths: Set<string> | null) {
67
+ const { packages, skipped } = bundleAdvisoryInventory(details, paths)
68
+ return append('{"packages":') && entries(packages) && append(',"skipped":') && entries(skipped) && append('}')
69
+ }
70
+ append('{"all":')
71
+ if (!inventory(null) || !append(',"reasons":{')) return Buffer.from('null')
72
+ let first = true
73
+ for (const [reason, paths] of bundleReasons(details)) {
74
+ if (!append(`${first ? '' : ','}${JSON.stringify(reason)}:`) || !inventory(paths)) return Buffer.from('null')
75
+ first = false
76
+ }
77
+ if (!append('}}')) return Buffer.from('null')
78
+ return Buffer.from(parts.join(''))
79
+ }
80
+
81
+ // What a catalog sends of a bundle before it opens. A Stasis bundle's
82
+ // `versionedPackages` counts the name@version pairs its unscoped advisory
83
+ // audit covers, audited or skipped: with none, Advisories has nothing to show.
84
+ export function bundleSummary(details: BundleDetails, metadata?: Parameters<typeof createBundleSummary>[1]): BundleSummary {
85
+ const summary = createBundleSummary(details, metadata)
86
+ if (details.kind !== 'stasis') return summary
87
+ const { packages, skipped } = bundleAdvisoryInventory(details)
88
+ return { ...summary, versionedPackages: packages.reduce((count, pkg) => count + pkg.versions.length, 0) + skipped.length }
89
+ }
90
+
91
+ // A sourcemap's edges, read with the parser (edges.js) that the client goes
92
+ // without: every map's, where the client reads Metro's alone. Without it,
93
+ // the metadata keeps what the client would read. A map with no
94
+ // sourcesContent has no source to read them from.
95
+ function readSourcemapEdges(details: BundleDetails) {
96
+ if (details.kind !== 'sourcemap' || !details.map?.files.some(file => file.content !== null)) return
97
+ try { details.edges = sourcemapEdges(details.map, bundleEdges) }
98
+ catch (err) { console.warn('managed: sourcemap edges failed:', err) }
99
+ }
100
+
101
+ async function build(record: BundleCacheRecord, storage: BundleCacheStorage, db: ManagedDb, store: BundleStore) {
102
+ const details = await readBundleDetails(record, store)
103
+ if (!details) throw new Error('Bundle bytes unavailable')
104
+ readSourcemapEdges(details)
105
+ const metadata = { ...await createBundleMetadata(details), id: record.id, filename: record.filename }
106
+ const body = await encodeBrotli(Buffer.from(JSON.stringify(metadata)))
107
+ if (!(await db.getBundle(record.id))) throw new Error('Bundle deleted')
108
+ await storage.put(record.id, filename, body)
109
+ if (record.kind === 'stasis') await storage.put(record.id, packagesFilename, encodePackageInventory(details))
110
+ const summary = bundleSummary(details, metadata)
111
+ await storage.put(record.id, SUMMARY_FILENAME, Buffer.from(JSON.stringify(summary)))
112
+ // A different instance may have deleted the row while these writes ran.
113
+ // Reconcile after publishing so its cleanup cannot be undone by us.
114
+ if (!(await db.getBundle(record.id))) {
115
+ await storage.delete(record.id)
116
+ throw new Error('Bundle deleted')
117
+ }
118
+ return summary
119
+ }
120
+
121
+ export function createBundleCache(storage: BundleCacheStorage, db: ManagedDb, store: BundleStore) {
122
+ const pending = new Map<string, Promise<void>>()
123
+ // The database deduplicates by integrity, so each hash has one persistent
124
+ // bundle/cache directory, shared across teams and repeated uploads.
125
+ const summaries = createBundleSummaryCache(storage, async record => {
126
+ const details = await readBundleDetails(record, store)
127
+ if (!details) throw new Error('Bundle bytes unavailable')
128
+ return bundleSummary(details)
129
+ }, id => db.getBundle(id))
130
+ let queue = Promise.resolve()
131
+ async function ensure(record: BundleCacheRecord): Promise<void> {
132
+ const existing = pending.get(record.id)
133
+ if (existing) return existing
134
+ const job = (async () => {
135
+ if (await storage.exists(record.id, filename) && (record.kind !== 'stasis' || await storage.exists(record.id, packagesFilename))) return
136
+ const work = queue.then(async () => summaries.remember(record, await build(record, storage, db, store)))
137
+ queue = work.catch(() => {})
138
+ await work
139
+ })()
140
+ pending.set(record.id, job)
141
+ try { await job } finally { if (pending.get(record.id) === job) pending.delete(record.id) }
142
+ }
143
+ async function openCached(record: BundleCacheRecord, file: string) {
144
+ try { return await storage.open(record.id, file) }
145
+ catch (error) { if (!(error instanceof CacheMissError)) throw error }
146
+ await ensure(record)
147
+ return storage.open(record.id, file)
148
+ }
149
+ return {
150
+ prebuild: ensure,
151
+ summary: summaries.summary,
152
+ summaryStatus: summaries.summaryStatus,
153
+ backfillSummaries: summaries.backfill,
154
+ async open(record: ManagedBundle, part: BundleCachePart) {
155
+ if (part === 'contents') {
156
+ if (record.kind !== 'stasis' && record.kind !== 'sourcemap') throw new Error('Unsupported bundle')
157
+ const stored = await store.open(record.id, record.kind)
158
+ if (!stored) throw new Error('Bundle bytes unavailable')
159
+ return stored
160
+ }
161
+ return openCached(record, filename)
162
+ },
163
+ // A sourcemap's edges as its metadata keeps them, read with the parser
164
+ // once, for a derivative that reads the bundle itself (report previews).
165
+ async sourcemapEdges(record: ManagedBundle): Promise<BundleDetails['edges']> {
166
+ const cached = await openCached(record, filename)
167
+ const chunks: Buffer[] = []
168
+ try { for await (const chunk of cached.stream) chunks.push(Buffer.from(chunk)) }
169
+ finally { cached.stream.destroy() }
170
+ const decoded = await decompress(Buffer.concat(chunks), { maxOutputLength: MAX_DECODED_BYTES })
171
+ return parseBundleMetadata(JSON.parse(decodeUtf8(decoded)), record.integrity).edges
172
+ },
173
+ async advisoryInventory(record: ManagedBundle, reason = ''): Promise<BundleAdvisoryInventory | null | undefined> {
174
+ if (record.kind !== 'stasis') return { packages: [], skipped: [] }
175
+ const cached = await openCached(record, packagesFilename)
176
+ try {
177
+ if (cached.size != null && cached.size > MAX_PACKAGE_INVENTORY_BYTES) return null
178
+ const chunks: Buffer[] = []
179
+ let size = 0
180
+ for await (const chunk of cached.stream) {
181
+ size += chunk.length
182
+ if (size > MAX_PACKAGE_INVENTORY_BYTES) return null
183
+ chunks.push(Buffer.from(chunk))
184
+ }
185
+ const inventory = JSON.parse(decodeUtf8(Buffer.concat(chunks)))
186
+ if (inventory === null) return null
187
+ return reason ? (Object.hasOwn(inventory.reasons, reason) ? inventory.reasons[reason] : undefined) : inventory.all
188
+ } finally { cached.stream.destroy() }
189
+ },
190
+ async delete(id: string) {
191
+ // Call after deleting the row. Waiting prevents an in-flight builder
192
+ // from recreating its files after deletion has completed.
193
+ await pending.get(id)?.catch(() => {})
194
+ await summaries.forget(id)
195
+ await storage.delete(id)
196
+ },
197
+ }
198
+ }
199
+ export type BundleCache = ReturnType<typeof createBundleCache>
@@ -0,0 +1,29 @@
1
+ import type { BundleCache, BundleSummary } from './bundle-cache.ts'
2
+ import type { AdminBundle, UserTeamBundle } from './db.ts'
3
+
4
+ const SUMMARY_READ_CONCURRENCY = 8
5
+
6
+ // Read only existing summaries: cold/malformed bundles must never trigger
7
+ // parsing or wait for metadata builds before the catalog response is sent.
8
+ export async function bundleSummaries(bundles: readonly (AdminBundle | UserTeamBundle)[], cache?: BundleCache) {
9
+ const summaries = new Map<string, { summary: BundleSummary | null; summaryRetryAt: number | null }>()
10
+ if (cache) {
11
+ const unique = [...new Map(bundles.map(bundle => [bundle.integrity, bundle])).values()]
12
+ let next = 0
13
+ // Cold reads may each access remote storage. Bound those requests as well
14
+ // as the subsequent backfill, without dropping entries from large catalogs.
15
+ await Promise.all(Array.from({ length: Math.min(SUMMARY_READ_CONCURRENCY, unique.length) }, async () => {
16
+ while (next < unique.length) {
17
+ const bundle = unique[next++]!
18
+ summaries.set(bundle.integrity, await cache.summaryStatus(bundle).catch(() => ({ summary: null, summaryRetryAt: null })))
19
+ }
20
+ }))
21
+ }
22
+ return summaries
23
+ }
24
+
25
+ // Called after sending the response. Each cache permits one small batch at a
26
+ // time, deduplicated by content hash, and skips persisted retry-backoff markers.
27
+ export async function backfillBundleSummaries(bundles: readonly (AdminBundle | UserTeamBundle)[], cache?: BundleCache) {
28
+ await cache?.backfillSummaries([...new Map(bundles.map(bundle => [bundle.integrity, bundle])).values()])
29
+ }