@crombolo/server 0.13.2 → 0.14.1

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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Replacing a site with a whole-site file (story 105): what has to be true
3
+ * before it starts, and what the site has that the file does not.
4
+ *
5
+ * `--replace` recycles content and media the file does not carry, so a revert
6
+ * brings them back, and keeps the site's people. `--replace-all` deletes them,
7
+ * and the members and users the file does not carry too.
8
+ */
9
+ import { existsSync } from 'node:fs'
10
+ import { join, sep } from 'node:path'
11
+ import { discoverBundles } from '@crombolo/backoffice-host'
12
+ import {
13
+ currentSchemaState,
14
+ type Db,
15
+ DocumentRepository,
16
+ MemberRepository,
17
+ ObjectTypes,
18
+ TransferRunRepository,
19
+ UserRepository,
20
+ } from '@crombolo/data'
21
+ import type { BundleManifest, BundlePeople, ContentSet } from '@crombolo/transfer'
22
+
23
+ export type ReplaceMode = 'replace' | 'replace-all'
24
+
25
+ /**
26
+ * The server bundles a file needs and this site lacks, or has older, each
27
+ * named with the version wanted.
28
+ */
29
+ export function requiresProblems(manifest: BundleManifest, siteDir: string): string[] {
30
+ const required = Object.entries(manifest.requires ?? {})
31
+ if (required.length === 0) return []
32
+ const installed = new Map(
33
+ discoverBundles(siteDir).bundles.map((found) => [found.packageName, found.version]),
34
+ )
35
+ const problems: string[] = []
36
+ for (const [name, version] of required) {
37
+ const here = installed.get(name)
38
+ if (!here) problems.push(`the file needs ${name}@${version}, which this site does not have`)
39
+ else if (Bun.semver.order(here, version) < 0)
40
+ problems.push(`the file needs ${name}@${version}, and this site has ${here}`)
41
+ }
42
+ return problems
43
+ }
44
+
45
+ /** Whether the file's people include someone who can administer the back office. */
46
+ export function carriesAdministrator(people: BundlePeople | undefined): boolean {
47
+ return (people?.users ?? []).some((user) => !user.disabled && user.groupAliases.includes('admin'))
48
+ }
49
+
50
+ const PRUNED_KINDS = [
51
+ { objectType: ObjectTypes.Document, kind: 'document' },
52
+ { objectType: ObjectTypes.Media, kind: 'media' },
53
+ { objectType: ObjectTypes.Element, kind: 'element' },
54
+ { objectType: ObjectTypes.DocumentBlueprint, kind: 'blueprint' },
55
+ ] as const
56
+
57
+ export interface PrunedNode {
58
+ key: string
59
+ kind: (typeof PRUNED_KINDS)[number]['kind']
60
+ head: number | undefined
61
+ published: number | undefined
62
+ }
63
+
64
+ /**
65
+ * Takes out what the site has and the file does not: the topmost of each such
66
+ * branch, recycled or deleted, inside the caller's transaction. Answers what
67
+ * was recycled, for the run to record so its revert can bring it back.
68
+ */
69
+ export async function pruneSite(
70
+ tx: Db,
71
+ set: ContentSet,
72
+ mode: ReplaceMode,
73
+ nodeId: string,
74
+ ): Promise<PrunedNode[]> {
75
+ const inFile = new Set(set.nodes.map((node) => node.key.toLowerCase()))
76
+ const state = await currentSchemaState(tx)
77
+ const nodeState = { version: state.version, revision: state.revision }
78
+ const pruned: PrunedNode[] = []
79
+ for (const { objectType, kind } of PRUNED_KINDS) {
80
+ const rows = await tx.query<{ id: number; unique_id: string; parent_id: number }>(
81
+ 'SELECT id, unique_id, parent_id FROM node WHERE node_object_type = ? AND trashed = ?',
82
+ [objectType, tx.dialect.boolValue(false)],
83
+ )
84
+ const leaving = rows.filter((row) => !inFile.has(String(row.unique_id).toLowerCase()))
85
+ const leavingIds = new Set(leaving.map((row) => Number(row.id)))
86
+ // The top of each branch: recycling or deleting it takes its children too.
87
+ const tops = leaving.filter((row) => !leavingIds.has(Number(row.parent_id)))
88
+ const repo = new DocumentRepository(tx, { kind, nodeState, nodeId, inTreeLock: true })
89
+ for (const row of tops) {
90
+ const key = String(row.unique_id)
91
+ if (mode === 'replace-all') {
92
+ await repo.delete(key)
93
+ continue
94
+ }
95
+ const markers = await repo.eventMarkers(key)
96
+ await repo.moveToRecycleBin(key)
97
+ pruned.push({ key, kind, head: markers?.head, published: markers?.published })
98
+ }
99
+ }
100
+ return pruned
101
+ }
102
+
103
+ /** Members and users the file does not carry, deleted: `--replace-all` only. */
104
+ export async function prunePeople(tx: Db, people: BundlePeople | undefined): Promise<void> {
105
+ const members = new Set((people?.members ?? []).map((m) => m.key.toLowerCase()))
106
+ const users = new Set((people?.users ?? []).map((u) => u.key.toLowerCase()))
107
+ const state = await currentSchemaState(tx)
108
+ const memberRepo = new MemberRepository(tx, {
109
+ nodeState: { version: state.version, revision: state.revision },
110
+ })
111
+ const memberRows = await tx.query<{ unique_id: string }>(
112
+ 'SELECT unique_id FROM node WHERE node_object_type = ?',
113
+ [ObjectTypes.Member],
114
+ )
115
+ for (const row of memberRows)
116
+ if (!members.has(String(row.unique_id).toLowerCase()))
117
+ await memberRepo.delete(String(row.unique_id))
118
+ const userRepo = new UserRepository(tx)
119
+ for (const row of await tx.query<{ key: string }>('SELECT key FROM user_account'))
120
+ if (!users.has(String(row.key).toLowerCase())) await userRepo.delete(String(row.key))
121
+ }
122
+
123
+ /** Records what a replace recycled on its run, so `content revert` brings it back. */
124
+ export async function recordPruned(tx: Db, runId: string, pruned: PrunedNode[]): Promise<void> {
125
+ const runs = new TransferRunRepository(tx)
126
+ for (const node of pruned)
127
+ await runs.record({
128
+ runId,
129
+ nodeKey: node.key,
130
+ kind: node.kind,
131
+ action: 'recycle',
132
+ beforeEventId: node.head ?? null,
133
+ beforePublishedEventId: node.published ?? null,
134
+ eventId: null,
135
+ })
136
+ }
137
+
138
+ export interface ReplaceSummary {
139
+ /** What the site has that the file does not, by kind: recycled, or deleted. */
140
+ leaving: Record<string, number>
141
+ /** Files in the site's folders that the file does not carry. */
142
+ filesRemoved: string[]
143
+ /** Members and users `--replace-all` would delete. */
144
+ people: { members: number; users: number }
145
+ }
146
+
147
+ /** Read-only: what replacing this site with the file would take out. */
148
+ export async function replaceSummary(
149
+ db: Db,
150
+ set: ContentSet,
151
+ carriedPaths: ReadonlySet<string>,
152
+ folders: ReadonlyArray<{ section: string; dir: string }>,
153
+ mode: ReplaceMode,
154
+ ): Promise<ReplaceSummary> {
155
+ const inFile = new Set(set.nodes.map((node) => node.key.toLowerCase()))
156
+ const leaving: Record<string, number> = {}
157
+ for (const { objectType, kind } of PRUNED_KINDS) {
158
+ const rows = await db.query<{ unique_id: string }>(
159
+ 'SELECT unique_id FROM node WHERE node_object_type = ? AND trashed = ?',
160
+ [objectType, db.dialect.boolValue(false)],
161
+ )
162
+ const count = rows.filter((row) => !inFile.has(String(row.unique_id).toLowerCase())).length
163
+ if (count > 0) leaving[kind] = count
164
+ }
165
+ const filesRemoved: string[] = []
166
+ for (const { section, dir } of folders) {
167
+ if (!existsSync(dir)) continue
168
+ for (const path of new Bun.Glob('**/*').scanSync({ cwd: dir, onlyFiles: true })) {
169
+ const bundlePath = `${section}/${path.split(sep).join('/')}`
170
+ if (!carriedPaths.has(bundlePath)) filesRemoved.push(join(dir, path))
171
+ }
172
+ }
173
+ let people = { members: 0, users: 0 }
174
+ if (mode === 'replace-all') {
175
+ const members = new Set((set.people?.members ?? []).map((m) => m.key.toLowerCase()))
176
+ const users = new Set((set.people?.users ?? []).map((u) => u.key.toLowerCase()))
177
+ const memberRows = await db.query<{ unique_id: string }>(
178
+ 'SELECT unique_id FROM node WHERE node_object_type = ?',
179
+ [ObjectTypes.Member],
180
+ )
181
+ const userRows = await db.query<{ key: string }>('SELECT key FROM user_account')
182
+ people = {
183
+ members: memberRows.filter((r) => !members.has(String(r.unique_id).toLowerCase())).length,
184
+ users: userRows.filter((r) => !users.has(String(r.key).toLowerCase())).length,
185
+ }
186
+ }
187
+ return { leaving, filesRemoved, people }
188
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Site search for a render (story 140): the published-only index, narrowed to
3
+ * what the person asking may open, each hit read back from the published cache
4
+ * so a page unpublished since it was indexed is never shown. docs/search.md.
5
+ */
6
+ import { branchAccess } from '@crombolo/core'
7
+ import { type Db, PublicAccessRepository, SiteSearch } from '@crombolo/data'
8
+ import {
9
+ excerpt,
10
+ type PublishedCache,
11
+ type RequestMember,
12
+ type SearchResult,
13
+ type SiteSearchPort,
14
+ } from '@crombolo/render'
15
+
16
+ /** The search `search()` calls during one render, for `member` or for nobody signed in. */
17
+ export function siteSearchFor(
18
+ db: Db,
19
+ cache: PublishedCache,
20
+ member: RequestMember | undefined,
21
+ ): SiteSearchPort {
22
+ return async (query, options) => {
23
+ const rules = await new PublicAccessRepository(db).allRules()
24
+ const access = branchAccess(
25
+ rules,
26
+ member ? { username: member.username, groups: member.roles } : undefined,
27
+ )
28
+ const culture = options.culture ?? (await cache.defaultCulture())
29
+ const hits = await new SiteSearch(db).query(query, {
30
+ culture: culture ?? '',
31
+ types: options.types,
32
+ skip: options.skip,
33
+ take: options.take,
34
+ access,
35
+ })
36
+ const nav = await cache.navigation(culture)
37
+ const items: SearchResult[] = []
38
+ for (const hit of hits.items) {
39
+ const content = nav.byKey(hit.key)
40
+ if (!content) continue
41
+ items.push({
42
+ content,
43
+ url: content.url,
44
+ name: content.name,
45
+ score: hit.score,
46
+ excerpt: excerpt(hit.text, query),
47
+ })
48
+ }
49
+ return { total: hits.total, items }
50
+ }
51
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The sections a bundle carries beyond its content — schema, components,
3
+ * styles, scripts and the dictionary — gathered from a site. One place, so the
4
+ * backoffice's Packages builder (picked items) and `crombolo export --site`
5
+ * (everything) compose a bundle the same way. `docs/bundles.md`.
6
+ */
7
+ import { readdir, readFile } from 'node:fs/promises'
8
+ import { join, relative, sep } from 'node:path'
9
+ import type { FileSystemPort } from '@crombolo/api-management'
10
+ import {
11
+ type ComponentFileStore,
12
+ ComponentRepository,
13
+ currentSchemaState,
14
+ type Db,
15
+ DictionaryRepository,
16
+ } from '@crombolo/data'
17
+ import {
18
+ exportSchemaSet,
19
+ fileNameFor,
20
+ type SchemaSet,
21
+ writeContentType,
22
+ writeDataType,
23
+ writeLanguages,
24
+ writeMediaType,
25
+ writeSchemaVersion,
26
+ } from '@crombolo/schema'
27
+ import { BUNDLE_SECTIONS, type BundleFile, type BundleSection } from '@crombolo/transfer'
28
+ import { dictionaryToUdt, writeDictionaryUdt } from './dictionary-transfer.ts'
29
+
30
+ export interface SiteSectionSources {
31
+ db: Db
32
+ /** Reads a template's view, which lives on disk rather than in the row. */
33
+ componentFiles: ComponentFileStore
34
+ components?: FileSystemPort
35
+ stylesheets?: FileSystemPort
36
+ scripts?: FileSystemPort
37
+ /** The schema directory, read whole for `'all'`: the files are the schema. */
38
+ schemaDir?: string
39
+ }
40
+
41
+ /** What a Packages definition picks, by key or alias as the backoffice sends them. */
42
+ export interface SectionPicks {
43
+ contentTypes: readonly string[]
44
+ mediaTypes: readonly string[]
45
+ dataTypes: readonly string[]
46
+ languages: readonly string[]
47
+ /** Template aliases and paths in the components tree, in one list. */
48
+ components: readonly string[]
49
+ stylesheets: readonly string[]
50
+ scripts: readonly string[]
51
+ dictionaryItems: readonly string[]
52
+ }
53
+
54
+ /** Matches a selection against a type's key or its alias, since the UI sends either. */
55
+ const picks = (selected: readonly string[]) => {
56
+ const wanted = new Set(selected.map((value) => value.trim().toLowerCase()))
57
+ return (type: { key?: string; alias: string }): boolean =>
58
+ wanted.has(type.alias.toLowerCase()) ||
59
+ (type.key !== undefined && wanted.has(type.key.toLowerCase()))
60
+ }
61
+
62
+ /** The selected slice of the site's schema, as the files `schema/` would hold. */
63
+ function schemaEntries(set: SchemaSet, from: SectionPicks): BundleFile[] {
64
+ const entries: BundleFile[] = []
65
+ const put = (path: string, text: string) =>
66
+ entries.push({ path: `${BUNDLE_SECTIONS.schema}/${path}`, text })
67
+
68
+ const contentTypes = set.contentTypes.filter(picks(from.contentTypes))
69
+ const mediaTypes = (set.mediaTypes ?? []).filter(picks(from.mediaTypes))
70
+ // Member types have no picker in the builder, and the contract's definition
71
+ // has no field for them, so a picked bundle never carries one.
72
+ const dataTypes = set.dataTypes.filter(picks(from.dataTypes))
73
+ const languages = set.languages.filter((language) =>
74
+ from.languages.some((pick) => pick.toLowerCase() === language.iso.toLowerCase()),
75
+ )
76
+
77
+ if (
78
+ contentTypes.length === 0 &&
79
+ mediaTypes.length === 0 &&
80
+ dataTypes.length === 0 &&
81
+ languages.length === 0
82
+ )
83
+ return entries
84
+
85
+ put('schema.toml', writeSchemaVersion(set.version))
86
+ for (const type of contentTypes)
87
+ put(join('content-types', fileNameFor(type.alias)), writeContentType(type))
88
+ for (const type of mediaTypes)
89
+ put(join('media-types', fileNameFor(type.alias)), writeMediaType(type))
90
+ for (const type of dataTypes)
91
+ put(join('data-types', fileNameFor(type.alias)), writeDataType(type))
92
+ if (languages.length > 0) put('languages.toml', writeLanguages(languages))
93
+ return entries
94
+ }
95
+
96
+ /** Every `.toml` under the schema directory, as it stands: the whole site's schema. */
97
+ async function wholeSchema(dir: string | undefined): Promise<BundleFile[]> {
98
+ if (!dir) return []
99
+ const files: BundleFile[] = []
100
+ const walk = async (at: string): Promise<void> => {
101
+ let entries: import('node:fs').Dirent[]
102
+ try {
103
+ entries = await readdir(at, { withFileTypes: true })
104
+ } catch {
105
+ return
106
+ }
107
+ for (const entry of entries) {
108
+ const path = join(at, entry.name)
109
+ if (entry.isDirectory()) await walk(path)
110
+ else if (entry.name.toLowerCase().endsWith('.toml'))
111
+ files.push({
112
+ path: `${BUNDLE_SECTIONS.schema}/${relative(dir, path).split(sep).join('/')}`,
113
+ text: await readFile(path, 'utf8'),
114
+ })
115
+ }
116
+ }
117
+ await walk(dir)
118
+ return files
119
+ }
120
+
121
+ /** Every file a file area holds, by its path within the area. */
122
+ async function everyPath(port: FileSystemPort | undefined): Promise<string[]> {
123
+ if (!port) return []
124
+ const paths: string[] = []
125
+ const walk = async (parent: string | null): Promise<void> => {
126
+ for (const entry of (await port.children(parent)) ?? []) {
127
+ if (entry.isFolder) await walk(entry.path)
128
+ else paths.push(entry.path)
129
+ }
130
+ }
131
+ await walk(null)
132
+ return paths
133
+ }
134
+
135
+ /**
136
+ * The files a selection of file-system paths resolves to, under one section.
137
+ *
138
+ * The section is a logical name — `styles`, not `css` — because a site holds
139
+ * these wherever it is configured to, and the importer is what maps a section
140
+ * onto a directory.
141
+ */
142
+ async function fileEntries(
143
+ port: FileSystemPort | undefined,
144
+ paths: readonly string[],
145
+ section: BundleSection,
146
+ ): Promise<BundleFile[]> {
147
+ if (!port) return []
148
+ const files: BundleFile[] = []
149
+ for (const path of paths) {
150
+ const file = await port.read(path)
151
+ if (!file) continue
152
+ const within = file.path.replace(/^\/+/, '')
153
+ files.push({ path: `${BUNDLE_SECTIONS[section]}/${within}`, text: file.content })
154
+ }
155
+ return files
156
+ }
157
+
158
+ async function componentEntries(
159
+ sources: SiteSectionSources,
160
+ keys: readonly string[],
161
+ ): Promise<BundleFile[]> {
162
+ if (keys.length === 0) return []
163
+ const templates = new ComponentRepository(sources.db, sources.componentFiles)
164
+ const wanted = new Set(keys.map((key) => key.trim().toLowerCase()))
165
+ const files: BundleFile[] = []
166
+ for (const template of await templates.all()) {
167
+ if (!wanted.has(template.key.toLowerCase()) && !wanted.has(template.alias.toLowerCase()))
168
+ continue
169
+ files.push({
170
+ path: `${BUNDLE_SECTIONS.components}/${template.alias}.tsx`,
171
+ text: template.content ?? '',
172
+ })
173
+ }
174
+ return files
175
+ }
176
+
177
+ /**
178
+ * The components a bundle carries, from one list that holds two kinds of name.
179
+ *
180
+ * The contract has `templates` and `partialViews`; this side has one list, so a
181
+ * picked component arrives either as a template alias — read through the alias
182
+ * — or as a path in the components tree. Both are tried and the results are
183
+ * keyed by path, so a component picked both ways is carried once.
184
+ */
185
+ async function componentSection(
186
+ sources: SiteSectionSources,
187
+ picked: readonly string[],
188
+ ): Promise<BundleFile[]> {
189
+ const byPath = new Map<string, BundleFile>()
190
+ for (const file of await componentEntries(sources, picked)) byPath.set(file.path, file)
191
+ for (const file of await fileEntries(sources.components, picked, 'components'))
192
+ byPath.set(file.path, file)
193
+ return [...byPath.values()]
194
+ }
195
+
196
+ /** The dictionary as one `.udt`: the picked items, or every root item when none are named. */
197
+ async function dictionaryEntries(db: Db, keys: readonly string[] | 'all'): Promise<BundleFile[]> {
198
+ const repo = new DictionaryRepository(db)
199
+ const items =
200
+ keys === 'all'
201
+ ? await dictionaryToUdt(repo)
202
+ : (await Promise.all(keys.map((key) => dictionaryToUdt(repo, key)))).flat()
203
+ if (items.length === 0) return []
204
+ return [{ path: `${BUNDLE_SECTIONS.dictionary}.udt`, text: writeDictionaryUdt(items) }]
205
+ }
206
+
207
+ /** The picked sections, or with `'all'` every one of them in full. */
208
+ export async function siteSections(
209
+ sources: SiteSectionSources,
210
+ selection: SectionPicks | 'all',
211
+ ): Promise<BundleFile[]> {
212
+ if (selection === 'all') {
213
+ return [
214
+ ...(await wholeSchema(sources.schemaDir)),
215
+ ...(await fileEntries(sources.components, await everyPath(sources.components), 'components')),
216
+ ...(await fileEntries(sources.stylesheets, await everyPath(sources.stylesheets), 'styles')),
217
+ ...(await fileEntries(sources.scripts, await everyPath(sources.scripts), 'scripts')),
218
+ ...(await dictionaryEntries(sources.db, 'all')),
219
+ ]
220
+ }
221
+ // The schema version the site is actually at, so the files a package carries
222
+ // say where they came from rather than claiming version 0.
223
+ const state = await currentSchemaState(sources.db).catch(() => undefined)
224
+ const schema = await exportSchemaSet(sources.db, state?.version ?? '0')
225
+ return [
226
+ ...schemaEntries(schema, selection),
227
+ ...(await componentSection(sources, selection.components)),
228
+ ...(await fileEntries(sources.stylesheets, selection.stylesheets, 'styles')),
229
+ ...(await fileEntries(sources.scripts, selection.scripts, 'scripts')),
230
+ ...(selection.dictionaryItems.length > 0
231
+ ? await dictionaryEntries(sources.db, selection.dictionaryItems)
232
+ : []),
233
+ ]
234
+ }
package/src/tar.ts CHANGED
@@ -7,6 +7,12 @@
7
7
  * `Bun.gzipSync` does the compression. Timestamps are fixed, as npm fixes them,
8
8
  * so the same selection packs to the same bytes.
9
9
  */
10
+ import { once } from 'node:events'
11
+ import { createReadStream, createWriteStream, type WriteStream } from 'node:fs'
12
+ import { mkdir } from 'node:fs/promises'
13
+ import { dirname, join } from 'node:path'
14
+ import { pipeline } from 'node:stream/promises'
15
+ import { createGunzip, createGzip, type Gzip } from 'node:zlib'
10
16
 
11
17
  export interface TarEntry {
12
18
  /** The path inside the package, `/`-separated and never absolute. */
@@ -83,3 +89,152 @@ export function writeNpmTarball(entries: readonly TarEntry[]): Uint8Array<ArrayB
83
89
  }
84
90
  return Bun.gzipSync(tar) as Uint8Array<ArrayBuffer>
85
91
  }
92
+
93
+ const padding = (size: number) => (BLOCK - (size % BLOCK)) % BLOCK
94
+
95
+ const checkInside = (path: string) => {
96
+ if (path.startsWith('/') || path.split('/').includes('..'))
97
+ throw new Error(`${path} is not a path inside the package`)
98
+ }
99
+
100
+ /**
101
+ * An npm package tarball written as it goes (story 104), for a package too big
102
+ * to hold in memory: entries are gzipped straight to the file, and a file's
103
+ * bytes may arrive as a stream. The layout is `writeNpmTarball`'s.
104
+ */
105
+ export class TarballWriter {
106
+ #gzip: Gzip
107
+ #done: Promise<void>
108
+
109
+ private constructor(file: string) {
110
+ this.#gzip = createGzip()
111
+ const out = createWriteStream(file)
112
+ this.#done = pipeline(this.#gzip, out)
113
+ }
114
+
115
+ static create(file: string): TarballWriter {
116
+ return new TarballWriter(file)
117
+ }
118
+
119
+ async #write(bytes: Uint8Array): Promise<void> {
120
+ if (!this.#gzip.write(bytes)) await once(this.#gzip, 'drain')
121
+ }
122
+
123
+ async add(path: string, bytes: Uint8Array): Promise<void> {
124
+ checkInside(path)
125
+ await this.#write(header(`package/${path}`, bytes.length))
126
+ await this.#write(bytes)
127
+ const pad = padding(bytes.length)
128
+ if (pad > 0) await this.#write(new Uint8Array(pad))
129
+ }
130
+
131
+ /** A file whose `size` is known and whose bytes arrive in chunks; refused if they come to another size. */
132
+ async addStream(path: string, size: number, chunks: AsyncIterable<Uint8Array>): Promise<void> {
133
+ checkInside(path)
134
+ await this.#write(header(`package/${path}`, size))
135
+ let written = 0
136
+ for await (const chunk of chunks) {
137
+ written += chunk.length
138
+ if (written > size) throw new Error(`${path} is larger than its declared ${size} bytes`)
139
+ await this.#write(chunk)
140
+ }
141
+ if (written !== size) throw new Error(`${path} was ${written} bytes, not ${size}`)
142
+ const pad = padding(size)
143
+ if (pad > 0) await this.#write(new Uint8Array(pad))
144
+ }
145
+
146
+ async close(): Promise<void> {
147
+ await this.#write(new Uint8Array(BLOCK * 2))
148
+ this.#gzip.end()
149
+ await this.#done
150
+ }
151
+ }
152
+
153
+ const decoder = new TextDecoder()
154
+
155
+ const field = (block: Uint8Array, offset: number, width: number) =>
156
+ decoder.decode(block.subarray(offset, offset + width)).replace(/\0.*$/s, '')
157
+
158
+ /**
159
+ * Unpacks a gzipped tarball into `dir`, streaming, and answers the paths it
160
+ * wrote. npm's `package/` folder is dropped, so `dir` holds what the package
161
+ * holds. Only regular files are written; a path that climbs out is refused.
162
+ */
163
+ export async function extractTarball(file: string, dir: string): Promise<string[]> {
164
+ const written: string[] = []
165
+ let pending = new Uint8Array(0)
166
+ let current: { out: WriteStream; left: number; skip: number } | undefined
167
+ let skipping = 0
168
+ let ended = false
169
+
170
+ const take = (n: number): Uint8Array | undefined => {
171
+ if (pending.length < n) return undefined
172
+ const head = pending.subarray(0, n)
173
+ pending = pending.subarray(n)
174
+ return head
175
+ }
176
+
177
+ const writeTo = async (out: WriteStream, bytes: Uint8Array) => {
178
+ if (!out.write(bytes)) await once(out, 'drain')
179
+ }
180
+
181
+ const finishEntry = async (out: WriteStream) => {
182
+ out.end()
183
+ await once(out, 'finish')
184
+ }
185
+
186
+ for await (const chunk of createReadStream(file).pipe(createGunzip())) {
187
+ const bytes = chunk as Uint8Array
188
+ const joined = new Uint8Array(pending.length + bytes.length)
189
+ joined.set(pending)
190
+ joined.set(bytes, pending.length)
191
+ pending = joined
192
+ while (!ended) {
193
+ if (current) {
194
+ if (current.left > 0) {
195
+ if (pending.length === 0) break
196
+ const part = pending.subarray(0, Math.min(current.left, pending.length))
197
+ pending = pending.subarray(part.length)
198
+ current.left -= part.length
199
+ await writeTo(current.out, part)
200
+ continue
201
+ }
202
+ skipping = current.skip
203
+ await finishEntry(current.out)
204
+ current = undefined
205
+ }
206
+ if (skipping > 0) {
207
+ const part = Math.min(skipping, pending.length)
208
+ pending = pending.subarray(part)
209
+ skipping -= part
210
+ if (skipping > 0) break
211
+ }
212
+ const block = take(BLOCK)
213
+ if (!block) break
214
+ if (block.every((byte) => byte === 0)) {
215
+ ended = true
216
+ break
217
+ }
218
+ const name = field(block, 0, 100)
219
+ const prefix = field(block, 345, 155)
220
+ const size = Number.parseInt(field(block, 124, 12).trim() || '0', 8)
221
+ const type = String.fromCharCode(block[156] ?? 48)
222
+ const full = prefix ? `${prefix}/${name}` : name
223
+ if (type !== '0' && type !== '\0') {
224
+ skipping = size + padding(size)
225
+ continue
226
+ }
227
+ const inside = full.replace(/^package\//, '')
228
+ checkInside(inside)
229
+ const target = join(dir, ...inside.split('/'))
230
+ await mkdir(dirname(target), { recursive: true })
231
+ written.push(inside)
232
+ current = { out: createWriteStream(target), left: size, skip: padding(size) }
233
+ }
234
+ }
235
+ if (current) {
236
+ if (current.left > 0) throw new Error(`${file} ends part-way through a file`)
237
+ await finishEntry(current.out)
238
+ }
239
+ return written
240
+ }