@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
package/src/config.ts ADDED
@@ -0,0 +1,556 @@
1
+ import { hostname, tmpdir } from 'node:os'
2
+ import { join, resolve } from 'node:path'
3
+ import { DEFAULT_BACKOFFICE_PATH } from '@crombolo/core'
4
+ import { DEFAULT_RENDER_TIMEOUT_MS, type RedirectRule, type SnapshotLimits } from '@crombolo/render'
5
+ import type { AssistantConfig } from './assistant.ts'
6
+ import type { ServerBundle } from './bundles.ts'
7
+ import type { EmailPort } from './email.ts'
8
+ import type { GitConfig } from './git-routes.ts'
9
+ import type { MediaStore } from './media-store.ts'
10
+ import type { SchemaStore } from './schema-store.ts'
11
+
12
+ /**
13
+ * Runtime settings for a site. Umbraco's equivalents live in appsettings.json;
14
+ * a site declares these in `crombolo.config.ts` via `defineConfig`, and
15
+ * `loadConfig` fills anything unset from the environment.
16
+ */
17
+ /** What a process serves; see `CromboloConfig.role`. */
18
+ export const SERVER_ROLES = ['all', 'api', 'web'] as const
19
+
20
+ export type ServerRole = (typeof SERVER_ROLES)[number]
21
+
22
+ /** Delivers an invitation or password-reset link to a backoffice user, typically by e-mail. */
23
+ export type UserLinkSender = (message: {
24
+ kind: 'invite' | 'reset'
25
+ to: { name: string; email: string }
26
+ link: string
27
+ /** The inviting user's personal message, for an invitation. */
28
+ message: string | null
29
+ }) => Promise<void>
30
+
31
+ export interface CromboloConfig {
32
+ port: number
33
+ /** The largest request body the server will read, media uploads included. */
34
+ maxRequestBodyBytes: number
35
+ backOfficePath: string
36
+ defaultUiLanguage: string
37
+ keepUserLoggedIn: boolean
38
+ allowLocalLogin: boolean
39
+ allowPasswordReset: boolean
40
+ versionCheckPeriod: number
41
+ umbracoCssPath: string
42
+ /**
43
+ * Reporting true lets the client open a plain WebSocket and skip SignalR's
44
+ * /negotiate handshake, which is the shortcut Umbraco intends for servers that
45
+ * do not implement the full protocol.
46
+ */
47
+ signalRSkipNegotiation: boolean
48
+ development: boolean
49
+ /**
50
+ * Whether the cache-busted asset path may be cached immutably. Defaults to
51
+ * `!development`.
52
+ *
53
+ * The hash in that path comes from the vendored client's `VERSION` alone, so
54
+ * re-vendoring the *same* upstream version with different output — editing
55
+ * `vendor-backoffice.ts`, or changing what `upstream-static/` seeds — leaves the
56
+ * path unchanged while the bytes behind it move. Development therefore sends
57
+ * `no-cache`, or a developer would debug a stale module graph.
58
+ *
59
+ * A long-lived process that vendors once wants the opposite, and wants it
60
+ * badly: the client is ~6,500 separate modules, and re-fetching all of them on
61
+ * every page load is enough to exhaust a browser. The browser suite sets this
62
+ * true for exactly that reason — see `tests/browser/serve.ts`.
63
+ */
64
+ immutableAssets?: boolean
65
+ /** SQLite file, or ':memory:'. Ignored when CROMBOLO_DB=postgres. */
66
+ sqliteFile: string
67
+ adminLogin: string
68
+ /** When unset, a password is generated on first run and printed once. */
69
+ adminPassword: string | undefined
70
+ /**
71
+ * The `__Host-` cookie prefix requires Secure. Browsers treat http://localhost
72
+ * as secure, so this stays on in local development.
73
+ */
74
+ secureCookies: boolean
75
+ siteName: string
76
+ /** The site's own directory: where `crombolo.config.ts` and `domains.toml` are. */
77
+ siteDir: string
78
+ /** Where template views live on disk. */
79
+ componentsDir: string
80
+ /**
81
+ * Where content-addressed copies of `componentsDir` are written, so an edited view
82
+ * is picked up without restarting the node.
83
+ *
84
+ * Inside the site by necessity, not by preference: JSX compiles to an import
85
+ * of `@crombolo/crombolo/jsx-runtime`, which resolves by walking up from the file, so a
86
+ * snapshot outside the site tree cannot load at all. Ephemeral — cleared at
87
+ * boot — and never shared between nodes.
88
+ */
89
+ componentsCacheDir: string
90
+ /**
91
+ * Tuning for the views snapshot: how often a node looks for a change, how
92
+ * long it looks eagerly after one is announced, how many generations it will
93
+ * load before it freezes, and the caps on a tree it will copy.
94
+ *
95
+ * Anything unset takes `SNAPSHOT_LIMITS`, or its development override.
96
+ */
97
+ componentsSnapshot: Partial<SnapshotLimits>
98
+ /**
99
+ * How long a page may take to render, layouts included, before it fails as a
100
+ * template error and its `signal` aborts; 0 for no limit.
101
+ */
102
+ renderTimeoutMs: number
103
+ /** Where `schema/*.toml` lives; sync is skipped when the directory is absent. */
104
+ schemaDir: string
105
+ /** Supplied at deploy time (CROMBOLO_SCHEMA_REVISION); orders deploys within one schema version. */
106
+ schemaRevision: string
107
+ /** This node's identity in `server` and on cache instructions. */
108
+ nodeId: string
109
+ /**
110
+ * What this process serves, and whether it owns the boot's write duties.
111
+ *
112
+ * `all` is one process doing everything, which is every single-box site and
113
+ * all local development. The other two split a deployment in half:
114
+ *
115
+ * - `api` serves the backoffice and the Management API, and owns the schema
116
+ * sync, the `domains.toml` and redirect convergence and the background jobs.
117
+ * It renders only for preview, so public traffic routed here by mistake is a
118
+ * 404 rather than a second copy of the site.
119
+ * - `web` serves the public site and nothing else: no backoffice, no
120
+ * Management API, no sign-in routes, and none of the write duties above. It
121
+ * neither migrates nor seeds, and refuses to boot against a database that
122
+ * has not been migrated yet — that is `api`'s job.
123
+ *
124
+ * Both halves still hold a database connection and stay coherent through
125
+ * `cache_instruction`, exactly as a load-balanced set already does. Splitting
126
+ * needs Postgres: `locks.ts`'s SQLite manager is an in-process queue, so it
127
+ * coordinates nothing between processes.
128
+ */
129
+ role: ServerRole
130
+ /**
131
+ * Where the schema files live, when not in `schemaDir` on this disk. Unset means
132
+ * the directory, which is what a repository checkout wants. Set — with
133
+ * `s3SchemaStore` or `azureSchemaStore` — the store is the truth: it is copied
134
+ * into `schemaCacheDir` at boot, every node reads the same copy, and a change
135
+ * made in the backoffice is published back rather than lost with the container.
136
+ */
137
+ schemaStore: SchemaStore | undefined
138
+ /** Where a `schemaStore` is materialised. Ephemeral by design: the store is the truth. */
139
+ schemaCacheDir: string
140
+ /**
141
+ * Whether the backoffice may write schema files. Development only by default,
142
+ * because a production container's disk does not survive a redeploy — unless a
143
+ * `schemaStore` makes the writes durable, which turns this on, or
144
+ * CROMBOLO_SCHEMA_WRITABLE says otherwise outright.
145
+ */
146
+ schemaWritable: boolean
147
+ /** Sync `schema/` at boot; a pipeline that runs `crombolo schema sync` turns this off. */
148
+ syncSchemaAtBoot: boolean
149
+ /** Which database engine; from CROMBOLO_DB. */
150
+ dialect: 'sqlite' | 'postgres'
151
+ /** A shell command that backs up Postgres; `--fix` and `upgrade` run it first. */
152
+ pgDump: string | undefined
153
+ /** Whether findings that need a person gate an upgrade: strict in production. */
154
+ upgradePolicy: 'strict' | 'pending-allowed'
155
+ /**
156
+ * The hourly version cleanup, as Umbraco's `ContentVersionCleanupPolicy`: off
157
+ * unless enabled, and a content type's own policy wins over these windows.
158
+ */
159
+ versionCleanup: {
160
+ enabled: boolean
161
+ keepAllVersionsNewerThanDays: number
162
+ keepLatestVersionPerDayForDays: number
163
+ }
164
+ /** Where uploaded media files live; served at `/media/`. */
165
+ mediaDir: string
166
+ /**
167
+ * Where the bytes of uploaded media actually go. Unset means the environment
168
+ * decides (`CROMBOLO_MEDIA_STORE`), which defaults to `mediaDir` on this disk.
169
+ * A site swaps in blob storage with `s3MediaStore` or `azureMediaStore`.
170
+ */
171
+ mediaStore: MediaStore | undefined
172
+ /** Where log files are written, and read by the backoffice's log viewer. */
173
+ logsDir: string
174
+ /** The least severe level logged: trace, debug, info, warning, error or fatal. */
175
+ logLevel: 'trace' | 'debug' | 'info' | 'warning' | 'error' | 'fatal'
176
+ /** Whether log events are also printed to the console. */
177
+ logToConsole: boolean
178
+ /**
179
+ * A per-request trace on the console, and a regular report of the heap and of
180
+ * in-flight requests. For diagnosing a live node; both off by default.
181
+ */
182
+ diagnostics: { traceRequests: boolean; heapIntervalSeconds: number }
183
+ /** Stylesheets the Settings section edits, served at `/css/`. */
184
+ stylesheetsDir: string
185
+ /** Scripts the Settings section edits, served at `/scripts/`. */
186
+ scriptsDir: string
187
+ /** Umbraco's `UsernameIsEmail`: a user's login is their e-mail address. */
188
+ usernameIsEmail: boolean
189
+ /** The site's public URL, which invitation and reset links point at. */
190
+ applicationUrl: string
191
+ /**
192
+ * How invitation and reset links reach people. Without one users cannot be
193
+ * invited and passwords cannot be reset by e-mail; in development the links
194
+ * are printed to the console instead, unless this is `null`.
195
+ */
196
+ sendUserLink: UserLinkSender | null | undefined
197
+ /**
198
+ * How e-mail leaves this site, and the only thing that makes user invitations,
199
+ * password resets and Forms' Send email workflow available.
200
+ *
201
+ * Opt-in, in three states. A port sends through it. `undefined` lets the
202
+ * environment decide (`CROMBOLO_EMAIL_PROVIDER`, which defaults to no e-mail),
203
+ * falling back in development to the console so a developer sees the message
204
+ * without configuring a provider. `null` is off outright, console included.
205
+ *
206
+ * Nothing here throws: a site with no provider reports the features as
207
+ * unavailable, with the reason, rather than failing when someone presses the
208
+ * button. See `email.ts` and `docs/configuration.md`.
209
+ */
210
+ email: EmailPort | null | undefined
211
+ /**
212
+ * Whether `POST /umbraco/members/register` accepts a registration. Off by
213
+ * default: an always-open endpoint that creates members is a decision a site
214
+ * makes deliberately, not one a framework makes for it.
215
+ */
216
+ allowMemberRegistration: boolean
217
+ /** The member type a registration creates, by alias. */
218
+ memberRegistrationType: string
219
+ /** Member group names a registration joins the new member to. */
220
+ memberRegistrationGroups: string[]
221
+ /** How long a member's session cookie is good for. */
222
+ memberSessionMinutes: number
223
+ /** Failed sign-ins that lock a member out; Umbraco's default is 5. 0 never locks out. */
224
+ maxFailedPasswordAttempts: number
225
+ /**
226
+ * Redirects the site declares itself, built with `redirect()`. They are synced
227
+ * into the database at boot, so the backoffice lists them beside the tracked
228
+ * ones, and they are matched only once route resolution has found no page —
229
+ * a rule can never make a live URL unreachable.
230
+ */
231
+ redirects: RedirectRule[]
232
+ /**
233
+ * Whether renaming or moving a published page records a redirect from the URL
234
+ * it used to answer on. Umbraco's `DisableRedirectUrlTracking`, inverted.
235
+ */
236
+ trackRedirects: boolean
237
+ /**
238
+ * Search and sharing defaults: `/robots.txt`, `/sitemap.xml`, the site-wide
239
+ * values `<SeoHead>` falls back to, and the page shown for an address nothing
240
+ * answers on. Every field may also be set in the backoffice, which wins.
241
+ */
242
+ seo: SeoConfig
243
+ /**
244
+ * Bundles whose server half this site runs, each the result of calling the
245
+ * factory its package exports. Imported and listed here by hand on purpose:
246
+ * installing a bundle never makes its server code run, so the only way code
247
+ * reaches the request path is a committed, deployed edit to this file
248
+ * (`bundles.ts`, `docs/bundles.md`).
249
+ */
250
+ bundles: ServerBundle[]
251
+ /**
252
+ * Whether installing a bundle from the Marketplace also applies the content it
253
+ * carries — its schema, components and content — straight after `bun add`. On
254
+ * by default; a load-balanced site that wants content to arrive only with a
255
+ * deploy turns it off, and an install can decline it for itself.
256
+ */
257
+ applyBundleContentOnInstall: boolean
258
+ /**
259
+ * Where the Packages section's Marketplace sends someone browsing for
260
+ * extensions. Never framed — npm refuses to be framed — so the native view
261
+ * uses it as the link out while listing the registry itself.
262
+ */
263
+ marketplaceUrl: string
264
+ /** The npm keyword a package publishes to be discoverable here. */
265
+ packageKeyword: string
266
+ /** The registry the marketplace searches. Overridden by tests, and by a mirror. */
267
+ npmRegistry: string
268
+ /**
269
+ * Source control for the schema, so a change made on a running site reaches the
270
+ * repository without an editor meeting git. Unset means the feature does not
271
+ * exist: no route, no credentials. Set with `gitHub({ repository, token })`.
272
+ */
273
+ git: GitConfig | undefined
274
+ /**
275
+ * The AI helper in the backoffice. Absent means the feature does not exist: no
276
+ * route, no tools, no model client, and nothing in the backoffice. When it is
277
+ * configured the assistant can read the CMS and propose changes, and a person
278
+ * approves each one — it cannot write, and it cannot publish.
279
+ */
280
+ assistant: AssistantConfig | undefined
281
+ }
282
+
283
+ export type SiteConfig = CromboloConfig
284
+
285
+ export const DEFAULTS = {
286
+ // The same port the container stack publishes, so the backoffice is at one
287
+ // address whichever way the site was started. Only one of the two can hold it;
288
+ // `assertPortAvailable` says so rather than letting the bind fail obscurely.
289
+ port: 8080,
290
+ // Bun's own default is 128 MB, which every unauthenticated endpoint that reads
291
+ // a body would otherwise accept. 32 MiB leaves room for the media library —
292
+ // Kestrel's default, which Umbraco runs behind, is about 30 MB.
293
+ maxRequestBodyBytes: 32 * 1024 * 1024,
294
+ backOfficePath: DEFAULT_BACKOFFICE_PATH,
295
+ defaultUiLanguage: 'en-US',
296
+ keepUserLoggedIn: false,
297
+ allowLocalLogin: true,
298
+ allowPasswordReset: false,
299
+ versionCheckPeriod: 7,
300
+ umbracoCssPath: '/css',
301
+ signalRSkipNegotiation: true,
302
+ sqliteFile: 'crombolo.sqlite',
303
+ adminLogin: 'admin@crombolo.local',
304
+ secureCookies: true,
305
+ siteName: 'Crombolo',
306
+ componentsDir: 'components',
307
+ componentsCacheDir: join('.crombolo', 'components'),
308
+ renderTimeoutMs: DEFAULT_RENDER_TIMEOUT_MS,
309
+ schemaDir: 'schema',
310
+ schemaRevision: '0',
311
+ mediaDir: 'media',
312
+ stylesheetsDir: 'css',
313
+ logsDir: 'logs',
314
+ scriptsDir: 'scripts',
315
+ marketplaceUrl: 'https://www.npmjs.com/search?q=keywords%3Acrombolo-bundle',
316
+ packageKeyword: 'crombolo-bundle',
317
+ npmRegistry: 'https://registry.npmjs.org',
318
+ keepAllVersionsNewerThanDays: 7,
319
+ keepLatestVersionPerDayForDays: 90,
320
+ memberRegistrationType: 'Member',
321
+ memberSessionMinutes: 60 * 24 * 14,
322
+ maxFailedPasswordAttempts: 5,
323
+ } as const
324
+
325
+ /**
326
+ * A misspelt role is refused rather than defaulted: silently falling back to
327
+ * `all` would put a second set of background jobs and a second schema sync on a
328
+ * node meant to be read-only, which is the failure this flag exists to prevent.
329
+ */
330
+ function assertRole(value: string): asserts value is ServerRole {
331
+ if (!(SERVER_ROLES as readonly string[]).includes(value))
332
+ throw new Error(`role must be one of ${SERVER_ROLES.join(', ')}, not '${value}'.`)
333
+ }
334
+
335
+ function roleFromEnvironment(value: string | undefined): ServerRole {
336
+ if (value === undefined || value === '') return 'all'
337
+ assertRole(value)
338
+ return value
339
+ }
340
+
341
+ /** Which page answers an address nothing else does, optionally for one hostname or language. */
342
+ export interface NotFoundRule {
343
+ /** The page's key. */
344
+ document: string
345
+ host?: string
346
+ culture?: string
347
+ }
348
+
349
+ export interface SeoConfig {
350
+ /** Serve `/sitemap.xml`. Default true; CROMBOLO_SITEMAP. */
351
+ sitemap?: boolean
352
+ /** False keeps search engines out entirely — a staging site. Default true; CROMBOLO_INDEXABLE. */
353
+ indexable?: boolean
354
+ /** The rules in `/robots.txt`; the sitemap's address is added to them. */
355
+ robotsTxt?: string
356
+ /** `og:site_name`; the site's name when unset. */
357
+ siteName?: string
358
+ /** Added to every page title, such as ` | Acme`. */
359
+ titleSuffix?: string
360
+ /** Shown when a page without its own image is shared: an address, or a media item's key. */
361
+ defaultImage?: string
362
+ /** A page key, or rules choosing one per hostname or language. */
363
+ notFoundPage?: string | NotFoundRule[]
364
+ }
365
+
366
+ /** A site's config file exports `defineConfig({...})`; unset keys come from the environment. */
367
+ export function defineConfig(overrides: Partial<CromboloConfig> = {}): Partial<CromboloConfig> {
368
+ return overrides
369
+ }
370
+
371
+ /** Builds the effective config: explicit values, then environment, then defaults. */
372
+ export function loadConfig(
373
+ overrides: Partial<CromboloConfig> = {},
374
+ cwd = process.cwd(),
375
+ ): CromboloConfig {
376
+ const fromEnv: CromboloConfig = {
377
+ port: Number(Bun.env.PORT ?? DEFAULTS.port),
378
+ maxRequestBodyBytes: Bun.env.CROMBOLO_MAX_REQUEST_BODY_MB
379
+ ? Number(Bun.env.CROMBOLO_MAX_REQUEST_BODY_MB) * 1024 * 1024
380
+ : DEFAULTS.maxRequestBodyBytes,
381
+ backOfficePath: Bun.env.CROMBOLO_BACKOFFICE_PATH ?? DEFAULTS.backOfficePath,
382
+ defaultUiLanguage: DEFAULTS.defaultUiLanguage,
383
+ keepUserLoggedIn: DEFAULTS.keepUserLoggedIn,
384
+ allowLocalLogin: DEFAULTS.allowLocalLogin,
385
+ allowPasswordReset: DEFAULTS.allowPasswordReset,
386
+ versionCheckPeriod: DEFAULTS.versionCheckPeriod,
387
+ umbracoCssPath: DEFAULTS.umbracoCssPath,
388
+ signalRSkipNegotiation: DEFAULTS.signalRSkipNegotiation,
389
+ development: Bun.env.NODE_ENV !== 'production',
390
+ sqliteFile: Bun.env.CROMBOLO_SQLITE_FILE ?? DEFAULTS.sqliteFile,
391
+ adminLogin: Bun.env.CROMBOLO_ADMIN_LOGIN ?? DEFAULTS.adminLogin,
392
+ adminPassword: Bun.env.CROMBOLO_ADMIN_PASSWORD,
393
+ secureCookies: Bun.env.CROMBOLO_INSECURE_COOKIES !== 'true',
394
+ siteName: Bun.env.CROMBOLO_SITE_NAME ?? DEFAULTS.siteName,
395
+ siteDir: cwd,
396
+ componentsDir: Bun.env.CROMBOLO_COMPONENTS_DIR ?? DEFAULTS.componentsDir,
397
+ componentsCacheDir: Bun.env.CROMBOLO_COMPONENTS_CACHE_DIR ?? DEFAULTS.componentsCacheDir,
398
+ componentsSnapshot: {
399
+ ...(Bun.env.CROMBOLO_COMPONENTS_GATE_MS
400
+ ? { gateTtlMs: Number(Bun.env.CROMBOLO_COMPONENTS_GATE_MS) }
401
+ : {}),
402
+ ...(Bun.env.CROMBOLO_COMPONENTS_GENERATION_LIMIT
403
+ ? { maxGenerations: Number(Bun.env.CROMBOLO_COMPONENTS_GENERATION_LIMIT) }
404
+ : {}),
405
+ ...(Bun.env.CROMBOLO_COMPONENTS_KEEP
406
+ ? { keep: Number(Bun.env.CROMBOLO_COMPONENTS_KEEP) }
407
+ : {}),
408
+ ...(Bun.env.CROMBOLO_COMPONENTS_SWAP_MS
409
+ ? { minSwapIntervalMs: Number(Bun.env.CROMBOLO_COMPONENTS_SWAP_MS) }
410
+ : {}),
411
+ },
412
+ renderTimeoutMs: Number(Bun.env.CROMBOLO_RENDER_TIMEOUT_MS ?? DEFAULTS.renderTimeoutMs),
413
+ schemaDir: Bun.env.CROMBOLO_SCHEMA_DIR ?? DEFAULTS.schemaDir,
414
+ schemaRevision: Bun.env.CROMBOLO_SCHEMA_REVISION ?? DEFAULTS.schemaRevision,
415
+ nodeId: Bun.env.CROMBOLO_NODE_ID ?? `${hostname()}:${process.pid}`,
416
+ role: roleFromEnvironment(Bun.env.CROMBOLO_ROLE),
417
+ git: undefined,
418
+ schemaStore: undefined,
419
+ schemaCacheDir: Bun.env.CROMBOLO_SCHEMA_CACHE_DIR ?? join(tmpdir(), 'crombolo-schema'),
420
+ // Resolved again below, once it is known whether a store was configured.
421
+ schemaWritable:
422
+ Bun.env.CROMBOLO_SCHEMA_WRITABLE !== undefined
423
+ ? Bun.env.CROMBOLO_SCHEMA_WRITABLE === 'true'
424
+ : Bun.env.NODE_ENV !== 'production',
425
+ syncSchemaAtBoot: Bun.env.CROMBOLO_SYNC_SCHEMA_AT_BOOT !== 'false',
426
+ dialect: Bun.env.CROMBOLO_DB === 'postgres' ? 'postgres' : 'sqlite',
427
+ pgDump: Bun.env.CROMBOLO_PG_DUMP,
428
+ upgradePolicy:
429
+ Bun.env.CROMBOLO_UPGRADE_POLICY === 'pending-allowed' ||
430
+ (Bun.env.CROMBOLO_UPGRADE_POLICY === undefined && Bun.env.NODE_ENV !== 'production')
431
+ ? 'pending-allowed'
432
+ : 'strict',
433
+ versionCleanup: {
434
+ enabled: Bun.env.CROMBOLO_VERSION_CLEANUP === 'true',
435
+ keepAllVersionsNewerThanDays: Number(
436
+ Bun.env.CROMBOLO_VERSION_CLEANUP_KEEP_ALL_DAYS ?? DEFAULTS.keepAllVersionsNewerThanDays,
437
+ ),
438
+ keepLatestVersionPerDayForDays: Number(
439
+ Bun.env.CROMBOLO_VERSION_CLEANUP_KEEP_LATEST_DAYS ??
440
+ DEFAULTS.keepLatestVersionPerDayForDays,
441
+ ),
442
+ },
443
+ mediaDir: Bun.env.CROMBOLO_MEDIA_DIR ?? DEFAULTS.mediaDir,
444
+ mediaStore: undefined,
445
+ logsDir: Bun.env.CROMBOLO_LOGS_DIR ?? DEFAULTS.logsDir,
446
+ logLevel: parseLogLevel(Bun.env.CROMBOLO_LOG_LEVEL),
447
+ logToConsole: Bun.env.CROMBOLO_LOG_TO_CONSOLE !== 'false',
448
+ diagnostics: {
449
+ traceRequests: Bun.env.CROMBOLO_TRACE_REQUESTS === 'true',
450
+ heapIntervalSeconds: Math.max(0, Number(Bun.env.CROMBOLO_HEAP_INTERVAL_SECONDS ?? 0) || 0),
451
+ },
452
+ stylesheetsDir: Bun.env.CROMBOLO_CSS_DIR ?? DEFAULTS.stylesheetsDir,
453
+ scriptsDir: Bun.env.CROMBOLO_SCRIPTS_DIR ?? DEFAULTS.scriptsDir,
454
+ usernameIsEmail: Bun.env.CROMBOLO_USERNAME_IS_EMAIL !== 'false',
455
+ applicationUrl: Bun.env.CROMBOLO_APPLICATION_URL ?? '',
456
+ sendUserLink: undefined,
457
+ email: undefined,
458
+ allowMemberRegistration: Bun.env.CROMBOLO_ALLOW_MEMBER_REGISTRATION === 'true',
459
+ memberRegistrationType:
460
+ Bun.env.CROMBOLO_MEMBER_REGISTRATION_TYPE ?? DEFAULTS.memberRegistrationType,
461
+ memberRegistrationGroups: (Bun.env.CROMBOLO_MEMBER_REGISTRATION_GROUPS ?? '')
462
+ .split(',')
463
+ .map((name) => name.trim())
464
+ .filter(Boolean),
465
+ memberSessionMinutes: Number(
466
+ Bun.env.CROMBOLO_MEMBER_SESSION_MINUTES ?? DEFAULTS.memberSessionMinutes,
467
+ ),
468
+ maxFailedPasswordAttempts: Number(
469
+ Bun.env.CROMBOLO_MAX_FAILED_PASSWORD_ATTEMPTS ?? DEFAULTS.maxFailedPasswordAttempts,
470
+ ),
471
+ redirects: [],
472
+ trackRedirects: Bun.env.CROMBOLO_TRACK_REDIRECTS !== 'false',
473
+ applyBundleContentOnInstall: Bun.env.CROMBOLO_APPLY_BUNDLE_CONTENT !== 'false',
474
+ seo: stripUndefined({
475
+ sitemap: envFlag(Bun.env.CROMBOLO_SITEMAP),
476
+ indexable: envFlag(Bun.env.CROMBOLO_INDEXABLE),
477
+ }),
478
+ // No environment variable: a bundle is an import, which is code.
479
+ bundles: [],
480
+ marketplaceUrl: Bun.env.CROMBOLO_MARKETPLACE_URL ?? DEFAULTS.marketplaceUrl,
481
+ packageKeyword: Bun.env.CROMBOLO_PACKAGE_KEYWORD ?? DEFAULTS.packageKeyword,
482
+ npmRegistry: Bun.env.CROMBOLO_NPM_REGISTRY ?? DEFAULTS.npmRegistry,
483
+ // No environment variable: enabling it takes a provider, which is code.
484
+ assistant: undefined,
485
+ }
486
+ const merged: CromboloConfig = { ...fromEnv, ...stripUndefined(overrides) }
487
+ // Field by field, so a site setting its title suffix keeps the environment's switches.
488
+ merged.seo = { ...fromEnv.seo, ...stripUndefined(overrides.seo ?? {}) }
489
+ // A schema store makes backoffice writes durable, so it carries writability with
490
+ // it — that is the point of configuring one. An explicit setting still wins.
491
+ if (
492
+ merged.schemaStore &&
493
+ Bun.env.CROMBOLO_SCHEMA_WRITABLE === undefined &&
494
+ overrides.schemaWritable === undefined
495
+ ) {
496
+ merged.schemaWritable = true
497
+ }
498
+ // Relative paths are relative to the site, not to wherever the process started.
499
+ merged.siteDir = resolve(cwd, merged.siteDir)
500
+ // Against the site rather than the working directory: it has to sit where the
501
+ // JSX runtime resolves from, which is the site's own tree.
502
+ merged.componentsCacheDir = resolve(merged.siteDir, merged.componentsCacheDir)
503
+ merged.componentsDir = resolve(cwd, merged.componentsDir)
504
+ merged.schemaDir = resolve(cwd, merged.schemaDir)
505
+ merged.mediaDir = resolve(cwd, merged.mediaDir)
506
+ merged.logsDir = resolve(cwd, merged.logsDir)
507
+ merged.stylesheetsDir = resolve(cwd, merged.stylesheetsDir)
508
+ merged.scriptsDir = resolve(cwd, merged.scriptsDir)
509
+ merged.applicationUrl = (merged.applicationUrl || `http://localhost:${merged.port}`).replace(
510
+ /\/$/,
511
+ '',
512
+ )
513
+ if (merged.sqliteFile !== ':memory:') merged.sqliteFile = resolve(cwd, merged.sqliteFile)
514
+ // Checked on the merged value, so a typo in `crombolo.config.ts` is refused on
515
+ // the same terms as one in the environment.
516
+ assertRole(merged.role)
517
+ return merged
518
+ }
519
+
520
+ /** `true`/`false` from the environment; undefined when unset, so a default applies. */
521
+ function envFlag(value: string | undefined): boolean | undefined {
522
+ return value === undefined ? undefined : value !== 'false'
523
+ }
524
+
525
+ const LOG_LEVELS = ['trace', 'debug', 'info', 'warning', 'error', 'fatal'] as const
526
+
527
+ function parseLogLevel(value: string | undefined): CromboloConfig['logLevel'] {
528
+ const level = value?.toLowerCase()
529
+ return (LOG_LEVELS as readonly string[]).includes(level ?? '')
530
+ ? (level as CromboloConfig['logLevel'])
531
+ : 'info'
532
+ }
533
+
534
+ function stripUndefined<T extends object>(value: T): Partial<T> {
535
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial<T>
536
+ }
537
+
538
+ /**
539
+ * The database a banner or log line should name. The Postgres URL carries the
540
+ * password, so only its host and database are shown.
541
+ */
542
+ export function describeDatabase(
543
+ config: Pick<CromboloConfig, 'dialect' | 'sqliteFile'>,
544
+ url: string | undefined = Bun.env.CROMBOLO_POSTGRES_URL ?? Bun.env.DATABASE_URL,
545
+ ): string {
546
+ if (config.dialect !== 'postgres') return config.sqliteFile
547
+ if (!url) return 'postgres'
548
+ try {
549
+ const parsed = new URL(url)
550
+ return `postgres ${parsed.host}${parsed.pathname}`
551
+ } catch {
552
+ return 'postgres'
553
+ }
554
+ }
555
+
556
+ export const VERSION = '0.10.0'