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

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 (110) 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/common/managed/agent-tasks.ts +5 -3
  7. package/common/managed/github-agents.ts +28 -10
  8. package/out/activity.js +4 -0
  9. package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
  10. package/out/chunk-5AHIQFFG.js +36 -0
  11. package/out/chunk-6ANSHRFQ.js +6 -0
  12. package/out/chunk-6WF3KNTL.js +3 -0
  13. package/out/chunk-7BKKYVWR.js +7 -0
  14. package/out/{chunk-HMAJDVXN.js → chunk-FH24VHSJ.js} +1 -1
  15. package/out/chunk-NOZJULWS.js +52 -0
  16. package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
  17. package/out/chunk-S6U2J62U.js +232 -0
  18. package/out/chunk-SPEFPML6.js +2 -0
  19. package/out/chunk-UENXT2HK.js +3 -0
  20. package/out/chunk-VL43BTSO.js +1 -0
  21. package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
  22. package/out/chunk-ZCS4UYHI.js +1 -0
  23. package/out/chunk-ZGNMDGSB.js +1 -0
  24. package/out/client-managed-import.js +1 -1
  25. package/out/client-managed.js +15 -15
  26. package/out/client-sync.js +2 -2
  27. package/out/compare.js +12 -12
  28. package/out/dialogs.js +28 -28
  29. package/out/graph.js +11 -11
  30. package/out/index.html +11 -11
  31. package/out/scan.js +2 -2
  32. package/out/terminal.js +6 -6
  33. package/out/view.css +1 -1
  34. package/out/view.js +15 -15
  35. package/package.json +4 -1
  36. package/risk/actions/findings.js +14 -9
  37. package/risk/actions/scripts/shell.js +5 -1
  38. package/risk/index.d.ts +9 -4
  39. package/risk/npm/assess.js +15 -4
  40. package/risk/npm/registry.js +6 -1
  41. package/server-e2e/app.ts +5 -4
  42. package/server-e2e/http.ts +5 -2
  43. package/server-managed/activity.ts +24 -11
  44. package/server-managed/app-agents.ts +10 -21
  45. package/server-managed/bundle-build-worker.js +7 -3
  46. package/server-managed/bundle-build.ts +5 -3
  47. package/server-managed/bundle-cache.ts +16 -4
  48. package/server-managed/bundle.ts +3 -4
  49. package/server-managed/combined.ts +3 -2
  50. package/server-managed/commit-coauthors.ts +5 -9
  51. package/server-managed/commit-lockfiles.ts +21 -11
  52. package/server-managed/config.ts +6 -0
  53. package/server-managed/db-methods.ts +321 -156
  54. package/server-managed/db-neon.ts +71 -24
  55. package/server-managed/db-schema.ts +4 -0
  56. package/server-managed/db.ts +2 -1
  57. package/server-managed/dependency-reviews.ts +13 -3
  58. package/server-managed/github-app.ts +12 -6
  59. package/server-managed/github-issue-links.ts +11 -5
  60. package/server-managed/github-pulls.ts +2 -2
  61. package/server-managed/github-requests.ts +39 -0
  62. package/server-managed/http-response.ts +10 -2
  63. package/server-managed/http.ts +269 -143
  64. package/server-managed/index.ts +8 -7
  65. package/server-managed/kept-briefly.ts +37 -0
  66. package/server-managed/link-reports.ts +5 -1
  67. package/server-managed/npm-delta.ts +11 -6
  68. package/server-managed/npm-kept.ts +51 -7
  69. package/server-managed/npm-loads.ts +117 -35
  70. package/server-managed/npm-packages.ts +107 -14
  71. package/server-managed/npm-risk.ts +68 -18
  72. package/server-managed/pretty-print.ts +112 -35
  73. package/server-managed/pull-dependencies.ts +29 -9
  74. package/server-managed/pull-dependency-reviews.ts +8 -8
  75. package/server-managed/queue.ts +16 -0
  76. package/server-managed/rename-diff.ts +22 -13
  77. package/server-managed/report-sources.ts +1 -1
  78. package/server-managed/report-store.ts +50 -12
  79. package/server-managed/repository-actions.ts +15 -6
  80. package/server-managed/repository-agents.ts +1 -1
  81. package/server-managed/repository-browser.ts +123 -33
  82. package/server-managed/repository-code.ts +28 -11
  83. package/server-managed/repository-dependency-reviews.ts +41 -19
  84. package/server-managed/repository-history.ts +24 -8
  85. package/server-managed/repository-pulse.ts +9 -2
  86. package/server-managed/repository-risk-audit.ts +13 -0
  87. package/server-managed/repository-risk-read.ts +11 -6
  88. package/server-managed/repository-risk.ts +27 -20
  89. package/server-managed/session.ts +18 -4
  90. package/server-managed/solidity-entry-points.ts +5 -3
  91. package/server-managed/sql-postgres.ts +3 -0
  92. package/server-managed/sql.ts +6 -2
  93. package/server-managed/storage-db.ts +11 -3
  94. package/server-managed/storage-encryption.ts +7 -4
  95. package/server-managed/storage.ts +8 -4
  96. package/server-managed/team-feed.ts +3 -1
  97. package/server-managed/team-reports.ts +30 -13
  98. package/server-managed/uploads.ts +29 -9
  99. package/server-managed/upstream-cache.ts +12 -0
  100. package/server.ts +14 -5
  101. package/out/chunk-5DHL5NG3.js +0 -2
  102. package/out/chunk-6HNXNIQY.js +0 -52
  103. package/out/chunk-AFLZH7WG.js +0 -1
  104. package/out/chunk-NV7ES4EW.js +0 -1
  105. package/out/chunk-PTGDYLNS.js +0 -36
  106. package/out/chunk-Q6MVYGYV.js +0 -3
  107. package/out/chunk-QRD6GIG5.js +0 -229
  108. package/out/chunk-WI7Z5A2T.js +0 -6
  109. package/out/chunk-WQ74ZBZF.js +0 -7
  110. package/out/chunk-XVBEOGOS.js +0 -3
@@ -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
+ }
@@ -39,7 +39,11 @@ function decryptGroups(key: StorageKey | null, id: string, payload: string): str
39
39
  try { return JSON.parse(plain.toString('utf8')) as string[][] } finally { plain.fill(0) }
40
40
  }
41
41
  export async function linkRevision(db: ManagedSql): Promise<string> {
42
- const rows = await db.prepare('SELECT id, sha256 FROM managed_link_report WHERE enabled = 1 ORDER BY id').all()
42
+ return linkRevisionOf(await db.prepare('SELECT id, sha256 FROM managed_link_report WHERE enabled = 1 ORDER BY id').all())
43
+ }
44
+ // The revision of the enabled link reports' `{ id, sha256 }` rows, by id,
45
+ // however they were read.
46
+ export function linkRevisionOf(rows: unknown[]): string {
43
47
  return rows.length > 0 ? createHash('sha256').update(JSON.stringify(rows)).digest('base64url') : ''
44
48
  }
45
49
  function parseLinkReport(content: string) {
@@ -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>
@@ -34,15 +38,25 @@ export interface KeptStore<T> {
34
38
  // When each database last had the rows under each prefix removed.
35
39
  const PRUNE_EVERY_MS = 60 * 60_000
36
40
  const pruned = new WeakMap<UpstreamCacheStore, Map<string, number>>()
41
+ // Who sees each database's prunes through (index.ts: its app, which waits
42
+ // for them before closing it, and on serverless keeps its invocation alive
43
+ // till they settle), as a write no longer waits for one.
44
+ const pruneOwners = new WeakMap<UpstreamCacheStore, (prune: Promise<unknown>) => void>()
45
+ export function ownKeptPrunes(db: UpstreamCacheStore, own: (prune: Promise<unknown>) => void) {
46
+ pruneOwners.set(db, own)
47
+ }
37
48
 
38
49
  // Removes the rows under `prefix` kept `keptMs` before `at`, at most once an
39
50
  // hour a database, as rows under it are written: here, and risk's downloads
40
- // (npm-risk.ts). A failure is `warn`ed of, and removes nothing.
41
- export async function pruneKept(db: UpstreamCacheStore, prefix: string, keptMs: number, at: number, warn: (error: unknown) => void) {
51
+ // (npm-risk.ts), each one run handed to the database's owner. A failure is
52
+ // `warn`ed of, and removes nothing.
53
+ export function pruneKept(db: UpstreamCacheStore, prefix: string, keptMs: number, at: number, warn: (error: unknown) => void): Promise<void> {
42
54
  const times = pruned.get(db) ?? pruned.set(db, new Map()).get(db)!
43
- if (at - (times.get(prefix) ?? -Infinity) < PRUNE_EVERY_MS) return
55
+ if (at - (times.get(prefix) ?? -Infinity) < PRUNE_EVERY_MS) return Promise.resolve()
44
56
  times.set(prefix, at)
45
- try { await db.deleteUpstreamCacheEntries(prefix, at - keptMs) } catch (error) { warn(error) }
57
+ const prune = (async () => { try { await db.deleteUpstreamCacheEntries(prefix, at - keptMs) } catch (error) { warn(error) } })()
58
+ pruneOwners.get(db)?.(prune)
59
+ return prune
46
60
  }
47
61
 
48
62
  // Values of `type`, each read for `keptMs` after it was kept.
@@ -66,9 +80,20 @@ function keptStore<T>(db: UpstreamCacheStore, type: string, keptMs: number, debu
66
80
  return new Map(keys.flatMap(key => { const kept = valueOf(rows.get(keyOf(key))); return kept ? [[key, kept] as const] : [] }))
67
81
  } catch (error) { warn('read', error); return new Map() }
68
82
  },
83
+ async readFirst(first, then) {
84
+ try {
85
+ const { first: answered, entries } = await db.getFirstUpstreamCacheEntries(first.map(keyOf), then.map(keyOf))
86
+ const asked = answered ? first : then
87
+ return { first: answered, found: new Map(asked.flatMap(key => { const kept = valueOf(entries.get(keyOf(key))); return kept ? [[key, kept] as const] : [] })) }
88
+ } catch (error) { warn('read', error); return { first: false, found: new Map() } }
89
+ },
69
90
  async write(key, value, at) {
70
91
  try { await db.setUpstreamCacheEntry(keyOf(key), JSON.stringify({ at, value }), at) } catch (error) { warn('write', error) }
71
- await prune(at)
92
+ // Behind the write, not before it is done: the prune reads the whole
93
+ // table (no index serves its prefix), which no writer need wait on.
94
+ // Its hour is marked before it runs, so writes meanwhile start none,
95
+ // and its database's owner sees it through (ownKeptPrunes).
96
+ void prune(at)
72
97
  },
73
98
  async remove(key) {
74
99
  try { await db.deleteUpstreamCacheEntries(keyOf(key), Number.MAX_SAFE_INTEGER) } catch (error) { warn('remove', error) }
@@ -83,10 +108,29 @@ export function npmVersionStore(db: UpstreamCacheStore, debug = false): NpmVersi
83
108
  const hit = await kept.read(`${name}@${version}`)
84
109
  return hit && plainObject(hit.value) ? { at: hit.at, json: hit.value } : null
85
110
  },
111
+ async readMany(versions) {
112
+ const hits = await kept.readMany(versions.map(({ name, version }) => `${name}@${version}`))
113
+ return new Map([...hits].flatMap(([key, hit]) => plainObject(hit.value) ? [[key, { at: hit.at, json: hit.value }] as const] : []))
114
+ },
86
115
  write: (name, version, json) => kept.write(`${name}@${version}`, json, Date.now()),
87
116
  }
88
117
  }
89
118
 
119
+ // When public versions were published (npm-packages.ts npmPublishTime), which
120
+ // never changes once they are: kept a month, and kept again by the next view
121
+ // of the version past it.
122
+ const PUBLISH_TIME_KEPT_MS = 30 * 24 * 60 * 60_000
123
+ export function npmPublishTimeStore(db: UpstreamCacheStore, debug = false): NpmPublishTimeStore {
124
+ const kept = keptStore<string>(db, 'npm/publish-times', PUBLISH_TIME_KEPT_MS, debug)
125
+ return {
126
+ async read(name, version) {
127
+ const hit = await kept.read(`${name}@${version}`)
128
+ return typeof hit?.value === 'string' ? hit.value : null
129
+ },
130
+ write: (name, version, at) => kept.write(`${name}@${version}`, at, Date.now()),
131
+ }
132
+ }
133
+
90
134
  // Risk lines, each kept `keptMs`, for good where Infinity, a scan telling one
91
135
  // past RISK_KEPT_MS as stale (repository-risk.ts).
92
136
  export const riskLineStore = <T>(db: UpstreamCacheStore, keptMs: number, debug = false): KeptStore<T> => keptStore<T>(db, 'repository-risk/lines', keptMs, debug)
@@ -1,14 +1,16 @@
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'
10
- import { MAX_NPM_PACKAGE_BYTES, MAX_NPM_PACKAGE_FILES, NPM_REGISTRY, NpmPackageError, type NpmPackageFile, type NpmVersionDocument, askUpstream,
11
- npmFileRows, npmPublishTime, npmToken, readNpmTar } from './npm-packages.ts'
11
+ import { keptBriefly } from './kept-briefly.ts'
12
+ import { MAX_NPM_PACKAGE_BYTES, MAX_NPM_PACKAGE_FILES, NPM_REGISTRY, NpmPackageError, type NpmPackageFile, type NpmVersionDocument, type TarReading,
13
+ askUpstream, npmFileRows, npmPublishTime, npmToken, readNpmTar } from './npm-packages.ts'
12
14
  import { keepFile, readKeptFile, upstreamCachePath } from './upstream-cache.ts'
13
15
 
14
16
  // The tar stream a package's files may come in, headers and padding included.
@@ -16,20 +18,31 @@ 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 and how many places it takes.
32
43
  const loads = new Map<string, Load>()
44
+ const slots = new Map<string, { holding: number; places: number }>()
45
+ const placesTaken = () => [...slots.values()].reduce((sum, slot) => sum + slot.places, 0)
33
46
 
34
47
  const refused = <T>(status: number, code: string): NpmLoad<T> => {
35
48
  const result = Promise.reject(new NpmPackageError(status, code))
@@ -37,26 +50,40 @@ const refused = <T>(status: number, code: string): NpmLoad<T> => {
37
50
  return { result, release() {} }
38
51
  }
39
52
 
40
- function shared<T>(key: string, doc: NpmVersionDocument, work: () => Promise<T>): NpmLoad<T> {
53
+ // What names a version's tarball, and so its files: its integrity and URL,
54
+ // and whether it is read with the token.
55
+ const tarballKey = (doc: NpmVersionDocument) => `${doc.dist.integrity} ${doc.dist.tarball} ${doc.private}`
56
+
57
+ function shared<T>(what: string, doc: NpmVersionDocument, work: () => Promise<T>): NpmLoad<T> {
41
58
  const { unpackedSize, fileCount } = doc.dist
42
59
  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')
60
+ const slot = tarballKey(doc)
61
+ return sharedLoad(`${what} ${slot}`, work, 'npm-busy', { slot })
62
+ }
63
+
64
+ // The places among the active ones a load takes whose files hold `files` and
65
+ // `bytes`: as many as packages they would fill, up to every one, each place
66
+ // holding what a package may.
67
+ export function loadPlaces({ files, bytes }: { files: number; bytes: number }): number {
68
+ return Math.min(MAX_ACTIVE_LOADS, Math.max(1, Math.ceil(files / MAX_NPM_PACKAGE_FILES), Math.ceil(bytes / MAX_NPM_PACKAGE_BYTES)))
44
69
  }
45
70
 
46
71
  // A load among the active ones, by `key`, refused as `busy` past them: a
47
72
  // 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> {
73
+ // alike a tarball, its tar, its files and their encoded JSON. Loads of one
74
+ // `slot` take one place between them, or `places` (loadPlaces) where they
75
+ // hold more.
76
+ export function sharedLoad<T>(key: string, work: () => Promise<T>, busy: string,
77
+ { slot = key, places = 1 }: { slot?: string; places?: number } = {}): NpmLoad<T> {
50
78
  let load = loads.get(key)
51
79
  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)
80
+ if (!slots.has(slot) && placesTaken() + places > MAX_ACTIVE_LOADS) return refused(429, busy)
81
+ const created: Load = { job: work(), readers: 0, settled: false, slot }
82
+ const finish = () => { settle(key, created) }
83
+ created.job.then(finish, finish)
59
84
  loads.set(key, created)
85
+ const taken = slots.get(slot)
86
+ slots.set(slot, { holding: (taken?.holding ?? 0) + 1, places: taken?.places ?? places })
60
87
  load = created
61
88
  }
62
89
  const held = load
@@ -67,11 +94,27 @@ export function sharedLoad<T>(key: string, work: () => Promise<T>, busy: string)
67
94
  release() {
68
95
  if (released) return
69
96
  released = true
70
- if (--held.readers === 0 && held.settled && loads.get(key) === held) loads.delete(key)
97
+ held.readers--
98
+ done(key, held)
71
99
  },
72
100
  }
73
101
  }
74
102
 
103
+ function settle(key: string, load: Load) {
104
+ load.settled = true
105
+ done(key, load)
106
+ }
107
+
108
+ // A load gone from the active ones, and its place with it, once its work is
109
+ // done and its readers have released it.
110
+ function done(key: string, load: Load) {
111
+ if (load.readers !== 0 || !load.settled || loads.get(key) !== load) return
112
+ loads.delete(key)
113
+ const taken = slots.get(load.slot)!
114
+ if (taken.holding === 1) slots.delete(load.slot)
115
+ else taken.holding--
116
+ }
117
+
75
118
  // The sha512 an integrity names, in base64, or null where it names none.
76
119
  const sha512Of = (integrity: string) => /(?:^|\s)sha512-([\d+/A-Za-z]{86}==)(?=\s|$)/u.exec(integrity)?.[1] ?? null
77
120
  // Hashed off the event loop: a tarball runs to tens of MiB.
@@ -129,12 +172,51 @@ async function readTarball(doc: NpmVersionDocument): Promise<Uint8Array> {
129
172
  return bytes
130
173
  }
131
174
 
175
+ // Tarballs being read, and versions' files being unpacked, by what names
176
+ // them (tarballKey): readers asking for one meanwhile share it, its bytes
177
+ // those each one's own document names.
178
+ const reading = new Map<string, Promise<Uint8Array>>()
179
+ const unpacking = new Map<string, Promise<Unpacked>>()
180
+
181
+ function sharedRead<T>(pending: Map<string, Promise<T>>, key: string, read: () => Promise<T>): Promise<T> {
182
+ let job = pending.get(key)
183
+ if (!job) {
184
+ const started = read().finally(() => { if (pending.get(key) === started) pending.delete(key) })
185
+ pending.set(key, job = started)
186
+ }
187
+ return job
188
+ }
189
+
190
+ const sharedTarball = (doc: NpmVersionDocument) => sharedRead(reading, tarballKey(doc), () => readTarball(doc))
191
+
192
+ // A version's files, and the size of the tarball they came in.
193
+ interface Unpacked { files: NpmPackageFile[]; tarballSize: number }
194
+
195
+ // Public versions' files lately unpacked, by tarballKey.
196
+ const unpacked = keptBriefly<Unpacked>({ keptMs: UNPACKED_KEPT_MS, maxBytes: MAX_UNPACKED_BYTES })
197
+
198
+ // The version's files, kept from a load of them a little while since, else
199
+ // unpacked from its tarball (and kept, a public version's): bytes its
200
+ // reader's own document names, as its tarball's are.
201
+ function unpackedOf(doc: NpmVersionDocument): Promise<Unpacked> {
202
+ const key = tarballKey(doc)
203
+ const kept = unpacked.get(key)
204
+ if (kept) return Promise.resolve(kept)
205
+ return sharedRead(unpacking, key, async () => {
206
+ const tarball = await sharedTarball(doc)
207
+ const value = { files: await readTarballFiles(tarball), tarballSize: tarball.byteLength }
208
+ if (!doc.private) unpacked.keep(key, value, value.files.reduce((sum, file) => sum + file.path.length + file.bytes.byteLength, 0))
209
+ return value
210
+ })
211
+ }
212
+
132
213
  // A gzipped tarball's files, its top directory left out, as npm extracts
133
214
  // a package's: a repository tree's as GitHub archives it, too, its symlinks
134
- // let climb as readNpmTar's `symlinksAbove` says.
135
- export async function readTarballFiles(tarball: Uint8Array, options: { symlinksAbove?: number } = {}): Promise<NpmPackageFile[]> {
215
+ // let climb as readNpmTar's `symlinksAbove` says, and its tar stream and
216
+ // files bounded as `options` give, a package's bounds unless given.
217
+ export async function readTarballFiles(tarball: Uint8Array, { maxTarBytes = MAX_TAR_BYTES, ...options }: TarReading & { maxTarBytes?: number } = {}): Promise<NpmPackageFile[]> {
136
218
  let tar: Uint8Array
137
- try { tar = await decompress(tarball, 'gzip', { limit: MAX_TAR_BYTES }) }
219
+ try { tar = await decompress(tarball, 'gzip', { limit: maxTarBytes }) }
138
220
  catch (err) {
139
221
  const limited = err instanceof CompressionError && err.limited
140
222
  throw new NpmPackageError(limited ? 413 : 422, limited ? 'package-too-large' : 'bad-tarball')
@@ -144,31 +226,31 @@ export async function readTarballFiles(tarball: Uint8Array, options: { symlinksA
144
226
 
145
227
  // The version's tarball, for download.
146
228
  export function loadNpmTarball(doc: NpmVersionDocument): NpmLoad<Uint8Array> {
147
- return shared(`tarball ${doc.dist.integrity} ${doc.dist.tarball}`, doc, () => readTarball(doc))
229
+ return shared('tarball', doc, () => sharedTarball(doc))
148
230
  }
149
231
 
150
232
  // The version's files, for one of them to be pretty-printed (pretty-print.ts).
151
233
  export function loadNpmFiles(doc: NpmVersionDocument): NpmLoad<NpmPackageFile[]> {
152
- return shared(`files ${doc.dist.integrity} ${doc.dist.tarball}`, doc, async () => readTarballFiles(await readTarball(doc)))
234
+ return shared('files', doc, async () => (await unpackedOf(doc)).files)
153
235
  }
154
236
 
155
237
  // The version as the viewer reads it, as brotli-encoded JSON: `{ name,
156
238
  // version, private, integrity, tarballSize, published, manifest, files,
157
239
  // 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
240
+ // are encoded, when it was published where that is kept (`published`, asked
241
+ // then, else null) and what `known` gives: what is kept about its package
160
242
  // (npm-insights.ts npmKnown), which the viewer need not then ask for. Encoding
161
243
  // is where a load holds the most, so it takes its place among the active
162
244
  // 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)
245
+ // the first one's manifest, `published` and `known` in it; the body is not
246
+ // kept once its readers are done, its files a little while (unpackedOf).
247
+ export function loadNpmPackageBody(doc: NpmVersionDocument, known: () => object = () => ({}),
248
+ published: () => Promise<string | null> = npmPublishTime(doc).read): NpmLoad<Buffer> {
249
+ return shared('package', doc, async () => {
250
+ const { files, tarballSize } = await unpackedOf(doc)
169
251
  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(),
252
+ name: doc.name, version: doc.version, private: doc.private, integrity: doc.dist.integrity, tarballSize,
253
+ published: await published(), manifest: doc.manifest, files: npmFileRows(files), known: known(),
172
254
  })))
173
255
  })
174
256
  }