@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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +507 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +609 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +556 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +148 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +65 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +382 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1642 -0
- package/src/status.ts +275 -0
- package/src/tar.ts +85 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- 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 }
|