@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.
- 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/common/managed/agent-tasks.ts +5 -3
- package/common/managed/github-agents.ts +28 -10
- package/out/activity.js +4 -0
- package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
- package/out/chunk-5AHIQFFG.js +36 -0
- package/out/chunk-6ANSHRFQ.js +6 -0
- package/out/chunk-6WF3KNTL.js +3 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/{chunk-HMAJDVXN.js → chunk-FH24VHSJ.js} +1 -1
- package/out/chunk-NOZJULWS.js +52 -0
- package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
- package/out/chunk-S6U2J62U.js +232 -0
- package/out/chunk-SPEFPML6.js +2 -0
- package/out/chunk-UENXT2HK.js +3 -0
- package/out/chunk-VL43BTSO.js +1 -0
- package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
- package/out/chunk-ZCS4UYHI.js +1 -0
- package/out/chunk-ZGNMDGSB.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +15 -15
- package/out/client-sync.js +2 -2
- package/out/compare.js +12 -12
- package/out/dialogs.js +28 -28
- package/out/graph.js +11 -11
- package/out/index.html +11 -11
- package/out/scan.js +2 -2
- package/out/terminal.js +6 -6
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +4 -1
- package/risk/actions/findings.js +14 -9
- package/risk/actions/scripts/shell.js +5 -1
- package/risk/index.d.ts +9 -4
- package/risk/npm/assess.js +15 -4
- package/risk/npm/registry.js +6 -1
- package/server-e2e/app.ts +5 -4
- package/server-e2e/http.ts +5 -2
- package/server-managed/activity.ts +24 -11
- package/server-managed/app-agents.ts +10 -21
- package/server-managed/bundle-build-worker.js +7 -3
- package/server-managed/bundle-build.ts +5 -3
- package/server-managed/bundle-cache.ts +16 -4
- package/server-managed/bundle.ts +3 -4
- package/server-managed/combined.ts +3 -2
- package/server-managed/commit-coauthors.ts +5 -9
- package/server-managed/commit-lockfiles.ts +21 -11
- package/server-managed/config.ts +6 -0
- package/server-managed/db-methods.ts +321 -156
- package/server-managed/db-neon.ts +71 -24
- 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 +269 -143
- package/server-managed/index.ts +8 -7
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/link-reports.ts +5 -1
- package/server-managed/npm-delta.ts +11 -6
- package/server-managed/npm-kept.ts +51 -7
- package/server-managed/npm-loads.ts +117 -35
- package/server-managed/npm-packages.ts +107 -14
- package/server-managed/npm-risk.ts +68 -18
- package/server-managed/pretty-print.ts +112 -35
- 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 +50 -12
- package/server-managed/repository-actions.ts +15 -6
- package/server-managed/repository-agents.ts +1 -1
- package/server-managed/repository-browser.ts +123 -33
- package/server-managed/repository-code.ts +28 -11
- package/server-managed/repository-dependency-reviews.ts +41 -19
- package/server-managed/repository-history.ts +24 -8
- package/server-managed/repository-pulse.ts +9 -2
- 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/solidity-entry-points.ts +5 -3
- package/server-managed/sql-postgres.ts +3 -0
- package/server-managed/sql.ts +6 -2
- package/server-managed/storage-db.ts +11 -3
- package/server-managed/storage-encryption.ts +7 -4
- package/server-managed/storage.ts +8 -4
- package/server-managed/team-feed.ts +3 -1
- package/server-managed/team-reports.ts +30 -13
- package/server-managed/uploads.ts +29 -9
- package/server-managed/upstream-cache.ts +12 -0
- package/server.ts +14 -5
- package/out/chunk-5DHL5NG3.js +0 -2
- package/out/chunk-6HNXNIQY.js +0 -52
- package/out/chunk-AFLZH7WG.js +0 -1
- package/out/chunk-NV7ES4EW.js +0 -1
- package/out/chunk-PTGDYLNS.js +0 -36
- package/out/chunk-Q6MVYGYV.js +0 -3
- package/out/chunk-QRD6GIG5.js +0 -229
- package/out/chunk-WI7Z5A2T.js +0 -6
- package/out/chunk-WQ74ZBZF.js +0 -7
- package/out/chunk-XVBEOGOS.js +0 -3
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
|
+
}
|
|
@@ -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
|
-
|
|
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,
|
|
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>
|
|
@@ -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)
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
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'
|
|
10
|
-
import {
|
|
11
|
-
|
|
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
|
|
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 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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 (
|
|
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;
|
|
164
|
-
// are done.
|
|
165
|
-
export function loadNpmPackageBody(doc: NpmVersionDocument, known: () => object = () => ({})
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const files = await
|
|
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
|
|
171
|
-
published:
|
|
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
|
}
|