@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.
- package/package.json +12 -12
- package/src/adapters/bundles.ts +20 -139
- package/src/adapters/members.ts +22 -10
- package/src/adapters/schema-proposals.ts +10 -2
- package/src/adapters/users.ts +23 -1
- package/src/backoffice-external.ts +99 -0
- package/src/bundle-install.ts +261 -77
- package/src/bundle-sections.ts +160 -0
- package/src/bundles.ts +4 -0
- package/src/config.ts +15 -1
- package/src/delivery-members.ts +11 -2
- package/src/external-sign-in.ts +270 -0
- package/src/identity-provider-overrides.ts +283 -0
- package/src/identity-providers.ts +185 -0
- package/src/index.ts +8 -0
- package/src/media-store-azure.ts +6 -0
- package/src/media-store-s3.ts +1 -0
- package/src/media-store.ts +21 -0
- package/src/member-auth.ts +232 -15
- package/src/ports.ts +55 -1
- package/src/schema-store.ts +16 -0
- package/src/schema.ts +5 -7
- package/src/server.ts +112 -7
- package/src/site-file.ts +258 -0
- package/src/site-replace.ts +188 -0
- package/src/site-search.ts +51 -0
- package/src/site-sections.ts +234 -0
- package/src/tar.ts +155 -0
- package/src/two-factor.ts +138 -0
|
@@ -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
|
+
}
|