@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.
- package/client/linked-findings.js +3 -0
- package/common/bundle-metadata.d.ts +1 -0
- package/common/bundle-metadata.js +1 -1
- package/common/bundle-sources.js +5 -1
- package/common/line-diff.js +5 -4
- package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
- package/out/{chunk-AFLZH7WG.js → chunk-4UFAMJAX.js} +1 -1
- package/out/{chunk-HMAJDVXN.js → chunk-7XRR4OFU.js} +1 -1
- package/out/{chunk-Q6MVYGYV.js → chunk-BOIXMUU5.js} +1 -1
- package/out/{chunk-XVBEOGOS.js → chunk-CC4OM6TZ.js} +2 -2
- package/out/chunk-G24A42V6.js +2 -0
- package/out/{chunk-WI7Z5A2T.js → chunk-JKGQ3O7O.js} +1 -1
- package/out/chunk-LE5ZWLG4.js +235 -0
- package/out/chunk-MSION3G4.js +36 -0
- package/out/{chunk-6HNXNIQY.js → chunk-S6GIXWQW.js} +1 -1
- package/out/chunk-V3KWNNMY.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +25 -25
- package/out/compare.js +13 -13
- package/out/dialogs.js +40 -40
- package/out/graph.js +1 -1
- package/out/index.html +6 -6
- package/out/scan.js +2 -2
- package/out/terminal.js +1 -1
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +3 -1
- package/risk/actions/findings.js +14 -9
- package/risk/actions/scripts/shell.js +5 -1
- package/risk/index.d.ts +5 -2
- package/server-e2e/app.ts +5 -4
- package/server-e2e/http.ts +5 -2
- package/server-managed/bundle-cache.ts +7 -1
- package/server-managed/combined.ts +3 -2
- package/server-managed/commit-lockfiles.ts +21 -11
- package/server-managed/config.ts +6 -0
- package/server-managed/db-methods.ts +37 -9
- package/server-managed/db-neon.ts +61 -21
- package/server-managed/db-schema.ts +4 -0
- package/server-managed/db.ts +2 -1
- package/server-managed/dependency-reviews.ts +13 -3
- package/server-managed/github-app.ts +12 -6
- package/server-managed/github-issue-links.ts +11 -5
- package/server-managed/github-pulls.ts +2 -2
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/http-response.ts +10 -2
- package/server-managed/http.ts +195 -111
- package/server-managed/index.ts +8 -7
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/npm-delta.ts +11 -6
- package/server-managed/npm-kept.ts +32 -2
- package/server-managed/npm-loads.ts +100 -30
- package/server-managed/npm-packages.ts +89 -3
- package/server-managed/npm-risk.ts +4 -1
- package/server-managed/pretty-print.ts +67 -23
- package/server-managed/pull-dependencies.ts +29 -9
- package/server-managed/pull-dependency-reviews.ts +8 -8
- package/server-managed/queue.ts +16 -0
- package/server-managed/rename-diff.ts +22 -13
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/report-store.ts +42 -9
- package/server-managed/repository-actions.ts +15 -6
- package/server-managed/repository-browser.ts +119 -31
- package/server-managed/repository-dependency-reviews.ts +41 -19
- package/server-managed/repository-history.ts +24 -8
- package/server-managed/repository-pulse.ts +8 -1
- package/server-managed/repository-risk-audit.ts +13 -0
- package/server-managed/repository-risk-read.ts +11 -6
- package/server-managed/repository-risk.ts +27 -20
- package/server-managed/session.ts +18 -4
- package/server-managed/sql.ts +5 -2
- package/server-managed/storage.ts +4 -2
- package/server-managed/team-reports.ts +24 -12
- package/server-managed/upstream-cache.ts +12 -0
- package/server.ts +14 -5
- package/out/chunk-5DHL5NG3.js +0 -2
- package/out/chunk-NV7ES4EW.js +0 -1
- package/out/chunk-PTGDYLNS.js +0 -36
- package/out/chunk-QRD6GIG5.js +0 -229
package/server-managed/index.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
14
|
-
// past MAX_WAITING refused, each waiting a minute at most and
|
|
15
|
-
// minute at most; one asked again meanwhile sharing the
|
|
16
|
-
// both sides' integrities, in this instance and beyond
|
|
17
|
-
// version holds never changing. Access is the caller's
|
|
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
|
-
|
|
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)
|
|
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
|
|
4
|
-
// readers asking for it meanwhile and held to a few
|
|
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
|
|
20
|
-
// and
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
53
|
-
const created: Load = { job: work(), readers: 0, settled: false }
|
|
54
|
-
const
|
|
55
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 (
|
|
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;
|
|
164
|
-
// are done.
|
|
165
|
-
export function loadNpmPackageBody(doc: NpmVersionDocument, known: () => object = () => ({})
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const files = await
|
|
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
|
|
171
|
-
published:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|