@preventive/triage 1.0.0-alpha.36 → 1.0.0-alpha.38

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 (177) hide show
  1. package/README.md +16 -0
  2. package/common/bundle-compare-diff.d.ts +3 -0
  3. package/common/bundle-compare-diff.js +522 -0
  4. package/common/bundle-compare-inputs.d.ts +2 -0
  5. package/common/bundle-compare-inputs.js +65 -0
  6. package/common/bundle-metadata.d.ts +6 -2
  7. package/common/bundle-metadata.js +63 -20
  8. package/common/bundle-pkg-of.d.ts +1 -0
  9. package/common/bundle-pkg-of.js +90 -0
  10. package/common/bundle-source-links.js +7 -0
  11. package/common/bundle-sourcemap.d.ts +10 -0
  12. package/common/bundle-sourcemap.js +100 -0
  13. package/common/bundle-sources.d.ts +3 -1
  14. package/common/bundle-sources.js +47 -19
  15. package/common/code-language.js +16 -2
  16. package/common/default-scan-models.ts +2 -1
  17. package/common/dependency-reviews.d.ts +23 -0
  18. package/common/dependency-reviews.js +81 -0
  19. package/common/finding-duplicates.js +58 -0
  20. package/common/finding-filters.js +274 -0
  21. package/common/github-pr.ts +1 -1
  22. package/common/gzip.js +59 -0
  23. package/common/line-diff.d.ts +8 -0
  24. package/common/line-diff.js +56 -0
  25. package/common/linked-workspace-groups.js +63 -0
  26. package/common/managed/npm-packages.d.ts +5 -0
  27. package/common/managed/npm-packages.js +34 -0
  28. package/common/managed/permissions.ts +1 -5
  29. package/common/managed/routes.js +123 -12
  30. package/common/managed/team-app.d.ts +2 -0
  31. package/common/managed/team-app.js +31 -0
  32. package/common/managed/triage.ts +1 -1
  33. package/common/managed/upload-seal.ts +126 -0
  34. package/common/pretty-print.d.ts +5 -0
  35. package/common/pretty-print.js +24 -0
  36. package/common/revalidation-conflicts.js +48 -0
  37. package/common/revalidation-input-groups.js +56 -0
  38. package/common/workspace-app.js +23 -0
  39. package/common/workspace-groups.js +176 -0
  40. package/out/brotli-fallback.js +3 -3
  41. package/out/chunk-2KBF5TZ7.js +2 -0
  42. package/out/chunk-2XUEZHIP.js +28 -0
  43. package/out/chunk-35VE6CDH.js +2 -0
  44. package/out/chunk-3BZ7JERB.js +1 -0
  45. package/out/chunk-4XCDFGDP.js +10 -0
  46. package/out/chunk-5FKRIJGF.js +1 -0
  47. package/out/chunk-6Z5XLSPJ.js +1 -0
  48. package/out/chunk-ABW5FVSN.js +1 -0
  49. package/out/chunk-BJKRVROW.js +1 -0
  50. package/out/chunk-BYUEDJOM.js +7 -0
  51. package/out/chunk-C4Z6ZBXV.js +10 -0
  52. package/out/chunk-CMQA4DM6.js +1 -0
  53. package/out/chunk-CVVJAXVY.js +2 -0
  54. package/out/chunk-DCQX2J3C.js +223 -0
  55. package/out/chunk-DIJJNTRH.js +10 -0
  56. package/out/chunk-F4OMDNEY.js +1 -0
  57. package/out/chunk-FU4MTW7F.js +1 -0
  58. package/out/chunk-GJ4IUCNS.js +1 -0
  59. package/out/chunk-GMFXFEUA.js +2 -0
  60. package/out/chunk-GNXOKIGP.js +80 -0
  61. package/out/chunk-GZ4MS3TE.js +13 -0
  62. package/out/chunk-HIV7SVI7.js +1 -0
  63. package/out/chunk-IU3CCINR.js +3 -0
  64. package/out/chunk-JAKWIZWL.js +2 -0
  65. package/out/chunk-K3X5EJBQ.js +2 -0
  66. package/out/chunk-KEX2ATEW.js +2 -0
  67. package/out/chunk-KYCNJVB7.js +1 -0
  68. package/out/chunk-NRPC74AM.js +1 -0
  69. package/out/chunk-OJ5DGP5E.js +33 -0
  70. package/out/chunk-OOJM4CTU.js +1 -0
  71. package/out/chunk-PNHBZGDD.js +1 -0
  72. package/out/chunk-R5O6FAW4.js +2 -0
  73. package/out/chunk-RFZQQXSG.js +17 -0
  74. package/out/chunk-SD2WJAXN.js +48 -0
  75. package/out/chunk-TUII34K2.js +1 -0
  76. package/out/chunk-TZ5KB2Y5.js +10 -0
  77. package/out/chunk-UNTRGE26.js +2 -0
  78. package/out/chunk-UYAXVRVL.js +1 -0
  79. package/out/chunk-V3ME6FI5.js +2 -0
  80. package/out/chunk-V5O7M2YT.js +1 -0
  81. package/out/chunk-VIHGO77Y.js +1 -0
  82. package/out/chunk-W4J3MGLB.js +1 -0
  83. package/out/chunk-WGG7KQUH.js +1 -0
  84. package/out/chunk-XYFXXB2G.js +10 -0
  85. package/out/chunk-YVZH5SHP.js +1 -0
  86. package/out/chunk-ZPD5R5RP.js +3 -0
  87. package/out/client-managed-import.js +3 -87
  88. package/out/client-managed.js +26 -338
  89. package/out/client-sync.js +8 -14
  90. package/out/graph.js +17 -30
  91. package/out/index.html +34 -0
  92. package/out/prism.js +6 -2
  93. package/out/terminal.js +241 -257
  94. package/out/view.css +1 -1
  95. package/out/view.js +76 -520
  96. package/package.json +88 -7
  97. package/risk/files/characters.js +196 -0
  98. package/risk/files/hosts.js +205 -0
  99. package/risk/files/readability.js +164 -0
  100. package/risk/files/runs-code.js +329 -0
  101. package/risk/files/script.js +112 -0
  102. package/risk/files/stamps.js +46 -0
  103. package/risk/index.d.ts +142 -0
  104. package/risk/index.js +7 -0
  105. package/risk/npm/assess.js +219 -0
  106. package/risk/npm/combined.js +65 -0
  107. package/risk/npm/compare.js +222 -0
  108. package/risk/npm/dependencies.js +153 -0
  109. package/risk/npm/diff.js +43 -0
  110. package/risk/npm/downloads.js +84 -0
  111. package/risk/npm/files.js +34 -0
  112. package/risk/npm/held.js +60 -0
  113. package/risk/npm/install-scripts.js +33 -0
  114. package/risk/npm/malware.js +36 -0
  115. package/risk/npm/manifest.js +65 -0
  116. package/risk/npm/new-dependency.js +30 -0
  117. package/risk/npm/old-version.js +108 -0
  118. package/risk/npm/registry.js +282 -0
  119. package/risk/npm/release.js +85 -0
  120. package/risk/npm/specs.js +124 -0
  121. package/risk/npm/tarball.js +113 -0
  122. package/server-e2e/http.ts +7 -7
  123. package/server-e2e/npm-proxy.ts +1 -1
  124. package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
  125. package/server-e2e/objstore/handlers.ts +1 -1
  126. package/server-e2e/objstore/tokens.ts +1 -1
  127. package/server-e2e/sign.ts +1 -1
  128. package/server-managed/bundle-advisories.ts +68 -11
  129. package/server-managed/bundle-build-worker.js +1 -1
  130. package/server-managed/bundle-build.ts +6 -2
  131. package/server-managed/bundle-cache.ts +37 -4
  132. package/server-managed/bundle-commits.ts +140 -0
  133. package/server-managed/bundle-summary-cache.ts +24 -10
  134. package/server-managed/config.ts +4 -0
  135. package/server-managed/db-methods.ts +98 -11
  136. package/server-managed/db-neon.ts +39 -4
  137. package/server-managed/db-schema.ts +14 -2
  138. package/server-managed/db-table-names.ts +1 -1
  139. package/server-managed/db.ts +7 -3
  140. package/server-managed/dependency-reviews.ts +248 -0
  141. package/server-managed/github-issue-oauth.ts +5 -6
  142. package/server-managed/github-metadata.ts +115 -0
  143. package/server-managed/github-oauth.ts +38 -2
  144. package/server-managed/http-response.ts +26 -0
  145. package/server-managed/http.ts +943 -52
  146. package/server-managed/index.ts +11 -5
  147. package/server-managed/kept.ts +28 -0
  148. package/server-managed/locked-packages.ts +85 -0
  149. package/server-managed/lru.ts +30 -0
  150. package/server-managed/npm-insights.ts +440 -0
  151. package/server-managed/npm-kept.ts +65 -0
  152. package/server-managed/npm-loads.ts +167 -0
  153. package/server-managed/npm-manifest.ts +115 -0
  154. package/server-managed/npm-packages.ts +378 -0
  155. package/server-managed/npm-provenance.ts +123 -0
  156. package/server-managed/npm-risk.ts +177 -0
  157. package/server-managed/pretty-print.ts +352 -0
  158. package/server-managed/public-workspace.ts +78 -7
  159. package/server-managed/rename-diff-worker.js +26 -0
  160. package/server-managed/rename-diff.ts +270 -0
  161. package/server-managed/report-sources.ts +13 -2
  162. package/server-managed/repository-browser.ts +19 -5
  163. package/server-managed/repository-risk.ts +413 -0
  164. package/server-managed/session.ts +16 -2
  165. package/server-managed/sql.ts +5 -2
  166. package/server-managed/storage-encryption.ts +5 -3
  167. package/server-managed/storage-payload.ts +4 -0
  168. package/server-managed/storage-stores.ts +2 -0
  169. package/server-managed/storage.ts +6 -3
  170. package/server-managed/team-app.ts +94 -0
  171. package/server-managed/team-feed.ts +1 -1
  172. package/server-managed/team-npm-scopes.ts +77 -0
  173. package/server-managed/type-only-worker.js +19 -0
  174. package/server-managed/type-only.ts +103 -0
  175. package/server-managed/uploads.ts +25 -4
  176. package/server-managed/upstream-cache.ts +147 -5
  177. package/server-managed/view-as.ts +58 -0
@@ -0,0 +1,177 @@
1
+ // What calls for a look at an npm version, as risk tells it (risk/), for the
2
+ // managed viewer: risk is asked on the server alone, its records kept where
3
+ // each belongs by its type (risk/npm/registry.js, assess.js):
4
+ //
5
+ // - `risk/npm/assessments`, what of an assessment holds whatever the time:
6
+ // a public package's in the public npm cache, as its pretty-printed files
7
+ // are (storage-payload.ts PUBLIC_CACHE), by a hash of its key; a private
8
+ // one's in this instance's memory alone.
9
+ // - `risk/npm/versions`, what a version's document says, which npm never
10
+ // changes once published: a public package's in the database's upstream
11
+ // cache table, shared by every instance; a private one's in memory.
12
+ // - `risk/npm/packages`, `risk/npm/public`, `risk/npm/advisories` and
13
+ // `risk/npm/downloads`, kept an hour or a day by risk itself: in memory.
14
+ // - `npm/tarballs`: none asked, as tarballs are read as the viewer's loads
15
+ // read them, kept where they keep them (npm-loads.ts loadNpmTarball), and
16
+ // an assessment kept needs none.
17
+ //
18
+ // What memory keeps is held to 2,000 records and 64 MiB as JSON, the first
19
+ // kept going first.
20
+ //
21
+ // A cache failure is a miss, never a failed assessment. Packages' documents
22
+ // are read as every other npm document here is, within their bounds and
23
+ // shared budget (npm-packages.ts readNpmPackument), and tarballs as the
24
+ // viewer's loads read them, bounded as they arrive and among the loads made
25
+ // at once. Assessments made at once are bounded as those loads are, each
26
+ // unpacking up to two tarballs: one past them is refused as busy.
27
+ import { Buffer } from 'node:buffer'
28
+ import { createHash } from 'node:crypto'
29
+ import { buffer } from 'node:stream/consumers'
30
+ import type { CacheStore } from '@preventive/upstream/npm.js'
31
+ import { DEFAULT_MINIMUM_RELEASE_AGE, type NpmAssessment, assessNpm } from '../risk/index.js'
32
+ import { CacheMissError, type CacheStorage } from './cache-storage.ts'
33
+ import { loadNpmTarball } from './npm-loads.ts'
34
+ import { NpmPackageError, type NpmVersionDocument, readNpmPackument, readNpmVersion } from './npm-packages.ts'
35
+ import { npmPackageScope } from '../common/managed/npm-packages.js'
36
+ import type { UpstreamCacheStore } from './upstream-cache.ts'
37
+
38
+ const MEMORY_ENTRIES = 2000
39
+ // As npm-loads.ts MAX_ACTIVE_LOADS.
40
+ const MAX_ACTIVE_ASSESSMENTS = 4
41
+ // Of them, those no reader waits on (`spare`), such as a scan's: the rest are
42
+ // left for readers.
43
+ export const MAX_SPARE_ASSESSMENTS = 2
44
+ let assessing = 0
45
+ // What memory holds at most, by its records' length as JSON: the first kept
46
+ // go first past it, and a record past an eighth of it isn't kept at all.
47
+ const MEMORY_LENGTH = 64 * 1024 * 1024
48
+ let memoryLength = 0
49
+ const memory = new Map<string, { value: unknown; length: number }>()
50
+ function forget(key: string) {
51
+ const kept = memory.get(key)
52
+ if (!kept) return
53
+ memory.delete(key)
54
+ memoryLength -= kept.length
55
+ }
56
+ function remember(key: string, value: unknown) {
57
+ forget(key)
58
+ const length = JSON.stringify(value)?.length ?? 0
59
+ if (length > MEMORY_LENGTH / 8) return
60
+ memory.set(key, { value, length })
61
+ memoryLength += length
62
+ while (memory.size > MEMORY_ENTRIES || memoryLength > MEMORY_LENGTH) forget(memory.keys().next().value!)
63
+ }
64
+
65
+ const hashed = (type: string, key: string) => `risk/${createHash('sha256').update(`${type}\0${key}`).digest('hex')}`
66
+
67
+ export interface NpmRiskStores {
68
+ db: UpstreamCacheStore
69
+ npmCache?: CacheStorage | undefined
70
+ debug?: boolean | undefined
71
+ }
72
+
73
+ // Risk's cache for one package: `isPublic` whether anyone can read it, so
74
+ // that what is kept of it can be shared.
75
+ export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: boolean): CacheStore {
76
+ const warn = (what: string, error: unknown) => { if (debug) console.warn(`managed: risk cache ${what} failed:`, error) }
77
+ return {
78
+ async read(type, key) {
79
+ if (type === 'npm/tarballs') return null
80
+ try {
81
+ if (isPublic && type === 'risk/npm/assessments' && npmCache) return JSON.parse((await buffer((await npmCache.open(hashed(type, key))).stream)).toString('utf8'))
82
+ if (isPublic && type === 'risk/npm/versions') { const value = await db.getUpstreamCacheEntry(`${type}/${key}`); return value === null ? null : JSON.parse(value) }
83
+ } catch (error) { if (!(error instanceof CacheMissError)) warn('read', error); return null }
84
+ return memory.get(`${isPublic ? '' : 'private\0'}${type}\0${key}`)?.value ?? null
85
+ },
86
+ async write(type, key, value) {
87
+ if (type === 'npm/tarballs') return
88
+ try {
89
+ if (isPublic && type === 'risk/npm/assessments' && npmCache) { await npmCache.put(hashed(type, key), Buffer.from(JSON.stringify(value))); return }
90
+ if (isPublic && type === 'risk/npm/versions') { await db.setUpstreamCacheEntry(`${type}/${key}`, JSON.stringify(value), Date.now()); return }
91
+ } catch (error) { warn('write', error); return }
92
+ remember(`${isPublic ? '' : 'private\0'}${type}\0${key}`, value)
93
+ },
94
+ }
95
+ }
96
+
97
+ // A version's tarball as the viewer's loads read it (npm-loads.ts), its bytes
98
+ // held to the integrity its reader's document names: given up on, and the
99
+ // load released, once `signal` aborts, the load going on for its other
100
+ // readers, so that a reader gone holds no assessment's place.
101
+ async function tarballOf(doc: NpmVersionDocument | undefined, signal: AbortSignal): Promise<Uint8Array> {
102
+ if (!doc) throw new NpmPackageError(502, 'upstream-invalid')
103
+ signal.throwIfAborted()
104
+ const load = loadNpmTarball(doc)
105
+ let onAbort = () => {}
106
+ const aborted = new Promise<never>((_, reject) => { onAbort = () => reject(signal.reason); signal.addEventListener('abort', onAbort, { once: true }) })
107
+ try { return await Promise.race([load.result, aborted]) } finally {
108
+ signal.removeEventListener('abort', onAbort)
109
+ load.release()
110
+ }
111
+ }
112
+
113
+ // The version `doc` names assessed against `old`'s, where given, each as the
114
+ // reader's readNpmVersion read it, or against the one risk picks of its
115
+ // versions (`'pick'`, assessNpm `oldVersion: true`), its document read as the
116
+ // open version's is where its tarball is wanted: trusted however new and however few
117
+ // download it where a team lists its scope (`teamScoped`), its own packages;
118
+ // else held to risk's minimum release age and flagged as unpopular. A new
119
+ // dependency on a package of a scope a team lists (`ownScope`, asked once a
120
+ // scope) is told no tier nor age, as an own package. Its
121
+ // documents are read with npm's token where the reader may have it
122
+ // (`privileged`, as readNpmVersion), its dependencies' without; what is kept
123
+ // of it is shared only where neither version is private, and OSV is asked of
124
+ // its advisories only where it isn't, a private package's name never sent to
125
+ // OSV, as it never is to Socket (http.ts). A `spare` one, no
126
+ // reader waiting on it, is made only within MAX_SPARE_ASSESSMENTS, and tells
127
+ // its dependencies without asking npm of their packages (assessNpm
128
+ // `dependencies`), nor its advisories, which a scan asks of a lockfile's
129
+ // packages at once (repository-risk.ts).
130
+ export function npmRisk(doc: NpmVersionDocument, old: NpmVersionDocument | 'pick' | null,
131
+ { teamScoped, ownScope = () => Promise.resolve(false), fromEmpty = false, privileged, signal, spare = false, ...stores }: NpmRiskStores & { teamScoped: boolean;
132
+ ownScope?: (scope: string) => Promise<boolean>; fromEmpty?: boolean; privileged: boolean; signal: AbortSignal; spare?: boolean }): Promise<NpmAssessment> {
133
+ const scopes = new Map<string, Promise<boolean>>()
134
+ const owned = (name: string) => {
135
+ const scope = npmPackageScope(name)
136
+ if (scope === null) return false
137
+ if (!scopes.has(scope)) scopes.set(scope, ownScope(scope))
138
+ return scopes.get(scope)!
139
+ }
140
+ const given = old === 'pick' ? null : old
141
+ const documentOf = async (version: string) => [doc, given].find(read => read?.version === version) ?? await readNpmVersion(doc.name, version, privileged, signal) ?? undefined
142
+ return bounded(spare, () => assessNpm(doc.name, doc.version, { oldVersion: old === 'pick' ? true : old?.version ?? null, fromEmpty,
143
+ minimumReleaseAge: teamScoped ? 0 : DEFAULT_MINIMUM_RELEASE_AGE,
144
+ popularity: !teamScoped, cache: npmRiskCache(stores, !doc.private && !given?.private), signal, dependencies: !spare, advisories: !spare, osv: !doc.private, owned,
145
+ packument: (name, { authorized }) => readNpmPackument(name, privileged && authorized, signal),
146
+ tarball: async (_name, version) => tarballOf(await documentOf(version), signal) }))
147
+ }
148
+
149
+ // An assessment made within MAX_ACTIVE_ASSESSMENTS, a `spare` one within
150
+ // MAX_SPARE_ASSESSMENTS, all across the server; one past them refused as
151
+ // busy (`npm-busy`).
152
+ async function bounded(spare: boolean, assess: () => Promise<NpmAssessment>): Promise<NpmAssessment> {
153
+ if (assessing >= (spare ? MAX_SPARE_ASSESSMENTS : MAX_ACTIVE_ASSESSMENTS)) throw new NpmPackageError(429, 'npm-busy')
154
+ assessing++
155
+ try { return await assess() } finally { assessing-- }
156
+ }
157
+
158
+ // A version npm has no document of, as risk tells one npm removed, as it
159
+ // removes every one it finds malware in (assessNpm): its package taken down
160
+ // by npm, as a scan's (`spare`), and its advisories of malware, npm's and,
161
+ // where anyone can read its package, OSV's, which alone has some of those
162
+ // npm removed (nx's), a private package's name never sent to OSV. Its
163
+ // package's document read anonymously, else with npm's token where the
164
+ // reader may have it (`privileged`), and kept as a private package's is; no
165
+ // tarball read, nothing of the version being left. Spare, within
166
+ // MAX_SPARE_ASSESSMENTS as a scan's others are. An NpmRiskError where
167
+ // nothing tells of it.
168
+ export function npmRiskRemoved(name: string, version: string,
169
+ { teamScoped, privileged, signal, ...stores }: NpmRiskStores & { teamScoped: boolean; privileged: boolean; signal: AbortSignal }): Promise<NpmAssessment> {
170
+ return bounded(true, async () => {
171
+ const open = await readNpmPackument(name, false, signal)
172
+ return assessNpm(name, version, { minimumReleaseAge: teamScoped ? 0 : DEFAULT_MINIMUM_RELEASE_AGE, popularity: false, downloads: false, dependencies: false,
173
+ osv: open !== null, cache: npmRiskCache(stores, false), signal,
174
+ packument: (read, { authorized }) => read === name && open ? Promise.resolve(open) : readNpmPackument(read, privileged && authorized, signal),
175
+ tarball: () => Promise.reject(new NpmPackageError(502, 'upstream-invalid')) })
176
+ })
177
+ }
@@ -0,0 +1,352 @@
1
+ // Minified files pretty-printed for the Code tab (GET /api/bundles/:id/pretty
2
+ // one file at a time, GET /api/npm/pretty all a version's at once), and the
3
+ // files that changed between two bundles or versions for Compare (the same
4
+ // with `other`, or `otherVersion`), named by path and content hash, formatted
5
+ // with oxfmt once and kept Brotli-encoded by that hash: a bundle's in its
6
+ // cache directory (so only made from, and read by readers of, that bundle), a
7
+ // public npm version's under `cache/npm/`; a private version's are never
8
+ // kept. Access is the caller's to check.
9
+ import { Buffer } from 'node:buffer'
10
+ import { Readable } from 'node:stream'
11
+ import { promisify } from 'node:util'
12
+ import { brotliDecompress } from 'node:zlib'
13
+ import { format } from '@preventive/compare/pretty.js'
14
+ import { computeFileHash } from '@preventive/report'
15
+ import { FILE_HASH } from '../common/bundle-metadata.js'
16
+ import { bundleCompareFiles } from '../common/bundle-compare-inputs.js'
17
+ import { bundlePackageDirs, bundleSourcesAsMap } from '../common/bundle-sources.js'
18
+ import { MAX_PRETTY_BYTES, needsPrettyPrint, prettyExtension } from '../common/pretty-print.js'
19
+ import type { OpenedBlob } from './blob-store.ts'
20
+ import { type BundleCacheStorage, readBundleDetails } from './bundle-cache.ts'
21
+ import type { BundleStore } from './bundle-store.ts'
22
+ import { encodeBrotli } from './brotli.ts'
23
+ import { CacheMissError, type CacheStorage } from './cache-storage.ts'
24
+ import type { ManagedBundle, ManagedDb } from './db.ts'
25
+ import { loadNpmFiles } from './npm-loads.ts'
26
+ import { type NpmVersionDocument, npmFileText } from './npm-packages.ts'
27
+
28
+ // Part of every kept copy's name; bumped when what the formatter writes for
29
+ // the same file changes, or what its check lets through, so copies written
30
+ // before are no longer read.
31
+ const PRETTY_VERSION = 3
32
+ // Formats running at once per process, each holding its file's syntax tree,
33
+ // and those waiting their turn past them (a version's files are formatted one
34
+ // after another, beside other readers'), any more refused.
35
+ const MAX_ACTIVE_FORMATS = 2
36
+ const MAX_WAITING_FORMATS = 16
37
+
38
+ export class PrettyError extends Error {
39
+ status: number
40
+ constructor(status: number, code: string) { super(code); this.status = status }
41
+ }
42
+
43
+ export interface PrettyRequest { path: string; hash: string; extension: string }
44
+
45
+ // One of a version's files pretty-printed (npmAll), named by `source`, the
46
+ // hash of the file it was formatted from: its copy's text, or why it has
47
+ // none, as a PrettyError tells it.
48
+ export type PrettyLine = { path: string; source: string; text: string } | { path: string; source: string; error: string; status: number }
49
+
50
+ // Which of a comparison's two its files are of: the bundle or version asked
51
+ // about, or the `other`.
52
+ type Side = 'base' | 'other'
53
+
54
+ // One of a comparison's changed files pretty-printed (compared), as a
55
+ // version's is, with the side it is of.
56
+ export type ComparedLine = PrettyLine & { side: Side }
57
+
58
+ // A side of a comparison: its files that can be formatted, with their texts,
59
+ // and every one of its files' hashes, by path; its packages' directories by
60
+ // path, as a bundle records them (bundlePackageDirs), or null where it
61
+ // records none; the copy kept for one, or null; and one made, kept where the
62
+ // side keeps copies.
63
+ export interface ComparedSide {
64
+ side: Side
65
+ files: Array<PrettyRequest & { text: string }>
66
+ hashes: Map<string, string>
67
+ packageDirs: Map<string, string> | null
68
+ kept(request: PrettyRequest): Promise<OpenedBlob | null>
69
+ made(request: PrettyRequest, text: string): Promise<OpenedBlob>
70
+ }
71
+
72
+ // The file a request's `path` and `hash` name, or null for one that can't be
73
+ // formatted.
74
+ export function prettyRequest(params: URLSearchParams): PrettyRequest | null {
75
+ const path = params.get('path') ?? ''
76
+ const extension = prettyExtension(path), hash = params.get('hash') ?? ''
77
+ return extension !== null && FILE_HASH.test(hash) ? { path, hash, extension } : null
78
+ }
79
+
80
+ // A copy's name: its file's hash in hex, with the extension it was parsed by.
81
+ const prettyFile = ({ hash, extension }: PrettyRequest) =>
82
+ `pretty-v${PRETTY_VERSION}/${Buffer.from(hash.slice('sha512-'.length), 'base64').toString('hex')}.${extension}.br`
83
+
84
+ let activeFormats = 0
85
+ const waitingFormats: Array<() => void> = []
86
+
87
+ // The text formatted and Brotli-encoded, where @preventive/compare's checks
88
+ // find formatting changed only its layout: refused as `unsupported` where it
89
+ // has no check for the kind of file, `unformattable` or `pretty-mismatch`.
90
+ export async function prettyBody(text: string, extension: string): Promise<Buffer> {
91
+ if (Buffer.byteLength(text) > MAX_PRETTY_BYTES) throw new PrettyError(413, 'file-too-large')
92
+ if (activeFormats < MAX_ACTIVE_FORMATS) activeFormats++
93
+ else if (waitingFormats.length >= MAX_WAITING_FORMATS) throw new PrettyError(429, 'pretty-busy')
94
+ else await new Promise<void>(resolve => { waitingFormats.push(resolve) })
95
+ let result
96
+ try { result = await format(text, extension) }
97
+ finally {
98
+ const next = waitingFormats.shift()
99
+ if (next) next()
100
+ else activeFormats--
101
+ }
102
+ if ('error' in result) {
103
+ if (result.error === 'pretty-mismatch') console.warn(`managed: pretty-printing a .${extension} file changed more than its layout; not served`)
104
+ throw new PrettyError(422, result.error)
105
+ }
106
+ return encodeBrotli(Buffer.from(result.code))
107
+ }
108
+
109
+ const opened = (body: Buffer): OpenedBlob => ({ size: body.byteLength, stream: Readable.from([body]) })
110
+
111
+ const decompress = promisify(brotliDecompress)
112
+
113
+ // A pretty-printed copy's text, from its Brotli-encoded bytes.
114
+ export async function prettyText(copy: OpenedBlob): Promise<string> {
115
+ const chunks: Buffer[] = []
116
+ for await (const chunk of copy.stream) chunks.push(chunk as Buffer)
117
+ return (await decompress(Buffer.concat(chunks))).toString()
118
+ }
119
+
120
+ // Every file of a version that needs pretty-printing (needsPrettyPrint), read
121
+ // in one load of its files, in path order.
122
+ async function npmPrintable(doc: NpmVersionDocument): Promise<Array<PrettyRequest & { text: string }>> {
123
+ const load = loadNpmFiles(doc)
124
+ const files: Array<PrettyRequest & { text: string }> = []
125
+ try {
126
+ for (const { path, bytes } of await load.result) {
127
+ const extension = prettyExtension(path)
128
+ if (extension === null || bytes.byteLength > MAX_PRETTY_BYTES) continue
129
+ const text = npmFileText(bytes)
130
+ if (text !== null && needsPrettyPrint(path, text)) files.push({ path, hash: await computeFileHash(bytes), extension, text })
131
+ }
132
+ } finally { load.release() }
133
+ return files.toSorted((a, b) => (a.path < b.path ? -1 : 1))
134
+ }
135
+
136
+ // A file's line (PrettyLine): the text of its copy, as `copy` has it, or why
137
+ // it has none.
138
+ async function prettyLine({ path, hash }: PrettyRequest, copy: () => Promise<OpenedBlob>): Promise<PrettyLine> {
139
+ try { return { path, source: hash, text: await prettyText(await copy()) } }
140
+ catch (err) {
141
+ if (!(err instanceof PrettyError)) throw err
142
+ return { path, source: hash, error: err.message, status: err.status }
143
+ }
144
+ }
145
+
146
+ // The files that changed between two sides, pretty-printed: each side's
147
+ // formattable files the other has another text at the path of, or, where it
148
+ // has none there, none of their text at a path of its own (a file renamed, or
149
+ // moved with its tree, unchanged, has it there; a copy at a path both have is
150
+ // not one), by the paths each side has them at. Those are both of the pairs
151
+ // the viewer diffs but where it pairs files otherwise (among copies of one
152
+ // text, across packages), whose copies it asks for alone. As they are
153
+ // iterated, in path order, the base's first where both have one: those kept,
154
+ // at once; then the rest, formatted one after another, the minified before
155
+ // the others (needsPrettyPrint), as the viewer prints those unasked.
156
+ export function compared(sides: readonly [ComparedSide, ComparedSide]): AsyncGenerator<ComparedLine> {
157
+ // Each side's texts at the paths the other hasn't: where a file moved has its own.
158
+ const moved = sides.map((own, i) => new Set([...own.hashes].filter(([path]) => !sides[1 - i]!.hashes.has(path)).map(([, hash]) => hash)))
159
+ const changed = sides.flatMap((own, i) => {
160
+ const other = sides[1 - i]!.hashes
161
+ return own.files.filter(({ path, hash }) => (other.has(path) ? other.get(path) !== hash : !moved[1 - i]!.has(hash))).map(file => ({ own, file }))
162
+ }).toSorted((a, b) => (a.file.path < b.file.path ? -1 : a.file.path > b.file.path ? 1 : 0))
163
+ return (async function* () {
164
+ const unkept: typeof changed = []
165
+ for (const { own, file } of changed) {
166
+ const copy = await own.kept(file)
167
+ if (copy) yield { side: own.side, ...await prettyLine(file, () => Promise.resolve(copy)) }
168
+ else unkept.push({ own, file })
169
+ }
170
+ const minified = new Map(unkept.map(({ file }) => [file, needsPrettyPrint(file.path, file.text)]))
171
+ for (const { own, file } of unkept.toSorted((a, b) => Number(minified.get(b.file)) - Number(minified.get(a.file)))) {
172
+ yield { side: own.side, ...await prettyLine(file, () => own.made(file, file.text)) }
173
+ }
174
+ })()
175
+ }
176
+
177
+ // Jobs by key, readers asking for one while it runs sharing it.
178
+ function sharing<T>(): (key: string, make: () => Promise<T>) => Promise<T> {
179
+ const pending = new Map<string, Promise<T>>()
180
+ return (key, make) => {
181
+ let job = pending.get(key)
182
+ if (!job) pending.set(key, job = make().finally(() => pending.delete(key)))
183
+ return job
184
+ }
185
+ }
186
+
187
+ // Work done one job at a time, in the order asked.
188
+ function serial(): <T>(work: () => Promise<T>) => Promise<T> {
189
+ let queue = Promise.resolve()
190
+ return work => {
191
+ const job = queue.then(work)
192
+ queue = job.then(() => undefined, () => undefined)
193
+ return job
194
+ }
195
+ }
196
+
197
+ async function keep(put: () => Promise<void>) {
198
+ try { await put() } catch (err) { console.warn('managed: pretty-print cache write failed:', err) }
199
+ }
200
+
201
+ // A copy kept, or null where none is.
202
+ async function kept(open: () => Promise<OpenedBlob>): Promise<OpenedBlob | null> {
203
+ try { return await open() }
204
+ catch (err) { if (err instanceof CacheMissError) return null; throw err }
205
+ }
206
+
207
+ // A side's files that can be formatted, with their texts, and every one of
208
+ // its files' hashes by path, from their paths and contents: texts, or bytes,
209
+ // read as text where they are.
210
+ async function sideFiles(entries: Iterable<[string, string | Uint8Array]>): Promise<Pick<ComparedSide, 'files' | 'hashes'>> {
211
+ const files: ComparedSide['files'] = [], hashes = new Map<string, string>()
212
+ for (const [path, content] of entries) {
213
+ const hash = await computeFileHash(content)
214
+ hashes.set(path, hash)
215
+ const extension = prettyExtension(path)
216
+ if (extension === null || (typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength) > MAX_PRETTY_BYTES) continue
217
+ const text = typeof content === 'string' ? content : npmFileText(content)
218
+ if (text !== null) files.push({ path, hash, extension, text })
219
+ }
220
+ return { files, hashes }
221
+ }
222
+
223
+ // A bundle's side, from its details: its files, as sideFiles reads them,
224
+ // within a reason `scope` as Compare reads them there (bundleCompareFiles),
225
+ // and its packages' directories.
226
+ async function bundleSide(details: Parameters<typeof bundlePackageDirs>[0], scope: string): Promise<Pick<ComparedSide, 'files' | 'hashes' | 'packageDirs'>> {
227
+ const scoped = scope ? new Set(bundleCompareFiles(details, scope).keys()) : null, sources = bundleSourcesAsMap(details)
228
+ return { ...await sideFiles(scoped ? [...sources].filter(([path]) => scoped.has(path)) : sources), packageDirs: bundlePackageDirs(details) }
229
+ }
230
+
231
+ // A version's files by path, from one load of them.
232
+ async function npmFiles(doc: NpmVersionDocument): Promise<Array<[string, Uint8Array]>> {
233
+ const load = loadNpmFiles(doc)
234
+ try { return (await load.result).map(({ path, bytes }) => [path, bytes]) }
235
+ finally { load.release() }
236
+ }
237
+
238
+ // Removes the public npm copies of every version before PRETTY_VERSION, which
239
+ // nothing reads any more, for maintenance: once a process, again where
240
+ // removing them failed. A bundle's earlier copies go with the bundle.
241
+ export function stalePrettyRemover(npm: CacheStorage): (signal?: AbortSignal) => Promise<void> {
242
+ let removed = false
243
+ return async signal => {
244
+ if (removed) return
245
+ for (let version = 1; version < PRETTY_VERSION; version++) {
246
+ if (signal?.aborted) return
247
+ await npm.delete(`pretty-v${version}`)
248
+ }
249
+ removed = true
250
+ }
251
+ }
252
+
253
+ export function createPrettyCache(bundles: BundleCacheStorage, npm: CacheStorage, db: Pick<ManagedDb, 'getBundle'>, store: BundleStore) {
254
+ // Copies being made; readers asking for one meanwhile share it.
255
+ const shared = sharing<Buffer>()
256
+ // Bundles are read one at a time: reading one decodes all of it.
257
+ const enqueue = serial()
258
+ function bundleDetails(record: ManagedBundle) {
259
+ return enqueue(async () => {
260
+ const details = await readBundleDetails(record, store)
261
+ if (!details) throw new PrettyError(422, 'bundle-unavailable')
262
+ return details
263
+ })
264
+ }
265
+ // A bundle's file pretty-printed from the text `read` reads, and kept in
266
+ // its cache.
267
+ function bundleCopy(record: ManagedBundle, request: PrettyRequest, read: () => Promise<string>): Promise<Buffer> {
268
+ const file = prettyFile(request)
269
+ return shared(`bundle ${record.id} ${file}`, async () => {
270
+ const body = await prettyBody(await read(), request.extension)
271
+ // As for its metadata (bundle-cache.ts): a bundle deleted meanwhile
272
+ // keeps no copy, nor does one deleted while it was written.
273
+ if (!await db.getBundle(record.id)) throw new PrettyError(404, 'no-bundle')
274
+ await keep(() => bundles.put(record.id, file, body))
275
+ if (!await db.getBundle(record.id)) {
276
+ await bundles.delete(record.id)
277
+ throw new PrettyError(404, 'no-bundle')
278
+ }
279
+ return body
280
+ })
281
+ }
282
+ // A version's file pretty-printed, `read` reading its text (and checking
283
+ // it is the file named): from the copy kept for a public version, else
284
+ // made, and kept for a public one.
285
+ async function npmCopy(doc: NpmVersionDocument, request: PrettyRequest, read: () => Promise<string>): Promise<OpenedBlob> {
286
+ const file = prettyFile(request)
287
+ if (!doc.private) {
288
+ try { return await npm.open(file) }
289
+ catch (err) { if (!(err instanceof CacheMissError)) throw err }
290
+ }
291
+ return opened(await shared(doc.private ? `npm ${doc.dist.integrity} ${file}` : `npm ${file}`, async () => {
292
+ const body = await prettyBody(await read(), request.extension)
293
+ if (!doc.private) await keep(() => npm.put(file, body))
294
+ return body
295
+ }))
296
+ }
297
+ return {
298
+ async bundle(record: ManagedBundle, request: PrettyRequest): Promise<OpenedBlob> {
299
+ const copy = await kept(() => bundles.open(record.id, prettyFile(request)))
300
+ if (copy) return copy
301
+ return opened(await bundleCopy(record, request, async () => {
302
+ const text = bundleSourcesAsMap(await bundleDetails(record)).get(request.path)
303
+ if (text === undefined) throw new PrettyError(404, 'no-file')
304
+ if (await computeFileHash(text) !== request.hash) throw new PrettyError(409, 'hash-mismatch')
305
+ return text
306
+ }))
307
+ },
308
+ // The version's file, from its files loaded as the viewer's are (and
309
+ // held to the same few loads at once).
310
+ npm(doc: NpmVersionDocument, request: PrettyRequest): Promise<OpenedBlob> {
311
+ return npmCopy(doc, request, async () => {
312
+ const load = loadNpmFiles(doc)
313
+ let text: string | null
314
+ try {
315
+ const bytes = (await load.result).find(entry => entry.path === request.path)?.bytes
316
+ if (!bytes) throw new PrettyError(404, 'no-file')
317
+ if (await computeFileHash(bytes) !== request.hash) throw new PrettyError(409, 'hash-mismatch')
318
+ text = npmFileText(bytes)
319
+ } finally { load.release() }
320
+ if (text === null) throw new PrettyError(422, 'unformattable')
321
+ return text
322
+ })
323
+ },
324
+ // Every file of the version that needs pretty-printing (npmPrintable),
325
+ // read before this resolves; then, as they are iterated, each
326
+ // pretty-printed as `npm` has it, one after another.
327
+ async npmAll(doc: NpmVersionDocument): Promise<AsyncGenerator<PrettyLine>> {
328
+ const files = await npmPrintable(doc)
329
+ return (async function* () {
330
+ for (const { text, ...request } of files) yield await prettyLine(request, () => npmCopy(doc, request, () => Promise.resolve(text)))
331
+ })()
332
+ },
333
+ // Two bundles' sides, or two versions', as a comparison reads them, read
334
+ // before this resolves: for the files that changed between them
335
+ // pretty-printed (compared), and their diffs with renamed names hidden
336
+ // (rename-diff.ts renamedPairs); a bundle's within a reason `scope`
337
+ // where one is asked for, as Compare reads it then.
338
+ async bundleSides(base: ManagedBundle, other: ManagedBundle, scope = ''): Promise<[ComparedSide, ComparedSide]> {
339
+ const sideOf = async (record: ManagedBundle, side: Side): Promise<ComparedSide> => ({ side, ...await bundleSide(await bundleDetails(record), scope),
340
+ kept: request => kept(() => bundles.open(record.id, prettyFile(request))),
341
+ made: async (request, text) => opened(await bundleCopy(record, request, () => Promise.resolve(text))) })
342
+ return [await sideOf(base, 'base'), await sideOf(other, 'other')]
343
+ },
344
+ async npmSides(base: NpmVersionDocument, other: NpmVersionDocument): Promise<[ComparedSide, ComparedSide]> {
345
+ const sideOf = async (doc: NpmVersionDocument, side: Side): Promise<ComparedSide> => ({ side, ...await sideFiles(await npmFiles(doc)), packageDirs: null,
346
+ kept: request => (doc.private ? Promise.resolve(null) : kept(() => npm.open(prettyFile(request)))),
347
+ made: (request, text) => npmCopy(doc, request, () => Promise.resolve(text)) })
348
+ return [await sideOf(base, 'base'), await sideOf(other, 'other')]
349
+ },
350
+ }
351
+ }
352
+ export type PrettyCache = ReturnType<typeof createPrettyCache>