@crombolo/server 0.0.0-stage → 0.9.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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +432 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundles.ts +508 -0
  27. package/src/component-snippets.ts +163 -0
  28. package/src/components.ts +266 -0
  29. package/src/config.ts +548 -0
  30. package/src/content-type-udt.ts +256 -0
  31. package/src/database.ts +109 -0
  32. package/src/diagnostics.ts +143 -0
  33. package/src/dictionary-transfer.ts +123 -0
  34. package/src/domains.ts +384 -0
  35. package/src/editor-types.ts +108 -0
  36. package/src/email-http.ts +79 -0
  37. package/src/email-notice.ts +36 -0
  38. package/src/email-postmark.ts +78 -0
  39. package/src/email-resend.ts +69 -0
  40. package/src/email-ses.ts +307 -0
  41. package/src/email.ts +402 -0
  42. package/src/extensions-notice.ts +35 -0
  43. package/src/file-intake.ts +84 -0
  44. package/src/form-files.ts +241 -0
  45. package/src/form-submit.ts +397 -0
  46. package/src/form-workflows.ts +397 -0
  47. package/src/forms.ts +170 -0
  48. package/src/git-github.ts +171 -0
  49. package/src/git-routes.ts +154 -0
  50. package/src/git.ts +88 -0
  51. package/src/imaging.ts +434 -0
  52. package/src/index.ts +62 -0
  53. package/src/jobs.ts +140 -0
  54. package/src/listen.ts +36 -0
  55. package/src/log-expressions.ts +524 -0
  56. package/src/log-viewer.ts +190 -0
  57. package/src/logging.ts +126 -0
  58. package/src/marketplace.ts +297 -0
  59. package/src/media-files.ts +276 -0
  60. package/src/media-store-azure.ts +218 -0
  61. package/src/media-store-s3.ts +113 -0
  62. package/src/media-store.ts +248 -0
  63. package/src/member-auth.ts +384 -0
  64. package/src/models-builder.ts +106 -0
  65. package/src/no-nodes.ts +64 -0
  66. package/src/not-implemented.ts +44 -0
  67. package/src/oembed.ts +144 -0
  68. package/src/ports.ts +553 -0
  69. package/src/redirects.ts +132 -0
  70. package/src/schema-classify.ts +82 -0
  71. package/src/schema-store.ts +145 -0
  72. package/src/schema.ts +388 -0
  73. package/src/seo.ts +371 -0
  74. package/src/server-events.ts +131 -0
  75. package/src/server.ts +1579 -0
  76. package/src/status.ts +246 -0
  77. package/src/udt.ts +168 -0
  78. package/src/value-schemas.ts +64 -0
  79. package/src/view-runtime.ts +42 -0
  80. package/src/zip.ts +152 -0
  81. package/README.md +0 -3
package/src/status.ts ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * What an environment actually resolved to, without booting it.
3
+ *
4
+ * Everything here is already knowable — which database, which schema state,
5
+ * what is pending, what is outstanding — but until now only by starting a
6
+ * server and reading its banner, which is no use when the question is *why* it
7
+ * will not start.
8
+ *
9
+ * Nothing in here writes. `bootstrapDatabase` migrates and seeds, so it is
10
+ * deliberately not used: asking a production node how it is must never be the
11
+ * thing that changes it.
12
+ */
13
+
14
+ import { existsSync, readFileSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import { ObjectTypes } from '@crombolo/core'
17
+ import {
18
+ connect,
19
+ cromboloPlan,
20
+ currentSchemaState,
21
+ type Db,
22
+ INITIAL_STATE,
23
+ pendingMigrations,
24
+ readLedger,
25
+ readReport,
26
+ readState,
27
+ TransferRunRepository,
28
+ } from '@crombolo/data'
29
+ import { loadSchemaDirectory } from '@crombolo/schema'
30
+ import { type CromboloConfig, describeDatabase, VERSION } from './config.ts'
31
+ import { type EmailAvailability, emailAvailability, resolveEmailPort } from './email.ts'
32
+ import { assertViewRuntime } from './view-runtime.ts'
33
+
34
+ export interface SiteStatus {
35
+ site: {
36
+ name: string
37
+ dir: string
38
+ components: string
39
+ schema: string
40
+ /** Where views are snapshotted so an edit needs no restart. */
41
+ componentsCache: string
42
+ }
43
+ database: {
44
+ dialect: string
45
+ description: string
46
+ reachable: boolean
47
+ /** Why it could not be read, when it could not. */
48
+ problem?: string
49
+ installed: boolean
50
+ }
51
+ schema: {
52
+ /** The version in `schema/schema.toml`, and the revision this node deploys at. */
53
+ files?: string
54
+ revision: string
55
+ /** What the database is at, when it is installed. */
56
+ database?: { version: string; revision: string }
57
+ /** The database is ahead of the files: this node reads and refuses writes. */
58
+ compatibilityMode: boolean
59
+ /** The files are ahead: a sync or an upgrade is pending. */
60
+ pending: boolean
61
+ }
62
+ framework: { pending: string[] }
63
+ content: { documents: number; media: number; elements: number; lastImport?: string }
64
+ /**
65
+ * Open findings by kind. `auto` ones are reported, not counted against the
66
+ * site: "this page is new here" is a note, not a thing to fix.
67
+ */
68
+ findings: { blocking: number; person: number; auto: number; resolved: number }
69
+ versions: { crombolo: string; backoffice?: string; bun: string }
70
+ /**
71
+ * Whether e-mail can leave this site, and what is unavailable when it cannot.
72
+ * Not a problem — no e-mail is a valid configuration — so it is reported
73
+ * rather than counted against the site.
74
+ */
75
+ email: EmailAvailability
76
+ /** Anything that would stop this site working, in the order worth fixing. */
77
+ problems: string[]
78
+ }
79
+
80
+ /** `1.1.0` against `1.0.0`: which way round the two versions are. */
81
+ function compareVersions(a: string, b: string): number {
82
+ const parts = (value: string) => value.split('.').map((part) => Number(part) || 0)
83
+ const [left, right] = [parts(a), parts(b)]
84
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
85
+ const difference = (left[i] ?? 0) - (right[i] ?? 0)
86
+ if (difference !== 0) return difference
87
+ }
88
+ return 0
89
+ }
90
+
91
+ async function contentCounts(db: Db): Promise<SiteStatus['content']> {
92
+ const rows = await db.query<{ node_object_type: string; n: number }>(
93
+ `SELECT node_object_type, COUNT(*) AS n FROM node
94
+ WHERE trashed = ? GROUP BY node_object_type`,
95
+ [db.dialect.boolValue(false)],
96
+ )
97
+ const of = (objectType: string) =>
98
+ Number(rows.find((row) => row.node_object_type === objectType)?.n ?? 0)
99
+ return {
100
+ documents: of(ObjectTypes.Document),
101
+ media: of(ObjectTypes.Media),
102
+ elements: of(ObjectTypes.Element),
103
+ }
104
+ }
105
+
106
+ /** The vendored client's version, which is a fact about this deployment too. */
107
+ function backofficeVersion(): string | undefined {
108
+ for (const base of [join(import.meta.dir, '..', '..'), process.cwd()]) {
109
+ const file = join(base, 'backoffice-dist', 'package.json')
110
+ if (!existsSync(file)) continue
111
+ try {
112
+ return (JSON.parse(readFileSync(file, 'utf8')) as { version?: string }).version
113
+ } catch {
114
+ return undefined
115
+ }
116
+ }
117
+ return undefined
118
+ }
119
+
120
+ export async function siteStatus(config: CromboloConfig): Promise<SiteStatus> {
121
+ const loaded = existsSync(config.schemaDir) ? loadSchemaDirectory(config.schemaDir) : undefined
122
+ const status: SiteStatus = {
123
+ site: {
124
+ name: config.siteName,
125
+ dir: config.siteDir,
126
+ components: config.componentsDir,
127
+ schema: config.schemaDir,
128
+ componentsCache: config.componentsCacheDir,
129
+ },
130
+ database: {
131
+ dialect: config.dialect,
132
+ description: describeDatabase(config),
133
+ reachable: false,
134
+ installed: false,
135
+ },
136
+ schema: {
137
+ files: loaded?.set.version,
138
+ revision: config.schemaRevision,
139
+ compatibilityMode: false,
140
+ pending: false,
141
+ },
142
+ framework: { pending: [] },
143
+ content: { documents: 0, media: 0, elements: 0 },
144
+ findings: { blocking: 0, person: 0, auto: 0, resolved: 0 },
145
+ versions: { crombolo: VERSION, backoffice: backofficeVersion(), bun: Bun.version },
146
+ email: emailAvailability(resolveEmailPort(config)),
147
+ problems: [],
148
+ }
149
+
150
+ // Opening a SQLite file creates it, which is a change — and the question
151
+ // "what is this environment?" must not be the thing that makes one.
152
+ // Checked here as well as at boot, so a deploy learns before it serves: a
153
+ // views cache the JSX runtime does not resolve from fails every render.
154
+ try {
155
+ assertViewRuntime(config.componentsCacheDir)
156
+ } catch (error) {
157
+ status.problems.push((error as Error).message.split('\n')[0] as string)
158
+ }
159
+
160
+ if (
161
+ config.dialect === 'sqlite' &&
162
+ config.sqliteFile !== ':memory:' &&
163
+ !existsSync(config.sqliteFile)
164
+ ) {
165
+ status.database.reachable = true
166
+ status.problems.push('there is no database yet; `crombolo start` will create one')
167
+ return status
168
+ }
169
+
170
+ let db: Db | undefined
171
+ try {
172
+ db = await connect({ file: config.sqliteFile })
173
+ status.database.reachable = true
174
+ try {
175
+ status.database.installed = (await readState(db)) !== INITIAL_STATE
176
+ } catch {
177
+ // Reachable, with no tables in it at all: not installed, rather than
178
+ // unreachable. A half-finished install looks like this.
179
+ status.database.installed = false
180
+ }
181
+
182
+ status.framework.pending = (await pendingMigrations(db, cromboloPlan)).map((m) => m.name)
183
+ if (!status.database.installed) {
184
+ status.problems.push('the database is not installed yet; `crombolo start` will install it')
185
+ return status
186
+ }
187
+ if (status.framework.pending.length > 0 && !config.development)
188
+ status.problems.push(
189
+ `${status.framework.pending.length} framework migration(s) pending: run \`crombolo upgrade\``,
190
+ )
191
+
192
+ const state = await currentSchemaState(db)
193
+ status.schema.database = { version: state.version, revision: state.revision }
194
+ if (loaded) {
195
+ const difference = compareVersions(loaded.set.version, state.version)
196
+ status.schema.compatibilityMode = difference < 0
197
+ status.schema.pending =
198
+ difference > 0 || (difference === 0 && config.schemaRevision > state.revision)
199
+ if (status.schema.compatibilityMode)
200
+ status.problems.push(
201
+ `the database is at ${state.version} and these files are ${loaded.set.version}: reads only, writes refused with 409`,
202
+ )
203
+ if (status.schema.pending)
204
+ status.problems.push(
205
+ 'schema/ is ahead of the database: run `crombolo upgrade check` to see what it would do',
206
+ )
207
+ for (const problem of loaded.problems)
208
+ status.problems.push(`${problem.file}: ${problem.message}`)
209
+ }
210
+
211
+ status.content = await contentCounts(db)
212
+ const [run] = await new TransferRunRepository(db).recent(1)
213
+ if (run) status.content.lastImport = run.startedAt.toISOString()
214
+
215
+ const open = await readReport(db)
216
+ const all = await readReport(db, { includeResolved: true })
217
+ const of = (kind: string) => open.filter((finding) => finding.kind === kind).length
218
+ status.findings = {
219
+ blocking: of('blocking'),
220
+ person: of('person'),
221
+ auto: of('auto'),
222
+ resolved: all.length - open.length,
223
+ }
224
+ const wanting = status.findings.blocking + status.findings.person
225
+ if (wanting > 0)
226
+ status.problems.push(
227
+ `${wanting} finding(s) need somebody: \`crombolo upgrade check\` or the Changes dashboard`,
228
+ )
229
+ // Read so that a database whose ledger cannot be read is noticed here
230
+ // rather than at the next upgrade.
231
+ await readLedger(db)
232
+ } catch (error) {
233
+ status.database.problem = (error as Error).message
234
+ status.problems.push(
235
+ status.database.reachable
236
+ ? `the database could not be read: ${(error as Error).message}`
237
+ : `the database cannot be reached: ${(error as Error).message}`,
238
+ )
239
+ } finally {
240
+ // One exit, so a connection is never left open: on Postgres a leaked
241
+ // session blocks the next `DROP SCHEMA`, and the symptom is a test run
242
+ // that slows to a crawl rather than anything that looks like this.
243
+ await db?.close()
244
+ }
245
+ return status
246
+ }
package/src/udt.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Umbraco's dictionary export format (`.udt`): nested `<DictionaryItem Key Name>`
3
+ * elements, each with a `<Value LanguageCultureAlias>` per translation in CDATA.
4
+ * The reader understands only the XML this format uses: elements, attributes,
5
+ * text, CDATA, comments and the prolog.
6
+ */
7
+
8
+ export interface UdtItem {
9
+ key: string
10
+ name: string
11
+ translations: Array<{ isoCode: string; translation: string }>
12
+ children: UdtItem[]
13
+ }
14
+
15
+ export interface XmlElement {
16
+ name: string
17
+ attributes: Record<string, string>
18
+ children: XmlElement[]
19
+ text: string
20
+ }
21
+
22
+ /** XML text content: the three characters that cannot appear raw. */
23
+ export const escapeText = (value: string) =>
24
+ value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
25
+
26
+ const escapeAttribute = (value: string) =>
27
+ value
28
+ .replaceAll('&', '&amp;')
29
+ .replaceAll('<', '&lt;')
30
+ .replaceAll('>', '&gt;')
31
+ .replaceAll('"', '&quot;')
32
+
33
+ const cdata = (value: string) => `<![CDATA[${value.replaceAll(']]>', ']]]]><![CDATA[>')}]]>`
34
+
35
+ function writeItem(item: UdtItem, indent: string): string {
36
+ const inner = [
37
+ ...item.translations.map(
38
+ (t) =>
39
+ `${indent} <Value LanguageCultureAlias="${escapeAttribute(t.isoCode)}">${cdata(t.translation)}</Value>`,
40
+ ),
41
+ ...item.children.map((child) => writeItem(child, `${indent} `)),
42
+ ]
43
+ const open = `${indent}<DictionaryItem Key="${escapeAttribute(item.key)}" Name="${escapeAttribute(item.name)}"`
44
+ return inner.length === 0
45
+ ? `${open} />`
46
+ : `${open}>\n${inner.join('\n')}\n${indent}</DictionaryItem>`
47
+ }
48
+
49
+ /** One item, and its descendants when they are included, as a `.udt` document. */
50
+ export function writeUdt(item: UdtItem): string {
51
+ return `<?xml version="1.0" encoding="utf-8"?>\n${writeItem(item, '')}`
52
+ }
53
+
54
+ /**
55
+ * Several items as one `.udt` document, under the `<DictionaryItems>` wrapper
56
+ * `readUdt` already accepts — which is what a whole-dictionary export needs,
57
+ * since the tree has more than one root.
58
+ */
59
+ export function writeUdtAll(items: readonly UdtItem[]): string {
60
+ if (items.length === 1) return writeUdt(items[0] as UdtItem)
61
+ const inner = items.map((item) => writeItem(item, ' ')).join('\n')
62
+ return `<?xml version="1.0" encoding="utf-8"?>\n<DictionaryItems>\n${inner}\n</DictionaryItems>`
63
+ }
64
+
65
+ const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
66
+
67
+ const decode = (value: string) =>
68
+ value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, entity: string) => {
69
+ if (entity.startsWith('#x') || entity.startsWith('#X'))
70
+ return String.fromCodePoint(Number.parseInt(entity.slice(2), 16))
71
+ if (entity.startsWith('#')) return String.fromCodePoint(Number.parseInt(entity.slice(1), 10))
72
+ return ENTITIES[entity.toLowerCase()] ?? whole
73
+ })
74
+
75
+ /** Parses a document into its root element; undefined when it is not well formed. */
76
+ export function parseXml(source: string): XmlElement | undefined {
77
+ const root: XmlElement = { name: '#document', attributes: {}, children: [], text: '' }
78
+ const stack: XmlElement[] = [root]
79
+ let i = 0
80
+ const top = () => stack[stack.length - 1] as XmlElement
81
+ while (i < source.length) {
82
+ const lt = source.indexOf('<', i)
83
+ if (lt === -1) {
84
+ top().text += decode(source.slice(i))
85
+ break
86
+ }
87
+ top().text += decode(source.slice(i, lt))
88
+ if (source.startsWith('<![CDATA[', lt)) {
89
+ const end = source.indexOf(']]>', lt)
90
+ if (end === -1) return undefined
91
+ top().text += source.slice(lt + 9, end)
92
+ i = end + 3
93
+ } else if (source.startsWith('<!--', lt)) {
94
+ const end = source.indexOf('-->', lt)
95
+ if (end === -1) return undefined
96
+ i = end + 3
97
+ } else if (source.startsWith('<?', lt) || source.startsWith('<!', lt)) {
98
+ const end = source.indexOf('>', lt)
99
+ if (end === -1) return undefined
100
+ i = end + 1
101
+ } else if (source.startsWith('</', lt)) {
102
+ const end = source.indexOf('>', lt)
103
+ if (end === -1) return undefined
104
+ const name = source.slice(lt + 2, end).trim()
105
+ const closing = stack.pop()
106
+ if (!closing || closing.name !== name) return undefined
107
+ i = end + 1
108
+ } else {
109
+ const tag = /^<([A-Za-z_][\w.:-]*)((?:\s+[\w.:-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/.exec(
110
+ source.slice(lt),
111
+ )
112
+ if (!tag) return undefined
113
+ const element: XmlElement = {
114
+ name: tag[1] as string,
115
+ attributes: {},
116
+ children: [],
117
+ text: '',
118
+ }
119
+ for (const a of (tag[2] ?? '').matchAll(/([\w.:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g))
120
+ element.attributes[a[1] as string] = decode(a[2] ?? a[3] ?? '')
121
+ top().children.push(element)
122
+ if (tag[3] !== '/') stack.push(element)
123
+ i = lt + tag[0].length
124
+ }
125
+ }
126
+ if (stack.length !== 1) return undefined
127
+ return root.children[0]
128
+ }
129
+
130
+ function readItem(element: XmlElement): UdtItem | undefined {
131
+ const key = element.attributes.Key
132
+ const name = element.attributes.Name
133
+ if (!key || !name) return undefined
134
+ const children: UdtItem[] = []
135
+ for (const child of element.children.filter((c) => c.name === 'DictionaryItem')) {
136
+ const item = readItem(child)
137
+ if (!item) return undefined
138
+ children.push(item)
139
+ }
140
+ return {
141
+ key,
142
+ name,
143
+ translations: element.children
144
+ .filter((c) => c.name === 'Value' && c.attributes.LanguageCultureAlias)
145
+ .map((c) => ({ isoCode: c.attributes.LanguageCultureAlias as string, translation: c.text })),
146
+ children,
147
+ }
148
+ }
149
+
150
+ /** The items a `.udt` holds: one root item, or several under `<DictionaryItems>`. */
151
+ export function readUdt(source: string): UdtItem[] | undefined {
152
+ const root = parseXml(source)
153
+ if (!root) return undefined
154
+ const elements =
155
+ root.name === 'DictionaryItems'
156
+ ? root.children.filter((c) => c.name === 'DictionaryItem')
157
+ : root.name === 'DictionaryItem'
158
+ ? [root]
159
+ : []
160
+ if (elements.length === 0) return undefined
161
+ const items: UdtItem[] = []
162
+ for (const element of elements) {
163
+ const item = readItem(element)
164
+ if (!item) return undefined
165
+ items.push(item)
166
+ }
167
+ return items
168
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * JSON Schema for what a property or a whole content type holds.
3
+ *
4
+ * Umbraco derives these from the .NET type a property editor stores, so the
5
+ * names it reports are .NET's. The shape is what a caller needs to validate or
6
+ * generate a value, which for us follows the data type's storage column.
7
+ */
8
+ import type { ValueStorageTypeName } from '@crombolo/core'
9
+
10
+ /** The .NET name Umbraco reports for each storage type. */
11
+ export const VALUE_TYPE_NAMES: Record<ValueStorageTypeName, string> = {
12
+ Ntext: 'System.String',
13
+ Nvarchar: 'System.String',
14
+ Integer: 'System.Int32',
15
+ Decimal: 'System.Decimal',
16
+ Date: 'System.DateTime',
17
+ }
18
+
19
+ export function schemaForStorage(storage: string): Record<string, unknown> {
20
+ switch (storage) {
21
+ case 'Integer':
22
+ return { type: 'integer' }
23
+ case 'Decimal':
24
+ return { type: 'number' }
25
+ case 'Date':
26
+ return { type: 'string', format: 'date-time' }
27
+ default:
28
+ return { type: 'string' }
29
+ }
30
+ }
31
+
32
+ export const valueTypeName = (storage: string): string =>
33
+ VALUE_TYPE_NAMES[storage as ValueStorageTypeName] ?? 'System.String'
34
+
35
+ export interface SchemaProperty {
36
+ alias: string
37
+ name: string
38
+ description: string | null
39
+ mandatory: boolean
40
+ storage: string
41
+ }
42
+
43
+ /** A content type as a JSON Schema object: one property per alias. */
44
+ export function contentTypeJsonSchema(
45
+ type: { alias: string; name: string; description: string | null },
46
+ properties: readonly SchemaProperty[],
47
+ ): Record<string, unknown> {
48
+ const shape: Record<string, unknown> = {}
49
+ for (const property of properties) {
50
+ const schema = schemaForStorage(property.storage) as Record<string, unknown>
51
+ shape[property.alias] = property.description
52
+ ? { ...schema, title: property.name, description: property.description }
53
+ : { ...schema, title: property.name }
54
+ }
55
+ const required = properties.filter((p) => p.mandatory).map((p) => p.alias)
56
+ return {
57
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
58
+ title: type.name,
59
+ description: type.description ?? undefined,
60
+ type: 'object',
61
+ properties: shape,
62
+ ...(required.length > 0 ? { required } : {}),
63
+ }
64
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Proving a directory can import a view, before one has to.
3
+ *
4
+ * JSX compiles to an import of `@crombolo/crombolo/jsx-runtime`, which resolves by walking
5
+ * up from the file that needs it. So a views directory — or a snapshot of one —
6
+ * outside the site's own tree cannot load at all:
7
+ *
8
+ * Cannot find package '@crombolo/crombolo' imported from /mnt/views/homePage.tsx
9
+ *
10
+ * Checked at boot because the alternative is every render failing with a message
11
+ * that names the runtime rather than the configuration that moved it.
12
+ */
13
+ import { mkdirSync } from 'node:fs'
14
+
15
+ export const JSX_RUNTIME = '@crombolo/crombolo/jsx-runtime'
16
+
17
+ export class ViewRuntimeUnreachableError extends Error {
18
+ constructor(
19
+ readonly dir: string,
20
+ readonly reason: string,
21
+ ) {
22
+ super(
23
+ `Views cannot be imported from ${dir}: '${JSX_RUNTIME}' does not resolve from there (${reason}).\n` +
24
+ ' JSX compiles to an import of it, resolved by walking up from the file, so the directory\n' +
25
+ ' has to sit inside a tree whose node_modules carries @crombolo/crombolo. Move it under the site, or\n' +
26
+ ' set CROMBOLO_COMPONENTS_CACHE_DIR to somewhere that is.',
27
+ )
28
+ }
29
+ }
30
+
31
+ /** Throws unless a `.tsx` placed in `dir` could resolve the JSX runtime. */
32
+ export function assertViewRuntime(dir: string): void {
33
+ mkdirSync(dir, { recursive: true })
34
+ try {
35
+ Bun.resolveSync(JSX_RUNTIME, dir)
36
+ } catch (error) {
37
+ throw new ViewRuntimeUnreachableError(
38
+ dir,
39
+ error instanceof Error ? error.message : String(error),
40
+ )
41
+ }
42
+ }
package/src/zip.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * A minimal zip writer, because a created package is a zip and nothing else
3
+ * here needs one (`docs/17-bundles.md`).
4
+ *
5
+ * No dependency: `node:zlib` provides `deflateRawSync` and `crc32`, which is
6
+ * everything the format needs. Entries are written in the order given, each
7
+ * stored if deflating does not make it smaller, and timestamps are fixed at the
8
+ * epoch the format starts from so the same selection zips to the same bytes.
9
+ */
10
+ import { crc32, deflateRawSync } from 'node:zlib'
11
+
12
+ export interface ZipEntry {
13
+ /** The path inside the archive, `/`-separated and never absolute. */
14
+ path: string
15
+ bytes: Uint8Array
16
+ }
17
+
18
+ const LOCAL_HEADER = 0x04034b50
19
+ const CENTRAL_HEADER = 0x02014b50
20
+ const END_OF_CENTRAL = 0x06054b50
21
+
22
+ /** Deflate, or store when deflating gains nothing — as every zip writer does. */
23
+ const STORED = 0
24
+ const DEFLATED = 8
25
+
26
+ /**
27
+ * 1980-01-01 00:00:00, the earliest the DOS field can express. A real time
28
+ * would make the bytes depend on when the download happened, and nothing reads
29
+ * these timestamps.
30
+ */
31
+ const DOS_DATE = 0x0021
32
+ const DOS_TIME = 0x0000
33
+
34
+ /** Bit 11: the name is UTF-8, so a non-ASCII path survives the round trip. */
35
+ const UTF8_FLAG = 0x0800
36
+
37
+ class Writer {
38
+ #chunks: Uint8Array[] = []
39
+ #length = 0
40
+
41
+ get length(): number {
42
+ return this.#length
43
+ }
44
+
45
+ bytes(value: Uint8Array): void {
46
+ this.#chunks.push(value)
47
+ this.#length += value.length
48
+ }
49
+
50
+ u16(value: number): void {
51
+ const view = new Uint8Array(2)
52
+ new DataView(view.buffer).setUint16(0, value, true)
53
+ this.bytes(view)
54
+ }
55
+
56
+ u32(value: number): void {
57
+ const view = new Uint8Array(4)
58
+ new DataView(view.buffer).setUint32(0, value >>> 0, true)
59
+ this.bytes(view)
60
+ }
61
+
62
+ concat(): Uint8Array<ArrayBuffer> {
63
+ const out = new Uint8Array(this.#length)
64
+ let offset = 0
65
+ for (const chunk of this.#chunks) {
66
+ out.set(chunk, offset)
67
+ offset += chunk.length
68
+ }
69
+ return out
70
+ }
71
+ }
72
+
73
+ interface Compressed {
74
+ method: number
75
+ crc: number
76
+ deflated: Uint8Array
77
+ size: number
78
+ }
79
+
80
+ function compress(bytes: Uint8Array): Compressed {
81
+ const crc = crc32(bytes) >>> 0
82
+ if (bytes.length === 0) return { method: STORED, crc, deflated: bytes, size: 0 }
83
+ const deflated = new Uint8Array(deflateRawSync(bytes))
84
+ return deflated.length < bytes.length
85
+ ? { method: DEFLATED, crc, deflated, size: bytes.length }
86
+ : { method: STORED, crc, deflated: bytes, size: bytes.length }
87
+ }
88
+
89
+ /**
90
+ * The archive as one buffer. Package zips are a schema directory, some views
91
+ * and a media library's worth of images — large enough to care about, small
92
+ * enough that streaming would buy nothing but complexity.
93
+ */
94
+ export function writeZip(entries: readonly ZipEntry[]): Uint8Array<ArrayBuffer> {
95
+ const encoder = new TextEncoder()
96
+ const body = new Writer()
97
+ const central = new Writer()
98
+
99
+ for (const entry of entries) {
100
+ const name = encoder.encode(entry.path)
101
+ const { method, crc, deflated, size } = compress(entry.bytes)
102
+ const offset = body.length
103
+
104
+ body.u32(LOCAL_HEADER)
105
+ body.u16(20)
106
+ body.u16(UTF8_FLAG)
107
+ body.u16(method)
108
+ body.u16(DOS_TIME)
109
+ body.u16(DOS_DATE)
110
+ body.u32(crc)
111
+ body.u32(deflated.length)
112
+ body.u32(size)
113
+ body.u16(name.length)
114
+ body.u16(0)
115
+ body.bytes(name)
116
+ body.bytes(deflated)
117
+
118
+ central.u32(CENTRAL_HEADER)
119
+ central.u16(20)
120
+ central.u16(20)
121
+ central.u16(UTF8_FLAG)
122
+ central.u16(method)
123
+ central.u16(DOS_TIME)
124
+ central.u16(DOS_DATE)
125
+ central.u32(crc)
126
+ central.u32(deflated.length)
127
+ central.u32(size)
128
+ central.u16(name.length)
129
+ central.u16(0)
130
+ central.u16(0)
131
+ central.u16(0)
132
+ central.u16(0)
133
+ // 0o100644 in the high word: a regular file, readable, as unzip expects.
134
+ central.u32(0o100644 << 16)
135
+ central.u32(offset)
136
+ central.bytes(name)
137
+ }
138
+
139
+ const out = new Writer()
140
+ out.bytes(body.concat())
141
+ const centralOffset = out.length
142
+ out.bytes(central.concat())
143
+ out.u32(END_OF_CENTRAL)
144
+ out.u16(0)
145
+ out.u16(0)
146
+ out.u16(entries.length)
147
+ out.u16(entries.length)
148
+ out.u32(central.length)
149
+ out.u32(centralOffset)
150
+ out.u16(0)
151
+ return out.concat()
152
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.