@crombolo/server 0.0.0-stage → 0.10.0

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 (85) 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 +507 -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/bundle-install.ts +417 -0
  27. package/src/bundle-sections.ts +424 -0
  28. package/src/bundle-tables.ts +161 -0
  29. package/src/bundles.ts +609 -0
  30. package/src/component-snippets.ts +163 -0
  31. package/src/components.ts +266 -0
  32. package/src/config.ts +556 -0
  33. package/src/content-type-udt.ts +256 -0
  34. package/src/database.ts +148 -0
  35. package/src/diagnostics.ts +143 -0
  36. package/src/dictionary-transfer.ts +123 -0
  37. package/src/domains.ts +384 -0
  38. package/src/editor-types.ts +108 -0
  39. package/src/email-http.ts +79 -0
  40. package/src/email-notice.ts +36 -0
  41. package/src/email-postmark.ts +78 -0
  42. package/src/email-resend.ts +69 -0
  43. package/src/email-ses.ts +307 -0
  44. package/src/email.ts +402 -0
  45. package/src/extensions-notice.ts +35 -0
  46. package/src/file-intake.ts +84 -0
  47. package/src/form-files.ts +241 -0
  48. package/src/form-submit.ts +397 -0
  49. package/src/form-workflows.ts +397 -0
  50. package/src/forms.ts +170 -0
  51. package/src/git-github.ts +171 -0
  52. package/src/git-routes.ts +154 -0
  53. package/src/git.ts +88 -0
  54. package/src/imaging.ts +434 -0
  55. package/src/index.ts +65 -0
  56. package/src/jobs.ts +140 -0
  57. package/src/listen.ts +36 -0
  58. package/src/log-expressions.ts +524 -0
  59. package/src/log-viewer.ts +190 -0
  60. package/src/logging.ts +126 -0
  61. package/src/marketplace.ts +382 -0
  62. package/src/media-files.ts +276 -0
  63. package/src/media-store-azure.ts +218 -0
  64. package/src/media-store-s3.ts +113 -0
  65. package/src/media-store.ts +248 -0
  66. package/src/member-auth.ts +384 -0
  67. package/src/models-builder.ts +106 -0
  68. package/src/no-nodes.ts +64 -0
  69. package/src/not-implemented.ts +44 -0
  70. package/src/oembed.ts +144 -0
  71. package/src/ports.ts +553 -0
  72. package/src/redirects.ts +132 -0
  73. package/src/schema-classify.ts +82 -0
  74. package/src/schema-store.ts +145 -0
  75. package/src/schema.ts +388 -0
  76. package/src/seo.ts +371 -0
  77. package/src/server-events.ts +131 -0
  78. package/src/server.ts +1642 -0
  79. package/src/status.ts +275 -0
  80. package/src/tar.ts +85 -0
  81. package/src/udt.ts +168 -0
  82. package/src/value-schemas.ts +64 -0
  83. package/src/view-runtime.ts +42 -0
  84. package/src/zip.ts +152 -0
  85. package/README.md +0 -3
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The log viewer over the site's log files, as Umbraco's reads Serilog's: the
3
+ * files of each UTC day in the range (`*yyyyMMdd*.json`), one CLEF event per
4
+ * line (a line that is not one is skipped), and the entries whose time falls
5
+ * in the range, both ends included.
6
+ */
7
+ import { existsSync } from 'node:fs'
8
+ import { readdir, stat } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+ import type { LogEntry, LogLevelName, LogRange, LogViewerPort } from '@crombolo/api-management'
11
+ import { type Db, LogSearchRepository } from '@crombolo/data'
12
+ import type { CromboloConfig } from './config.ts'
13
+ import { type LogEvent, logFilter } from './log-expressions.ts'
14
+ import { SERILOG_LEVELS } from './logging.ts'
15
+
16
+ /** Umbraco's `FileSizeCap`: ranges whose files come to more are refused. */
17
+ export const LOG_SIZE_CAP_MB = 100
18
+
19
+ const DAY = 24 * 60 * 60 * 1000
20
+
21
+ /** Umbraco's default: when either end is missing, the last day. */
22
+ function resolveRange(range: LogRange, now = new Date()): { start: Date; end: Date } {
23
+ if (!range.start || !range.end) return { start: new Date(now.getTime() - DAY), end: now }
24
+ return { start: range.start, end: range.end }
25
+ }
26
+
27
+ const dayStamp = (date: Date) => date.toISOString().slice(0, 10).replaceAll('-', '')
28
+
29
+ async function filesFor(dir: string, range: { start: Date; end: Date }): Promise<string[]> {
30
+ if (!existsSync(dir)) return []
31
+ const names = (await readdir(dir)).filter((n) => n.endsWith('.json'))
32
+ const days = new Set<string>()
33
+ for (
34
+ let day = Date.UTC(
35
+ range.start.getUTCFullYear(),
36
+ range.start.getUTCMonth(),
37
+ range.start.getUTCDate(),
38
+ );
39
+ day <= range.end.getTime();
40
+ day += DAY
41
+ )
42
+ days.add(dayStamp(new Date(day)))
43
+ return names.filter((n) => [...days].some((d) => n.includes(d))).map((n) => join(dir, n))
44
+ }
45
+
46
+ /** Serilog's rendering of a property value in a message: strings quoted, the rest as written. */
47
+ function renderProperty(value: unknown, format: string | undefined): string {
48
+ if (typeof value === 'string') return format === 'l' ? value : `"${value}"`
49
+ if (value === null) return 'null'
50
+ if (typeof value === 'object') return JSON.stringify(value)
51
+ return String(value)
52
+ }
53
+
54
+ /** A message template with its properties filled in, as Serilog's `RenderMessage` does. */
55
+ export function renderMessage(template: string, properties: Record<string, unknown>): string {
56
+ return template.replace(
57
+ /\{\{|\}\}|\{([@$]?)([A-Za-z0-9_]+)(?:,[^:}]*)?(?::([^}]*))?\}/g,
58
+ (whole, _hint: string | undefined, name: string | undefined, format: string | undefined) => {
59
+ if (whole === '{{') return '{'
60
+ if (whole === '}}') return '}'
61
+ if (!name || !(name in properties)) return whole
62
+ return renderProperty(properties[name], format)
63
+ },
64
+ )
65
+ }
66
+
67
+ const LEVEL_NAMES = new Set(Object.values(SERILOG_LEVELS))
68
+
69
+ /** One CLEF line as an event the viewer shows and filters; undefined when it is not one. */
70
+ export function readClefLine(line: string): (LogEvent & { level: LogLevelName }) | undefined {
71
+ let raw: Record<string, unknown>
72
+ try {
73
+ raw = JSON.parse(line)
74
+ } catch {
75
+ return undefined
76
+ }
77
+ if (!raw || typeof raw !== 'object' || typeof raw['@t'] !== 'string') return undefined
78
+ const properties: Record<string, unknown> = {}
79
+ for (const [name, value] of Object.entries(raw))
80
+ if (!name.startsWith('@')) properties[name] = value
81
+ const template =
82
+ typeof raw['@mt'] === 'string' ? raw['@mt'] : typeof raw['@m'] === 'string' ? raw['@m'] : ''
83
+ const level =
84
+ typeof raw['@l'] === 'string' && LEVEL_NAMES.has(raw['@l']) ? raw['@l'] : 'Information'
85
+ return {
86
+ timestamp: raw['@t'],
87
+ level: level as LogLevelName,
88
+ messageTemplate: template,
89
+ message: typeof raw['@m'] === 'string' ? raw['@m'] : renderMessage(template, properties),
90
+ exception: typeof raw['@x'] === 'string' ? raw['@x'] : null,
91
+ properties,
92
+ }
93
+ }
94
+
95
+ async function readEvents(dir: string, range: { start: Date; end: Date }) {
96
+ const events: Array<LogEvent & { level: LogLevelName }> = []
97
+ const from = range.start.getTime()
98
+ const to = range.end.getTime()
99
+ for (const file of await filesFor(dir, range)) {
100
+ let content: string
101
+ try {
102
+ content = await Bun.file(file).text()
103
+ } catch {
104
+ continue
105
+ }
106
+ for (const line of content.split('\n')) {
107
+ if (!line.trim()) continue
108
+ const event = readClefLine(line)
109
+ if (!event) continue
110
+ const time = new Date(event.timestamp).getTime()
111
+ if (time >= from && time <= to) events.push(event)
112
+ }
113
+ }
114
+ return events
115
+ }
116
+
117
+ const propertyValue = (value: unknown): string | null =>
118
+ value === null || value === undefined
119
+ ? null
120
+ : typeof value === 'object'
121
+ ? JSON.stringify(value)
122
+ : String(value)
123
+
124
+ export function createLogViewerPort(
125
+ db: Db,
126
+ config: Pick<CromboloConfig, 'logsDir' | 'logLevel'>,
127
+ ): LogViewerPort {
128
+ const searches = new LogSearchRepository(db)
129
+ return {
130
+ async canView(range) {
131
+ let bytes = 0
132
+ for (const file of await filesFor(config.logsDir, resolveRange(range)))
133
+ bytes += (await stat(file)).size
134
+ return Math.floor(bytes / 1024 / 1024) <= LOG_SIZE_CAP_MB
135
+ },
136
+
137
+ async levelCounts(range) {
138
+ const counts = { information: 0, debug: 0, warning: 0, error: 0, fatal: 0 }
139
+ for (const event of await readEvents(config.logsDir, resolveRange(range))) {
140
+ // Verbose is not counted, as in Umbraco
141
+ const key = event.level.toLowerCase() as keyof typeof counts
142
+ if (key in counts) counts[key] += 1
143
+ }
144
+ return counts
145
+ },
146
+
147
+ async templates(range) {
148
+ const counts = new Map<string, number>()
149
+ for (const event of await readEvents(config.logsDir, resolveRange(range)))
150
+ counts.set(event.messageTemplate, (counts.get(event.messageTemplate) ?? 0) + 1)
151
+ return [...counts]
152
+ .map(([messageTemplate, count]) => ({ messageTemplate, count }))
153
+ .sort((a, b) => b.count - a.count)
154
+ },
155
+
156
+ async logs(range, query) {
157
+ const matches = logFilter(query.filter)
158
+ const levels = new Set(query.levels.map((l) => l.toLowerCase()))
159
+ const events = (await readEvents(config.logsDir, resolveRange(range)))
160
+ .filter(matches)
161
+ .filter((event) => levels.size === 0 || levels.has(event.level.toLowerCase()))
162
+ const sign = query.direction === 'Ascending' ? 1 : -1
163
+ events.sort((a, b) => (Date.parse(a.timestamp) - Date.parse(b.timestamp)) * sign)
164
+ return events.map(
165
+ (event): LogEntry => ({
166
+ timestamp: event.timestamp,
167
+ level: event.level,
168
+ messageTemplate: event.messageTemplate,
169
+ renderedMessage: event.message,
170
+ exception: event.exception,
171
+ properties: Object.entries(event.properties).map(([name, value]) => ({
172
+ name,
173
+ value: propertyValue(value),
174
+ })),
175
+ }),
176
+ )
177
+ },
178
+
179
+ loggers: () => [
180
+ { name: 'Global', level: SERILOG_LEVELS[config.logLevel] as LogLevelName },
181
+ // Everything the loggers pass reaches the file
182
+ { name: 'CromboloFile', level: 'Verbose' },
183
+ ],
184
+
185
+ savedSearches: () => searches.all(),
186
+ savedSearch: (name) => searches.byName(name),
187
+ saveSearch: (name, query) => searches.create(name, query),
188
+ deleteSearch: (name) => searches.delete(name),
189
+ }
190
+ }
package/src/logging.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Logging, through LogTape, into the files Umbraco's log viewer reads: Serilog's
3
+ * compact JSON (CLEF), one event per line, one file per machine per UTC day in
4
+ * the site's `logs/` — `CromboloTraceLog.<machine>.<yyyyMMdd>.json`, after
5
+ * Umbraco's `UmbracoTraceLog.<machine>.<yyyyMMdd>.json`. A message is a
6
+ * template (`'Published {name}'`) with its properties, as Serilog's are; an
7
+ * `error` property carries the exception.
8
+ */
9
+ import { appendFileSync, mkdirSync } from 'node:fs'
10
+ import { hostname } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import {
13
+ configureSync,
14
+ getConsoleSink,
15
+ getLogger,
16
+ type Logger,
17
+ type LogLevel,
18
+ type LogRecord,
19
+ type Sink,
20
+ } from '@logtape/logtape'
21
+
22
+ /** Serilog's level names, which the log viewer filters and counts by. */
23
+ export const SERILOG_LEVELS: Record<LogLevel, string> = {
24
+ trace: 'Verbose',
25
+ debug: 'Debug',
26
+ info: 'Information',
27
+ warning: 'Warning',
28
+ error: 'Error',
29
+ fatal: 'Fatal',
30
+ }
31
+
32
+ export const LOG_FILE_PREFIX = 'CromboloTraceLog'
33
+
34
+ /** Today's file for this machine: the day is UTC, as the timestamps are. */
35
+ export function logFileName(date: Date, machine = hostname()): string {
36
+ const day = date.toISOString().slice(0, 10).replaceAll('-', '')
37
+ return `${LOG_FILE_PREFIX}.${machine.replace(/[^A-Za-z0-9_-]/g, '_')}.${day}.json`
38
+ }
39
+
40
+ const renderValue = (value: unknown): unknown => {
41
+ if (value instanceof Error) return value.message
42
+ if (value instanceof Date) return value.toISOString()
43
+ return value
44
+ }
45
+
46
+ /** One CLEF event: `@t`, `@mt`, `@l` (left out for Information, as Serilog does), `@x`, then properties. */
47
+ export function toClef(record: LogRecord): Record<string, unknown> {
48
+ const { error, ...properties } = record.properties
49
+ const event: Record<string, unknown> = {
50
+ '@t': new Date(record.timestamp).toISOString(),
51
+ '@mt': typeof record.rawMessage === 'string' ? record.rawMessage : record.rawMessage.join('{}'),
52
+ }
53
+ if (record.level !== 'info') event['@l'] = SERILOG_LEVELS[record.level]
54
+ if (error !== undefined)
55
+ event['@x'] =
56
+ error instanceof Error ? (error.stack ?? `${error.name}: ${error.message}`) : String(error)
57
+ for (const [name, value] of Object.entries(properties)) event[name] = renderValue(value)
58
+ event.SourceContext = record.category.join('.')
59
+ event.MachineName = hostname()
60
+ event.ProcessId = process.pid
61
+ return event
62
+ }
63
+
64
+ /** Appends each event to the day's file; written synchronously, so nothing is lost on a crash. */
65
+ export function getClefFileSink(dir: string): Sink {
66
+ let made = false
67
+ return (record) => {
68
+ if (!made) {
69
+ mkdirSync(dir, { recursive: true })
70
+ made = true
71
+ }
72
+ appendFileSync(
73
+ join(dir, logFileName(new Date(record.timestamp))),
74
+ `${JSON.stringify(toClef(record))}\n`,
75
+ )
76
+ }
77
+ }
78
+
79
+ export interface LoggingOptions {
80
+ logsDir: string
81
+ /** The least severe level written, for the files and the console alike. */
82
+ level: LogLevel
83
+ /** Whether events are also printed; tests turn it off. */
84
+ console: boolean
85
+ }
86
+
87
+ /**
88
+ * Points every `crombolo` logger at the site's files (and the console). LogTape
89
+ * holds one configuration per process, so the most recently started server's
90
+ * wins.
91
+ */
92
+ export function configureLogging(options: LoggingOptions): void {
93
+ configureSync({
94
+ reset: true,
95
+ sinks: {
96
+ file: getClefFileSink(options.logsDir),
97
+ console: getConsoleSink(),
98
+ },
99
+ loggers: [
100
+ {
101
+ category: ['crombolo'],
102
+ lowestLevel: options.level,
103
+ sinks: options.console ? ['file', 'console'] : ['file'],
104
+ },
105
+ // The development 501 log prints its own line; its events only go to the file
106
+ {
107
+ category: ['crombolo', 'not-implemented'],
108
+ sinks: ['file'],
109
+ parentSinks: 'override',
110
+ },
111
+ // The request trace is thousands of lines per backoffice boot: the console
112
+ // only, so the log viewer's files stay readable
113
+ {
114
+ category: ['crombolo', 'http'],
115
+ sinks: options.console ? ['console'] : [],
116
+ parentSinks: 'override',
117
+ },
118
+ { category: ['logtape', 'meta'], lowestLevel: 'warning', sinks: ['console'] },
119
+ ],
120
+ })
121
+ }
122
+
123
+ /** A logger for one part of the server; its category becomes the event's `SourceContext`. */
124
+ export function logger(...category: string[]): Logger {
125
+ return getLogger(['crombolo', ...category])
126
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * The Bundles section's marketplace, over npm. `docs/bundles.md`.
3
+ *
4
+ * Discovery and declaration are two different things, because npm indexes only
5
+ * one of them: a bundle is *found* by the `crombolo-bundle` keyword, which is
6
+ * the only thing registry search will match, and it *declares* itself with a
7
+ * `crombolo` field in its `package.json`. Search for the keyword, then read each
8
+ * hit's packument — custom fields survive there — and offer only the packages
9
+ * whose manifest is a valid bundle. A keyword squatter never reaches the list.
10
+ *
11
+ * An install is `bun add`, and then — unless the site or the request says not
12
+ * to — the content the bundle carries, through the same installer as
13
+ * `crombolo bundle install`. A bundle's server half is never loaded by an
14
+ * install: that still takes an edit to `crombolo.config.ts` and a deploy.
15
+ *
16
+ * The search is proxied rather than done in the browser: it works where the
17
+ * browser cannot reach npm, and the backoffice keeps making same-origin
18
+ * requests only.
19
+ */
20
+ import { join } from 'node:path'
21
+ import {
22
+ type BundlePackage,
23
+ EXTENSION_KEYWORD,
24
+ type ExtensionRegistry,
25
+ type PackageJson,
26
+ validateBundlePackage,
27
+ } from '@crombolo/backoffice-host'
28
+ import { logger } from './logging.ts'
29
+
30
+ const log = logger('packages')
31
+
32
+ /** How long a search is reused. Long enough to page a list, short enough to stay fresh. */
33
+ const CACHE_MS = 5 * 60 * 1000
34
+
35
+ export interface MarketplaceListing {
36
+ name: string
37
+ version: string
38
+ description: string
39
+ publisher: string | null
40
+ links: { npm: string; homepage: string | null; repository: string | null }
41
+ /** What the latest version carries; only a valid bundle is listed at all. */
42
+ carries: BundlePackage['carries']
43
+ installedVersion: string | null
44
+ }
45
+
46
+ /** A bundle the site depends on, as the Installed view lists it. */
47
+ export interface InstalledListing {
48
+ /** The npm package name, which is what an uninstall names. */
49
+ packageName: string
50
+ /** The display name the package chose. */
51
+ name: string
52
+ id: string
53
+ version: string
54
+ extensionCount: number
55
+ carries: BundlePackage['carries']
56
+ /** Whether its server half is imported into `crombolo.config.ts`; only meaningful when it carries one. */
57
+ wired: boolean
58
+ /** The version whose content was applied here, or null when none has been. */
59
+ contentApplied: string | null
60
+ }
61
+
62
+ /** What became of the content half of an install. */
63
+ export interface ContentOutcome {
64
+ applied: boolean
65
+ /** Set when it was applied: the transfer run that can revert it. */
66
+ runId?: string
67
+ message: string
68
+ }
69
+
70
+ export interface MarketplaceOptions {
71
+ registry: string
72
+ keyword: string
73
+ marketplaceUrl: string
74
+ extensions: ExtensionRegistry
75
+ fetch?: typeof fetch
76
+ /** Where `bun add` runs, which is the site that owns the package.json. */
77
+ siteDir: string
78
+ /** Overridden by tests; the real one shells out to Bun. */
79
+ install?: PackageInstaller
80
+ /** The ids of the bundles whose server half this node has mounted. */
81
+ wired?: () => readonly string[]
82
+ /** The version of a package whose content has been applied here. */
83
+ contentApplied?: (packageName: string) => Promise<string | null>
84
+ /**
85
+ * Installs the content half of a bundle the site now depends on. Absent where
86
+ * content cannot be applied, which declines it for every install.
87
+ */
88
+ applyContent?: (packageName: string) => Promise<ContentOutcome>
89
+ /** The site's `applyBundleContentOnInstall`. */
90
+ applyContentOnInstall?: boolean
91
+ }
92
+
93
+ export interface InstallOutcome {
94
+ ok: boolean
95
+ /**
96
+ * The dependency line the site's `package.json` now holds, so the UI can show
97
+ * exactly what there is to commit: the range as written, and the version that
98
+ * resolved to.
99
+ */
100
+ dependency?: { name: string; range: string; version: string }
101
+ message: string
102
+ /** What happened to the content the bundle carries; absent when it carries none. */
103
+ content?: ContentOutcome
104
+ }
105
+
106
+ export type PackageInstaller = (
107
+ action: 'add' | 'remove',
108
+ specifier: string,
109
+ siteDir: string,
110
+ ) => Promise<{ ok: boolean; output: string }>
111
+
112
+ interface SearchObject {
113
+ package?: {
114
+ name?: string
115
+ version?: string
116
+ description?: string
117
+ publisher?: { username?: string }
118
+ links?: Record<string, string>
119
+ }
120
+ }
121
+
122
+ /** A name npm would accept, so nothing shell-ish or path-ish reaches `bun add`. */
123
+ export function isValidPackageName(name: string): boolean {
124
+ return /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(name) && name.length <= 214
125
+ }
126
+
127
+ /**
128
+ * A version or range that cannot be mistaken for another argument.
129
+ *
130
+ * Ranges start with an operator — `^2.1`, `>=1 <2` — so those lead; `-` never
131
+ * does, because `bun add` would read it as a flag. No shell is involved
132
+ * (`Bun.spawn` takes an argv), so the space in a range is harmless.
133
+ */
134
+ export function isValidVersion(version: string): boolean {
135
+ return /^[A-Za-z0-9^~>=<*][A-Za-z0-9.^~>=<| *+-]*$/.test(version) && version.length <= 64
136
+ }
137
+
138
+ export interface Marketplace {
139
+ /** Where someone browsing should be sent, when they want more than this list. */
140
+ url(): string
141
+ search(query: string | undefined): Promise<MarketplaceListing[]>
142
+ installed(): Promise<InstalledListing[]>
143
+ install(
144
+ name: string,
145
+ version: string | undefined,
146
+ options?: { applyContent?: boolean },
147
+ ): Promise<InstallOutcome>
148
+ uninstall(name: string): Promise<InstallOutcome>
149
+ }
150
+
151
+ /**
152
+ * `bun add` in the site directory: Bun owns resolution and writes the lockfile.
153
+ *
154
+ * The spawn itself can fail rather than the command — an unreadable site
155
+ * directory or a `bun` that is not on the path both throw out of `Bun.spawn` —
156
+ * and that has to come back as a failed install with a reason, not as an
157
+ * exception the route turns into a 500.
158
+ */
159
+ export const bunInstaller: PackageInstaller = async (action, specifier, siteDir) => {
160
+ try {
161
+ const proc = Bun.spawn(['bun', action, specifier], {
162
+ cwd: siteDir,
163
+ stdout: 'pipe',
164
+ stderr: 'pipe',
165
+ })
166
+ const [stdout, stderr, code] = await Promise.all([
167
+ new Response(proc.stdout).text(),
168
+ new Response(proc.stderr).text(),
169
+ proc.exited,
170
+ ])
171
+ return { ok: code === 0, output: `${stdout}${stderr}`.trim() }
172
+ } catch (error) {
173
+ return {
174
+ ok: false,
175
+ output: `bun ${action} could not be run in ${siteDir}: ${
176
+ error instanceof Error ? error.message : String(error)
177
+ }`,
178
+ }
179
+ }
180
+ }
181
+
182
+ export function createMarketplace(options: MarketplaceOptions): Marketplace {
183
+ const call = options.fetch ?? fetch
184
+ const install = options.install ?? bunInstaller
185
+
186
+ /** The range `package.json` holds for a dependency, as `bun add` wrote it. */
187
+ const rangeFor = async (name: string): Promise<string | undefined> => {
188
+ try {
189
+ const json = (await Bun.file(join(options.siteDir, 'package.json')).json()) as {
190
+ dependencies?: Record<string, string>
191
+ }
192
+ return json.dependencies?.[name]
193
+ } catch {
194
+ return undefined
195
+ }
196
+ }
197
+ let cached: { at: number; query: string; listings: MarketplaceListing[] } | undefined
198
+
199
+ const installedVersion = (name: string): string | null =>
200
+ options.extensions.bundles().bundles.find((bundle) => bundle.packageName === name)?.version ??
201
+ null
202
+
203
+ /** What the registry says this package's latest version carries, when it is a valid bundle. */
204
+ const declares = async (name: string): Promise<BundlePackage['carries'] | undefined> => {
205
+ try {
206
+ const response = await call(`${options.registry}/${encodeURIComponent(name)}`, {
207
+ headers: { accept: 'application/json' },
208
+ })
209
+ if (!response.ok) return undefined
210
+ const packument = (await response.json()) as {
211
+ 'dist-tags'?: { latest?: string }
212
+ versions?: Record<string, PackageJson>
213
+ }
214
+ const latest = packument['dist-tags']?.latest
215
+ const version = latest ? packument.versions?.[latest] : undefined
216
+ if (!version || validateBundlePackage(version).length > 0) return undefined
217
+ const field = version.crombolo as {
218
+ extensions?: unknown[]
219
+ content?: string
220
+ server?: string
221
+ }
222
+ return {
223
+ extensions: Array.isArray(field.extensions) && field.extensions.length > 0,
224
+ content: field.content !== undefined,
225
+ server: field.server !== undefined,
226
+ }
227
+ } catch {
228
+ return undefined
229
+ }
230
+ }
231
+
232
+ return {
233
+ url: () => options.marketplaceUrl,
234
+
235
+ async search(query) {
236
+ const text = [`keywords:${options.keyword}`, query?.trim()].filter(Boolean).join(' ')
237
+ if (cached && cached.query === text && Date.now() - cached.at < CACHE_MS)
238
+ return cached.listings.map((listing) => ({
239
+ ...listing,
240
+ installedVersion: installedVersion(listing.name),
241
+ }))
242
+
243
+ let objects: SearchObject[] = []
244
+ try {
245
+ const response = await call(
246
+ `${options.registry}/-/v1/search?text=${encodeURIComponent(text)}&size=50`,
247
+ { headers: { accept: 'application/json' } },
248
+ )
249
+ if (!response.ok) throw new Error(`the registry answered ${response.status}`)
250
+ objects = ((await response.json()) as { objects?: SearchObject[] }).objects ?? []
251
+ } catch (error) {
252
+ // An unreachable registry is an empty list with a log line, not a 500:
253
+ // an air-gapped site should still be able to open the section.
254
+ log.warning('The package registry could not be reached: {detail}', {
255
+ detail: error instanceof Error ? error.message : String(error),
256
+ })
257
+ return []
258
+ }
259
+
260
+ // The packuments are read together: one request per hit, and a page of
261
+ // hits read one at a time would make the view feel broken.
262
+ const listings = await Promise.all(
263
+ objects
264
+ .map((object) => object.package)
265
+ .filter((pkg): pkg is NonNullable<typeof pkg> =>
266
+ Boolean(pkg?.name && isValidPackageName(pkg.name)),
267
+ )
268
+ .map(async (pkg): Promise<MarketplaceListing | undefined> => {
269
+ const name = pkg.name as string
270
+ const carries = await declares(name)
271
+ if (!carries) return undefined
272
+ return {
273
+ name,
274
+ version: pkg.version ?? '0.0.0',
275
+ description: pkg.description ?? '',
276
+ publisher: pkg.publisher?.username ?? null,
277
+ links: {
278
+ npm: pkg.links?.npm ?? `https://www.npmjs.com/package/${name}`,
279
+ homepage: pkg.links?.homepage ?? null,
280
+ repository: pkg.links?.repository ?? null,
281
+ },
282
+ carries,
283
+ installedVersion: installedVersion(name),
284
+ }
285
+ }),
286
+ )
287
+ // Only what really is a bundle: the keyword is a claim, the manifest is
288
+ // the thing itself.
289
+ const offered = listings.filter((listing): listing is MarketplaceListing => Boolean(listing))
290
+ cached = { at: Date.now(), query: text, listings: offered }
291
+ return offered
292
+ },
293
+
294
+ async installed() {
295
+ const wired = new Set(options.wired?.() ?? [])
296
+ return Promise.all(
297
+ options.extensions.bundles().bundles.map(async (bundle) => ({
298
+ packageName: bundle.packageName,
299
+ name: bundle.name,
300
+ id: bundle.id,
301
+ version: bundle.version,
302
+ extensionCount: bundle.field.extensions?.length ?? 0,
303
+ carries: bundle.carries,
304
+ wired: wired.has(bundle.id),
305
+ contentApplied: (await options.contentApplied?.(bundle.packageName)) ?? null,
306
+ })),
307
+ )
308
+ },
309
+
310
+ async install(name, version, request = {}) {
311
+ if (!isValidPackageName(name))
312
+ return { ok: false, message: `'${name}' is not a package name npm would accept.` }
313
+ if (version !== undefined && !isValidVersion(version))
314
+ return { ok: false, message: `'${version}' is not a version npm would accept.` }
315
+
316
+ const specifier = version ? `${name}@${version}` : name
317
+ const result = await install('add', specifier, options.siteDir)
318
+ options.extensions.invalidate()
319
+ if (!result.ok)
320
+ return {
321
+ ok: false,
322
+ message: result.output || `Installing ${specifier} failed.`,
323
+ }
324
+
325
+ const range = (await rangeFor(name)) ?? version ?? 'latest'
326
+ const found = options.extensions
327
+ .bundles()
328
+ .bundles.find((bundle) => bundle.packageName === name)
329
+ if (!found)
330
+ return {
331
+ ok: true,
332
+ dependency: { name, range, version: version ?? range },
333
+ message: `${name} was installed, but it is not a valid bundle, so nothing was added to the backoffice.`,
334
+ }
335
+
336
+ // The content half lands after the dependency, so a refused check leaves
337
+ // the package installed and says why, rather than undoing an install that
338
+ // succeeded.
339
+ let content: ContentOutcome | undefined
340
+ if (found.carries.content) {
341
+ const declined = request.applyContent === false || options.applyContentOnInstall === false
342
+ content =
343
+ declined || !options.applyContent
344
+ ? {
345
+ applied: false,
346
+ message: `Its content was not applied. Run \`crombolo bundle install ${name}\` to apply it.`,
347
+ }
348
+ : await options.applyContent(name).catch((error: unknown) => ({
349
+ applied: false,
350
+ message: `Its content could not be applied: ${error instanceof Error ? error.message : String(error)}`,
351
+ }))
352
+ }
353
+ return {
354
+ ok: true,
355
+ dependency: { name, range, version: found.version },
356
+ // The install lives in this node's node_modules until the dependency is
357
+ // committed; saying so is the difference between a surprise and a plan.
358
+ message: `${name}@${found.version} is installed. Commit the changed package.json and bun.lock to keep it across deployments.${
359
+ found.carries.server
360
+ ? ' Its server half runs only once crombolo.config.ts imports it and the site is deployed.'
361
+ : ''
362
+ }`,
363
+ ...(content ? { content } : {}),
364
+ }
365
+ },
366
+
367
+ async uninstall(name) {
368
+ if (!isValidPackageName(name))
369
+ return { ok: false, message: `'${name}' is not a package name npm would accept.` }
370
+ const result = await install('remove', name, options.siteDir)
371
+ options.extensions.invalidate()
372
+ return result.ok
373
+ ? {
374
+ ok: true,
375
+ message: `${name} was removed. Commit the changed package.json and bun.lock.`,
376
+ }
377
+ : { ok: false, message: result.output || `Removing ${name} failed.` }
378
+ },
379
+ }
380
+ }
381
+
382
+ export { EXTENSION_KEYWORD }