@preventive/triage 1.0.0-alpha.45 → 1.0.0-alpha.46

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 (79) hide show
  1. package/client/linked-findings.js +3 -0
  2. package/common/bundle-metadata.d.ts +1 -0
  3. package/common/bundle-metadata.js +1 -1
  4. package/common/bundle-sources.js +5 -1
  5. package/common/line-diff.js +5 -4
  6. package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
  7. package/out/{chunk-AFLZH7WG.js → chunk-4UFAMJAX.js} +1 -1
  8. package/out/{chunk-HMAJDVXN.js → chunk-7XRR4OFU.js} +1 -1
  9. package/out/{chunk-Q6MVYGYV.js → chunk-BOIXMUU5.js} +1 -1
  10. package/out/{chunk-XVBEOGOS.js → chunk-CC4OM6TZ.js} +2 -2
  11. package/out/chunk-G24A42V6.js +2 -0
  12. package/out/{chunk-WI7Z5A2T.js → chunk-JKGQ3O7O.js} +1 -1
  13. package/out/chunk-LE5ZWLG4.js +235 -0
  14. package/out/chunk-MSION3G4.js +36 -0
  15. package/out/{chunk-6HNXNIQY.js → chunk-S6GIXWQW.js} +1 -1
  16. package/out/chunk-V3KWNNMY.js +1 -0
  17. package/out/client-managed-import.js +1 -1
  18. package/out/client-managed.js +25 -25
  19. package/out/compare.js +13 -13
  20. package/out/dialogs.js +40 -40
  21. package/out/graph.js +1 -1
  22. package/out/index.html +6 -6
  23. package/out/scan.js +2 -2
  24. package/out/terminal.js +1 -1
  25. package/out/view.css +1 -1
  26. package/out/view.js +15 -15
  27. package/package.json +3 -1
  28. package/risk/actions/findings.js +14 -9
  29. package/risk/actions/scripts/shell.js +5 -1
  30. package/risk/index.d.ts +5 -2
  31. package/server-e2e/app.ts +5 -4
  32. package/server-e2e/http.ts +5 -2
  33. package/server-managed/bundle-cache.ts +7 -1
  34. package/server-managed/combined.ts +3 -2
  35. package/server-managed/commit-lockfiles.ts +21 -11
  36. package/server-managed/config.ts +6 -0
  37. package/server-managed/db-methods.ts +37 -9
  38. package/server-managed/db-neon.ts +61 -21
  39. package/server-managed/db-schema.ts +4 -0
  40. package/server-managed/db.ts +2 -1
  41. package/server-managed/dependency-reviews.ts +13 -3
  42. package/server-managed/github-app.ts +12 -6
  43. package/server-managed/github-issue-links.ts +11 -5
  44. package/server-managed/github-pulls.ts +2 -2
  45. package/server-managed/github-requests.ts +39 -0
  46. package/server-managed/http-response.ts +10 -2
  47. package/server-managed/http.ts +195 -111
  48. package/server-managed/index.ts +8 -7
  49. package/server-managed/kept-briefly.ts +37 -0
  50. package/server-managed/npm-delta.ts +11 -6
  51. package/server-managed/npm-kept.ts +32 -2
  52. package/server-managed/npm-loads.ts +100 -30
  53. package/server-managed/npm-packages.ts +89 -3
  54. package/server-managed/npm-risk.ts +4 -1
  55. package/server-managed/pretty-print.ts +67 -23
  56. package/server-managed/pull-dependencies.ts +29 -9
  57. package/server-managed/pull-dependency-reviews.ts +8 -8
  58. package/server-managed/queue.ts +16 -0
  59. package/server-managed/rename-diff.ts +22 -13
  60. package/server-managed/report-sources.ts +1 -1
  61. package/server-managed/report-store.ts +42 -9
  62. package/server-managed/repository-actions.ts +15 -6
  63. package/server-managed/repository-browser.ts +119 -31
  64. package/server-managed/repository-dependency-reviews.ts +41 -19
  65. package/server-managed/repository-history.ts +24 -8
  66. package/server-managed/repository-pulse.ts +8 -1
  67. package/server-managed/repository-risk-audit.ts +13 -0
  68. package/server-managed/repository-risk-read.ts +11 -6
  69. package/server-managed/repository-risk.ts +27 -20
  70. package/server-managed/session.ts +18 -4
  71. package/server-managed/sql.ts +5 -2
  72. package/server-managed/storage.ts +4 -2
  73. package/server-managed/team-reports.ts +24 -12
  74. package/server-managed/upstream-cache.ts +12 -0
  75. package/server.ts +14 -5
  76. package/out/chunk-5DHL5NG3.js +0 -2
  77. package/out/chunk-NV7ES4EW.js +0 -1
  78. package/out/chunk-PTGDYLNS.js +0 -36
  79. package/out/chunk-QRD6GIG5.js +0 -229
@@ -22,7 +22,8 @@ import { setCacheDir } from '@preventive/upstream/npm.js'
22
22
  const REAP_INTERVAL_MS = 3_600_000
23
23
  const REAP_RETRY_MS = 60_000
24
24
 
25
- type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown' | 'reviewedDependencies'>>
25
+ // `ui: false` serves only the API: `out/` is never read.
26
+ type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown' | 'reviewedDependencies'>> & { ui?: boolean }
26
27
 
27
28
  export async function createManagedApp(config: ManagedConfig, options: ManagedAppOptions = {}) {
28
29
  // Audits keep upstream's records on disk where the config has a cache
@@ -41,7 +42,7 @@ export async function createManagedApp(config: ManagedConfig, options: ManagedAp
41
42
  return await initializeApp(rollback => assembleManagedApp(config, assembled, rollback, waitUntil))
42
43
  }
43
44
 
44
- async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
45
+ async function assembleManagedApp(config: ManagedConfig, { ui = true, ...options }: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
45
46
  const storage = await openManagedStorage(config)
46
47
  const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage } = storage
47
48
  rollback.defer(() => db.close())
@@ -55,12 +56,12 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
55
56
  p.finally(() => inFlight.delete(p)).catch(() => {})
56
57
  }
57
58
 
58
- const serveStatic = loadManagedStatic(fileURLToPath(new URL('../out/', import.meta.url)), {
59
+ const serveStatic = ui ? loadManagedStatic(fileURLToPath(new URL('../out/', import.meta.url)), {
59
60
  indexOnly: options.next != null, scanServer: options.serverInfo?.deepviewScanServer ?? null,
60
- })
61
+ }) : () => false
61
62
  const routeRequest = createManagedRequestHandler({
62
63
  ...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, prettyCache, npmCacheStorage, originGate, serveStatic,
63
- ...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}),
64
+ ...(storage.uploadStore ? { uploadStore: storage.uploadStore } : {}), ...(waitUntil ? { waitUntil } : {}),
64
65
  isShuttingDown, track,
65
66
  })
66
67
 
@@ -137,8 +138,8 @@ export function logManagedStartup(config: ManagedConfig, port: number, mode = 'm
137
138
  ].join('\n'))
138
139
  }
139
140
 
140
- export async function init(config = loadManagedConfig()): Promise<Server> {
141
- const app = await createManagedApp(config)
141
+ export async function init(config = loadManagedConfig(), options: Pick<ManagedAppOptions, 'ui'> = {}): Promise<Server> {
142
+ const app = await createManagedApp(config, options)
142
143
  const server = createServer(withReap(app.handleRequest, { managed: app.reap }, { isShuttingDown: app.isShuttingDown }))
143
144
  let disposal: Promise<void> | undefined
144
145
  function dispose(): Promise<void> {
@@ -0,0 +1,37 @@
1
+ // What was lately worked out, kept a little while in memory for whoever asks
2
+ // for it next, by key: each for `keptMs` from when it was kept, the least
3
+ // lately read going first past `maxEntries` of them or `maxBytes` between
4
+ // them, and one that alone takes more than `maxBytes` not kept at all.
5
+ // Whoever reads one is the caller's to check, as they would one worked out.
6
+ export function keptBriefly<T>({ keptMs, maxBytes, maxEntries = Infinity }: { keptMs: number; maxBytes: number; maxEntries?: number }) {
7
+ const held = new Map<string, { value: T; bytes: number; timer: ReturnType<typeof setTimeout> }>()
8
+ let heldBytes = 0
9
+ const forget = (key: string) => {
10
+ const entry = held.get(key)
11
+ if (!entry) return
12
+ clearTimeout(entry.timer)
13
+ held.delete(key)
14
+ heldBytes -= entry.bytes
15
+ }
16
+ return {
17
+ get(key: string): T | undefined {
18
+ const entry = held.get(key)
19
+ if (!entry) return undefined
20
+ held.delete(key)
21
+ held.set(key, entry)
22
+ return entry.value
23
+ },
24
+ keep(key: string, value: T, bytes: number) {
25
+ if (bytes > maxBytes) return
26
+ forget(key)
27
+ for (const old of held.keys()) {
28
+ if (held.size < maxEntries && heldBytes + bytes <= maxBytes) break
29
+ forget(old)
30
+ }
31
+ const timer = setTimeout(() => { forget(key) }, keptMs)
32
+ timer.unref()
33
+ held.set(key, { value, bytes, timer })
34
+ heldBytes += bytes
35
+ },
36
+ }
37
+ }
@@ -10,11 +10,12 @@
10
10
  // leaving those out; and whether a binary file, which they count none of,
11
11
  // was added, removed or changed (`binary`), one moved as it was not. Worked
12
12
  // out in a worker, off the event loop, as many at once per process as
13
- // MAX_ACTIVE, their files read only once their turn has come, those waiting
14
- // past MAX_WAITING refused, each waiting a minute at most and counting in a
15
- // minute at most; one asked again meanwhile sharing the first's; and kept by
16
- // both sides' integrities, in this instance and beyond it (`kept`), what a
17
- // version holds never changing. Access is the caller's to check.
13
+ // MAX_ACTIVE, both sides' files read together only once their turn has come,
14
+ // those waiting past MAX_WAITING refused, each waiting a minute at most and
15
+ // counting in a minute at most; one asked again meanwhile sharing the
16
+ // first's; and kept by both sides' integrities, in this instance and beyond
17
+ // it (`kept`), what a version holds never changing. Access is the caller's
18
+ // to check.
18
19
  import { Worker } from 'node:worker_threads'
19
20
  import { createHash } from 'node:crypto'
20
21
  import type { KeptStore } from './npm-kept.ts'
@@ -160,7 +161,11 @@ async function worked(key: string, base: Side | null, other: Side, kept: KeptSto
160
161
  await turn(Date.now() + DELTA_TIMEOUT_MS)
161
162
  let binary, told
162
163
  try {
163
- const [olds, news] = [texts(base ? await base.files() : null), texts(await other.files())]
164
+ // Its turn held till both sides are read, or have failed.
165
+ const [baseRead, otherRead] = await Promise.allSettled([base ? base.files() : Promise.resolve(null), other.files()])
166
+ if (baseRead.status === 'rejected') throw baseRead.reason
167
+ if (otherRead.status === 'rejected') throw otherRead.reason
168
+ const [olds, news] = [texts(baseRead.value), texts(otherRead.value)]
164
169
  if (olds.length + news.length > MAX_TEXT_LENGTH) throw new NpmPackageError(413, 'package-too-large')
165
170
  binary = binaryChanged(olds.binaries, news.binaries)
166
171
  told = await counted(olds.read, news.read, Date.now() + DELTA_TIMEOUT_MS)
@@ -4,7 +4,8 @@
4
4
  // instance: public versions' documents, of the fields read of them
5
5
  // (npm-manifest.ts npmVersionFields), for a day (readNpmVersion asks npm again
6
6
  // then, as a version can be deprecated, its package made private, or itself
7
- // removed); and, for scans, each package's risk line, for as long as a scan
7
+ // removed), and when each was published, for a month (npmPublishTimeStore);
8
+ // and, for scans, each package's risk line, for as long as a scan
8
9
  // keeps it, and what a scan read of GitHub, for a day, and of npm's
9
10
  // advisories, for an hour (repository-risk.ts); and what the code viewer read
10
11
  // of a repository's GitHub, its issues, pull requests and agents' work, for a
@@ -18,7 +19,7 @@
18
19
  // nothing.
19
20
  import { createHash } from 'node:crypto'
20
21
  import { plainObject } from './npm-manifest.ts'
21
- import { type NpmVersionStore, VERSION_KEPT_MS } from './npm-packages.ts'
22
+ import { type NpmPublishTimeStore, type NpmVersionStore, VERSION_KEPT_MS } from './npm-packages.ts'
22
23
  import type { UpstreamCacheStore } from './upstream-cache.ts'
23
24
 
24
25
  // A value kept by key, as JSON, `at` when it was.
@@ -26,6 +27,9 @@ export interface KeptStore<T> {
26
27
  read(key: string): Promise<{ at: number; value: T } | null>
27
28
  // Those of `keys` kept, by key, in one read of the database.
28
29
  readMany(keys: string[]): Promise<Map<string, { at: number; value: T }>>
30
+ // Those of `first` kept; where none is, those of `then`, in one read of the
31
+ // database (`first` whether they are `first`'s).
32
+ readFirst(first: string[], then: string[]): Promise<{ first: boolean; found: Map<string, { at: number; value: T }> }>
29
33
  write(key: string, value: T, at: number): Promise<void>
30
34
  // Removes what is kept by `key`.
31
35
  remove(key: string): Promise<void>
@@ -66,6 +70,13 @@ function keptStore<T>(db: UpstreamCacheStore, type: string, keptMs: number, debu
66
70
  return new Map(keys.flatMap(key => { const kept = valueOf(rows.get(keyOf(key))); return kept ? [[key, kept] as const] : [] }))
67
71
  } catch (error) { warn('read', error); return new Map() }
68
72
  },
73
+ async readFirst(first, then) {
74
+ try {
75
+ const { first: answered, entries } = await db.getFirstUpstreamCacheEntries(first.map(keyOf), then.map(keyOf))
76
+ const asked = answered ? first : then
77
+ return { first: answered, found: new Map(asked.flatMap(key => { const kept = valueOf(entries.get(keyOf(key))); return kept ? [[key, kept] as const] : [] })) }
78
+ } catch (error) { warn('read', error); return { first: false, found: new Map() } }
79
+ },
69
80
  async write(key, value, at) {
70
81
  try { await db.setUpstreamCacheEntry(keyOf(key), JSON.stringify({ at, value }), at) } catch (error) { warn('write', error) }
71
82
  await prune(at)
@@ -83,10 +94,29 @@ export function npmVersionStore(db: UpstreamCacheStore, debug = false): NpmVersi
83
94
  const hit = await kept.read(`${name}@${version}`)
84
95
  return hit && plainObject(hit.value) ? { at: hit.at, json: hit.value } : null
85
96
  },
97
+ async readMany(versions) {
98
+ const hits = await kept.readMany(versions.map(({ name, version }) => `${name}@${version}`))
99
+ return new Map([...hits].flatMap(([key, hit]) => plainObject(hit.value) ? [[key, { at: hit.at, json: hit.value }] as const] : []))
100
+ },
86
101
  write: (name, version, json) => kept.write(`${name}@${version}`, json, Date.now()),
87
102
  }
88
103
  }
89
104
 
105
+ // When public versions were published (npm-packages.ts npmPublishTime), which
106
+ // never changes once they are: kept a month, and kept again by the next view
107
+ // of the version past it.
108
+ const PUBLISH_TIME_KEPT_MS = 30 * 24 * 60 * 60_000
109
+ export function npmPublishTimeStore(db: UpstreamCacheStore, debug = false): NpmPublishTimeStore {
110
+ const kept = keptStore<string>(db, 'npm/publish-times', PUBLISH_TIME_KEPT_MS, debug)
111
+ return {
112
+ async read(name, version) {
113
+ const hit = await kept.read(`${name}@${version}`)
114
+ return typeof hit?.value === 'string' ? hit.value : null
115
+ },
116
+ write: (name, version, at) => kept.write(`${name}@${version}`, at, Date.now()),
117
+ }
118
+ }
119
+
90
120
  // Risk lines, each kept `keptMs`, for good where Infinity, a scan telling one
91
121
  // past RISK_KEPT_MS as stale (repository-risk.ts).
92
122
  export const riskLineStore = <T>(db: UpstreamCacheStore, keptMs: number, debug = false): KeptStore<T> => keptStore<T>(db, 'repository-risk/lines', keptMs, debug)
@@ -1,12 +1,14 @@
1
1
  // Loading a published version for the npm package viewer (npm-packages.ts):
2
2
  // its tarball, kept on disk as bundle builds keep theirs, else read from the
3
- // registry, and the viewer's response built from it, each shared by the
4
- // readers asking for it meanwhile and held to a few at once per process.
3
+ // registry, its files unpacked from it, and the viewer's response built from
4
+ // them, each shared by the readers asking for it meanwhile and held to a few
5
+ // versions at once per process; its files kept a little while after.
5
6
  import { Buffer } from 'node:buffer'
6
7
  import { homedir } from 'node:os'
7
8
  import { join, resolve } from 'node:path'
8
9
  import { CompressionError, decompress } from '@preventive/archive/compression.js'
9
10
  import { encodeBrotli } from './brotli.ts'
11
+ import { keptBriefly } from './kept-briefly.ts'
10
12
  import { MAX_NPM_PACKAGE_BYTES, MAX_NPM_PACKAGE_FILES, NPM_REGISTRY, NpmPackageError, type NpmPackageFile, type NpmVersionDocument, askUpstream,
11
13
  npmFileRows, npmPublishTime, npmToken, readNpmTar } from './npm-packages.ts'
12
14
  import { keepFile, readKeptFile, upstreamCachePath } from './upstream-cache.ts'
@@ -16,20 +18,30 @@ import { keepFile, readKeptFile, upstreamCachePath } from './upstream-cache.ts'
16
18
  // arrives.
17
19
  export const MAX_TAR_BYTES = 96 * 1024 * 1024
18
20
  const TARBALL_TIMEOUT_MS = 120_000
19
- // Loads in flight per process; each can hold a tarball, its tar, its files
20
- // and their encoded JSON.
21
+ // Loads in flight per process, those of one version (its tarball, its files,
22
+ // its response) as one, as they read its tarball and unpack it once between
23
+ // them; each can hold a tarball, its tar, its files and their encoded JSON.
21
24
  const MAX_ACTIVE_LOADS = 4
25
+ // How long a public version's files are kept once unpacked, and what they
26
+ // may take between them, the least lately read going first past it:
27
+ // Compare's requests for a pair of versions, and the Code tab's, come one
28
+ // after another, each loading the same files. A private version's are never
29
+ // kept, as nothing else of it is past its readers.
30
+ const UNPACKED_KEPT_MS = 60_000
31
+ const MAX_UNPACKED_BYTES = 128 * 1024 * 1024
22
32
 
23
33
  // A load's result, shared by its readers, and a reader's release of it.
24
34
  export interface NpmLoad<T> { result: Promise<T>; release: () => void }
25
35
 
26
- interface Load { job: Promise<unknown>; readers: number; settled: boolean }
36
+ interface Load { job: Promise<unknown>; readers: number; settled: boolean; slot: string }
27
37
 
28
38
  // Loads by what they read; readers asking for the same share one. A load
29
39
  // keeps its place among the active ones until its work is done and each
30
40
  // reader has released it, as a reader does once its response is written or
31
- // abandoned, so what responses still hold counts too.
41
+ // abandoned, so what responses still hold counts too. Places by the slot
42
+ // their loads share, with how many hold each.
32
43
  const loads = new Map<string, Load>()
44
+ const slots = new Map<string, number>()
33
45
 
34
46
  const refused = <T>(status: number, code: string): NpmLoad<T> => {
35
47
  const result = Promise.reject(new NpmPackageError(status, code))
@@ -37,26 +49,30 @@ const refused = <T>(status: number, code: string): NpmLoad<T> => {
37
49
  return { result, release() {} }
38
50
  }
39
51
 
40
- function shared<T>(key: string, doc: NpmVersionDocument, work: () => Promise<T>): NpmLoad<T> {
52
+ // What names a version's tarball, and so its files: its integrity and URL,
53
+ // and whether it is read with the token.
54
+ const tarballKey = (doc: NpmVersionDocument) => `${doc.dist.integrity} ${doc.dist.tarball} ${doc.private}`
55
+
56
+ function shared<T>(what: string, doc: NpmVersionDocument, work: () => Promise<T>): NpmLoad<T> {
41
57
  const { unpackedSize, fileCount } = doc.dist
42
58
  if ((unpackedSize ?? 0) > MAX_NPM_PACKAGE_BYTES || (fileCount ?? 0) > MAX_NPM_PACKAGE_FILES) return refused(413, 'package-too-large')
43
- return sharedLoad(key, work, 'npm-busy')
59
+ const slot = tarballKey(doc)
60
+ return sharedLoad(`${what} ${slot}`, work, 'npm-busy', slot)
44
61
  }
45
62
 
46
63
  // A load among the active ones, by `key`, refused as `busy` past them: a
47
64
  // repository directory's (repository-code.ts) as a package's, each holding
48
- // alike a tarball, its tar, its files and their encoded JSON.
49
- export function sharedLoad<T>(key: string, work: () => Promise<T>, busy: string): NpmLoad<T> {
65
+ // alike a tarball, its tar, its files and their encoded JSON. Loads of one
66
+ // `slot` take one place between them.
67
+ export function sharedLoad<T>(key: string, work: () => Promise<T>, busy: string, slot = key): NpmLoad<T> {
50
68
  let load = loads.get(key)
51
69
  if (!load) {
52
- if (loads.size >= MAX_ACTIVE_LOADS) return refused(429, busy)
53
- const created: Load = { job: work(), readers: 0, settled: false }
54
- const settle = () => {
55
- created.settled = true
56
- if (created.readers === 0 && loads.get(key) === created) loads.delete(key)
57
- }
58
- created.job.then(settle, settle)
70
+ if (!slots.has(slot) && slots.size >= MAX_ACTIVE_LOADS) return refused(429, busy)
71
+ const created: Load = { job: work(), readers: 0, settled: false, slot }
72
+ const finish = () => { settle(key, created) }
73
+ created.job.then(finish, finish)
59
74
  loads.set(key, created)
75
+ slots.set(slot, (slots.get(slot) ?? 0) + 1)
60
76
  load = created
61
77
  }
62
78
  const held = load
@@ -67,11 +83,27 @@ export function sharedLoad<T>(key: string, work: () => Promise<T>, busy: string)
67
83
  release() {
68
84
  if (released) return
69
85
  released = true
70
- if (--held.readers === 0 && held.settled && loads.get(key) === held) loads.delete(key)
86
+ held.readers--
87
+ done(key, held)
71
88
  },
72
89
  }
73
90
  }
74
91
 
92
+ function settle(key: string, load: Load) {
93
+ load.settled = true
94
+ done(key, load)
95
+ }
96
+
97
+ // A load gone from the active ones, and its place with it, once its work is
98
+ // done and its readers have released it.
99
+ function done(key: string, load: Load) {
100
+ if (load.readers !== 0 || !load.settled || loads.get(key) !== load) return
101
+ loads.delete(key)
102
+ const holding = slots.get(load.slot)! - 1
103
+ if (holding === 0) slots.delete(load.slot)
104
+ else slots.set(load.slot, holding)
105
+ }
106
+
75
107
  // The sha512 an integrity names, in base64, or null where it names none.
76
108
  const sha512Of = (integrity: string) => /(?:^|\s)sha512-([\d+/A-Za-z]{86}==)(?=\s|$)/u.exec(integrity)?.[1] ?? null
77
109
  // Hashed off the event loop: a tarball runs to tens of MiB.
@@ -129,6 +161,44 @@ async function readTarball(doc: NpmVersionDocument): Promise<Uint8Array> {
129
161
  return bytes
130
162
  }
131
163
 
164
+ // Tarballs being read, and versions' files being unpacked, by what names
165
+ // them (tarballKey): readers asking for one meanwhile share it, its bytes
166
+ // those each one's own document names.
167
+ const reading = new Map<string, Promise<Uint8Array>>()
168
+ const unpacking = new Map<string, Promise<Unpacked>>()
169
+
170
+ function sharedRead<T>(pending: Map<string, Promise<T>>, key: string, read: () => Promise<T>): Promise<T> {
171
+ let job = pending.get(key)
172
+ if (!job) {
173
+ const started = read().finally(() => { if (pending.get(key) === started) pending.delete(key) })
174
+ pending.set(key, job = started)
175
+ }
176
+ return job
177
+ }
178
+
179
+ const sharedTarball = (doc: NpmVersionDocument) => sharedRead(reading, tarballKey(doc), () => readTarball(doc))
180
+
181
+ // A version's files, and the size of the tarball they came in.
182
+ interface Unpacked { files: NpmPackageFile[]; tarballSize: number }
183
+
184
+ // Public versions' files lately unpacked, by tarballKey.
185
+ const unpacked = keptBriefly<Unpacked>({ keptMs: UNPACKED_KEPT_MS, maxBytes: MAX_UNPACKED_BYTES })
186
+
187
+ // The version's files, kept from a load of them a little while since, else
188
+ // unpacked from its tarball (and kept, a public version's): bytes its
189
+ // reader's own document names, as its tarball's are.
190
+ function unpackedOf(doc: NpmVersionDocument): Promise<Unpacked> {
191
+ const key = tarballKey(doc)
192
+ const kept = unpacked.get(key)
193
+ if (kept) return Promise.resolve(kept)
194
+ return sharedRead(unpacking, key, async () => {
195
+ const tarball = await sharedTarball(doc)
196
+ const value = { files: await readTarballFiles(tarball), tarballSize: tarball.byteLength }
197
+ if (!doc.private) unpacked.keep(key, value, value.files.reduce((sum, file) => sum + file.path.length + file.bytes.byteLength, 0))
198
+ return value
199
+ })
200
+ }
201
+
132
202
  // A gzipped tarball's files, its top directory left out, as npm extracts
133
203
  // a package's: a repository tree's as GitHub archives it, too, its symlinks
134
204
  // let climb as readNpmTar's `symlinksAbove` says.
@@ -144,31 +214,31 @@ export async function readTarballFiles(tarball: Uint8Array, options: { symlinksA
144
214
 
145
215
  // The version's tarball, for download.
146
216
  export function loadNpmTarball(doc: NpmVersionDocument): NpmLoad<Uint8Array> {
147
- return shared(`tarball ${doc.dist.integrity} ${doc.dist.tarball}`, doc, () => readTarball(doc))
217
+ return shared('tarball', doc, () => sharedTarball(doc))
148
218
  }
149
219
 
150
220
  // The version's files, for one of them to be pretty-printed (pretty-print.ts).
151
221
  export function loadNpmFiles(doc: NpmVersionDocument): NpmLoad<NpmPackageFile[]> {
152
- return shared(`files ${doc.dist.integrity} ${doc.dist.tarball}`, doc, async () => readTarballFiles(await readTarball(doc)))
222
+ return shared('files', doc, async () => (await unpackedOf(doc)).files)
153
223
  }
154
224
 
155
225
  // The version as the viewer reads it, as brotli-encoded JSON: `{ name,
156
226
  // version, private, integrity, tarballSize, published, manifest, files,
157
227
  // known }`, its files as npmFileRows gives them, and with them, as its files
158
- // are encoded, when it was published where that is kept (npmPublishTime,
159
- // else null) and what `known` gives: what is kept about its package
228
+ // are encoded, when it was published where that is kept (`published`, asked
229
+ // then, else null) and what `known` gives: what is kept about its package
160
230
  // (npm-insights.ts npmKnown), which the viewer need not then ask for. Encoding
161
231
  // is where a load holds the most, so it takes its place among the active
162
232
  // loads until its body is done, and readers asking meanwhile share that body,
163
- // the first one's manifest and `known` in it; nothing is kept once its readers
164
- // are done.
165
- export function loadNpmPackageBody(doc: NpmVersionDocument, known: () => object = () => ({})): NpmLoad<Buffer> {
166
- return shared(`package ${doc.dist.integrity} ${doc.dist.tarball} ${doc.private}`, doc, async () => {
167
- const tarball = await readTarball(doc)
168
- const files = await readTarballFiles(tarball)
233
+ // the first one's manifest, `published` and `known` in it; the body is not
234
+ // kept once its readers are done, its files a little while (unpackedOf).
235
+ export function loadNpmPackageBody(doc: NpmVersionDocument, known: () => object = () => ({}),
236
+ published: () => Promise<string | null> = npmPublishTime(doc).read): NpmLoad<Buffer> {
237
+ return shared('package', doc, async () => {
238
+ const { files, tarballSize } = await unpackedOf(doc)
169
239
  return encodeBrotli(Buffer.from(JSON.stringify({
170
- name: doc.name, version: doc.version, private: doc.private, integrity: doc.dist.integrity, tarballSize: tarball.byteLength,
171
- published: npmPublishTime(doc.name, doc.version), manifest: doc.manifest, files: npmFileRows(files), known: known(),
240
+ name: doc.name, version: doc.version, private: doc.private, integrity: doc.dist.integrity, tarballSize,
241
+ published: await published(), manifest: doc.manifest, files: npmFileRows(files), known: known(),
172
242
  })))
173
243
  })
174
244
  }
@@ -153,14 +153,60 @@ async function publicWholeDocument(name: string, url: string, { limit, signal }:
153
153
  return json
154
154
  }
155
155
 
156
+ // Whole documents being asked of npm as anyone asks, by URL and limit:
157
+ // readers asking for one meanwhile share it, as opening a version asks for
158
+ // its package's for its versions, its advisories and its risk at once; each
159
+ // gives up on it alone once its own signal aborts, and it is given up on once
160
+ // they all have. None is asked with the token. Read alike by every reader,
161
+ // none changing it.
162
+ const wholeAsks = new Map<string, { asked: Promise<Record<string, unknown> | null>; controller: AbortController; readers: number }>()
163
+
164
+ function sharedWholeDocument(name: string, url: string, { limit, signal }: { limit: number; signal: AbortSignal }): Promise<Record<string, unknown> | null> {
165
+ if (signal.aborted) return Promise.reject(signal.reason)
166
+ const key = `${url} ${limit}`
167
+ let ask = wholeAsks.get(key)
168
+ if (!ask) {
169
+ const controller = new AbortController()
170
+ const created = { asked: publicWholeDocument(name, url, { limit, signal: controller.signal }), controller, readers: 0 }
171
+ const settle = () => { if (wholeAsks.get(key) === created) wholeAsks.delete(key) }
172
+ created.asked.then(settle, settle)
173
+ wholeAsks.set(key, ask = created)
174
+ }
175
+ const held = ask
176
+ held.readers++
177
+ return new Promise((answer, fail) => {
178
+ let left = false
179
+ const leave = () => {
180
+ if (left) return false
181
+ left = true
182
+ held.readers--
183
+ signal.removeEventListener('abort', onAbort)
184
+ return true
185
+ }
186
+ const onAbort = () => {
187
+ if (!leave()) return
188
+ if (held.readers === 0) {
189
+ if (wholeAsks.get(key) === held) wholeAsks.delete(key)
190
+ held.controller.abort(signal.reason)
191
+ }
192
+ fail(signal.reason)
193
+ }
194
+ const answered = (json: Record<string, unknown> | null) => { if (leave()) answer(json) }
195
+ const failed = (err: unknown) => { if (leave()) fail(err) }
196
+ signal.addEventListener('abort', onAbort, { once: true })
197
+ held.asked.then(answered, failed)
198
+ })
199
+ }
200
+
156
201
  // Asked anonymously first, unless its caller knows npm has none for anyone
157
202
  // (`anonymous` false): a reader without private access stops there. One
158
203
  // with it asks again with the token where npmTokenReads says so, and the
159
204
  // answer is private. A whole document (`whole`) anyone may read is kept
160
- // (publicWholeDocument).
205
+ // (publicWholeDocument), and asked once for those asking at once
206
+ // (sharedWholeDocument).
161
207
  async function readDocument(name: string, url: string, privileged: boolean,
162
208
  { whole = false, anonymous = true, ...options }: { accept: string; limit: number; signal: AbortSignal; whole?: boolean; anonymous?: boolean }) {
163
- const open = anonymous ? whole ? await publicWholeDocument(name, url, options) : await registryDocument(url, { ...options, token: null }) : null
209
+ const open = anonymous ? whole ? await sharedWholeDocument(name, url, options) : await registryDocument(url, { ...options, token: null }) : null
164
210
  if (open) return { json: open, private: false }
165
211
  if (!npmTokenReads(name, privileged)) return null
166
212
  const json = await registryDocument(url, { ...options, token: npmToken() })
@@ -203,6 +249,8 @@ function versionDocument(name: string, json: Record<string, unknown>, isPrivate:
203
249
  // (npm-manifest.ts npmVersionFields), as the database keeps them (npm-kept.ts).
204
250
  export interface NpmVersionStore {
205
251
  read(name: string, version: string): Promise<{ at: number; json: Record<string, unknown> } | null>
252
+ // Those of `versions` kept, by `name@version`, in one read.
253
+ readMany?(versions: { name: string; version: string }[]): Promise<Map<string, { at: number; json: Record<string, unknown> }>>
206
254
  write(name: string, version: string, json: Record<string, unknown>): Promise<void>
207
255
  }
208
256
 
@@ -228,6 +276,20 @@ export function readNpmVersion(name: string, spec: string, privileged: boolean,
228
276
  })
229
277
  }
230
278
 
279
+ // `store`, where it reads many at once, with the exact ones of `versions`
280
+ // read from it in one read, for readNpmVersion to read each of those from as
281
+ // it asks: a version and the one it is compared with, read together.
282
+ export function readTogether(store: NpmVersionStore, versions: { name: string; version: string }[]): NpmVersionStore {
283
+ const exact = versions.filter(({ version }) => isExactVersion(version))
284
+ if (!store.readMany || exact.length < 2) return store
285
+ const keys = new Set(exact.map(({ name, version }) => `${name}@${version}`))
286
+ const kept = store.readMany(exact)
287
+ return { ...store, async read(name, version) {
288
+ const key = `${name}@${version}`
289
+ return keys.has(key) ? (await kept).get(key) ?? null : store.read(name, version)
290
+ } }
291
+ }
292
+
231
293
  // The package's dist-tags and versions, newest published first, from its
232
294
  // abbreviated document; with `times`, from its whole one, which alone has
233
295
  // when each was published, `times` by version (ISO dates), and `created`,
@@ -277,7 +339,31 @@ const isPublishTime = (value: string | undefined) => typeof value === 'string' &
277
339
  const PUBLISH_TIMES = 100_000
278
340
  const publishTimes = new Map<string, string>()
279
341
 
280
- export const npmPublishTime = (name: string, version: string): string | null => publishTimes.get(`${name}@${version}`) ?? null
342
+ // Where public versions' publish times are kept beyond the instance, as the
343
+ // database keeps them (npm-kept.ts npmPublishTimeStore).
344
+ export interface NpmPublishTimeStore {
345
+ read(name: string, version: string): Promise<string | null>
346
+ write(name: string, version: string, at: string): Promise<void>
347
+ }
348
+
349
+ // When `doc` was published: as noted here, else as `store` keeps a public
350
+ // version's, so that another instance, or this one restarted, has it before
351
+ // it reads the whole document again. The store is read once, at once, beside
352
+ // whatever its caller reads meanwhile, a failure read as none; `read` looks
353
+ // here again as it is asked, as the whole document read meanwhile may have
354
+ // noted it by then, and `keep` keeps there the time noted here where it read
355
+ // none.
356
+ export function npmPublishTime(doc: Pick<NpmVersionDocument, 'name' | 'version' | 'private'>, store?: NpmPublishTimeStore) {
357
+ const noted = () => publishTimes.get(`${doc.name}@${doc.version}`) ?? null
358
+ const kept = store && !doc.private ? store.read(doc.name, doc.version).catch(() => null) : Promise.resolve(null)
359
+ return {
360
+ read: async (): Promise<string | null> => noted() ?? await kept ?? noted(),
361
+ async keep() {
362
+ const at = noted()
363
+ if (at !== null && store && !doc.private && await kept === null) await store.write(doc.name, doc.version, at)
364
+ },
365
+ }
366
+ }
281
367
 
282
368
  export interface NpmPackageFile { path: string; bytes: Uint8Array }
283
369
 
@@ -35,6 +35,7 @@ import { buffer } from 'node:stream/consumers'
35
35
  import type { CacheStore } from '@preventive/upstream/npm.js'
36
36
  import { DEFAULT_MINIMUM_RELEASE_AGE, type NpmAssessment, assessNpm, npmReadyDownload, npmReadyDownloads } from '../risk/index.js'
37
37
  import { CacheMissError, type CacheStorage } from './cache-storage.ts'
38
+ import { npmVersionStore } from './npm-kept.ts'
38
39
  import { loadNpmTarball } from './npm-loads.ts'
39
40
  import { NpmPackageError, type NpmVersionDocument, npmTokenReads, readNpmPackument, readNpmVersion } from './npm-packages.ts'
40
41
  import { npmPackageScope } from '../common/managed/npm-packages.js'
@@ -176,7 +177,9 @@ export function npmRisk(doc: NpmVersionDocument, old: NpmVersionDocument | 'pick
176
177
  return scopes.get(scope)!
177
178
  }
178
179
  const given = old === 'pick' ? null : old
179
- const documentOf = async (version: string) => [doc, given].find(read => read?.version === version) ?? await readNpmVersion(doc.name, version, privileged, signal) ?? undefined
180
+ // The one picked, read as the viewer reads a version, kept a day where public.
181
+ const documentOf = async (version: string) => [doc, given].find(read => read?.version === version)
182
+ ?? await readNpmVersion(doc.name, version, privileged, signal, npmVersionStore(stores.db, stores.debug)) ?? undefined
180
183
  return bounded(spare, () => assessNpm(doc.name, doc.version, { oldVersion: old === 'pick' ? true : old?.version ?? null, fromEmpty,
181
184
  minimumReleaseAge: teamScoped ? 0 : DEFAULT_MINIMUM_RELEASE_AGE,
182
185
  popularity: !teamScoped, cache: npmRiskCache(stores, !doc.private && !given?.private), signal, dependencies: !spare, advisories: !spare, osv: !doc.private, owned,