@crombolo/server 0.0.0-stage → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +432 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundles.ts +508 -0
  27. package/src/component-snippets.ts +163 -0
  28. package/src/components.ts +266 -0
  29. package/src/config.ts +548 -0
  30. package/src/content-type-udt.ts +256 -0
  31. package/src/database.ts +109 -0
  32. package/src/diagnostics.ts +143 -0
  33. package/src/dictionary-transfer.ts +123 -0
  34. package/src/domains.ts +384 -0
  35. package/src/editor-types.ts +108 -0
  36. package/src/email-http.ts +79 -0
  37. package/src/email-notice.ts +36 -0
  38. package/src/email-postmark.ts +78 -0
  39. package/src/email-resend.ts +69 -0
  40. package/src/email-ses.ts +307 -0
  41. package/src/email.ts +402 -0
  42. package/src/extensions-notice.ts +35 -0
  43. package/src/file-intake.ts +84 -0
  44. package/src/form-files.ts +241 -0
  45. package/src/form-submit.ts +397 -0
  46. package/src/form-workflows.ts +397 -0
  47. package/src/forms.ts +170 -0
  48. package/src/git-github.ts +171 -0
  49. package/src/git-routes.ts +154 -0
  50. package/src/git.ts +88 -0
  51. package/src/imaging.ts +434 -0
  52. package/src/index.ts +62 -0
  53. package/src/jobs.ts +140 -0
  54. package/src/listen.ts +36 -0
  55. package/src/log-expressions.ts +524 -0
  56. package/src/log-viewer.ts +190 -0
  57. package/src/logging.ts +126 -0
  58. package/src/marketplace.ts +297 -0
  59. package/src/media-files.ts +276 -0
  60. package/src/media-store-azure.ts +218 -0
  61. package/src/media-store-s3.ts +113 -0
  62. package/src/media-store.ts +248 -0
  63. package/src/member-auth.ts +384 -0
  64. package/src/models-builder.ts +106 -0
  65. package/src/no-nodes.ts +64 -0
  66. package/src/not-implemented.ts +44 -0
  67. package/src/oembed.ts +144 -0
  68. package/src/ports.ts +553 -0
  69. package/src/redirects.ts +132 -0
  70. package/src/schema-classify.ts +82 -0
  71. package/src/schema-store.ts +145 -0
  72. package/src/schema.ts +388 -0
  73. package/src/seo.ts +371 -0
  74. package/src/server-events.ts +131 -0
  75. package/src/server.ts +1579 -0
  76. package/src/status.ts +246 -0
  77. package/src/udt.ts +168 -0
  78. package/src/value-schemas.ts +64 -0
  79. package/src/view-runtime.ts +42 -0
  80. package/src/zip.ts +152 -0
  81. package/README.md +0 -3
package/src/imaging.ts ADDED
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Image processing for `/media/…?width=…` URLs, in the query vocabulary
3
+ * Umbraco's ImageSharp URL generator writes: `width`, `height`, `rmode`
4
+ * (crop, max, stretch, pad, boxpad, min), `rxy` (focal point), `cc` (crop
5
+ * coordinates as fractions trimmed from each edge), `format` and `quality`.
6
+ *
7
+ * Bun decodes, resizes and encodes images but cannot crop or pad, so those two
8
+ * go through raw RGBA pixels: Bun re-encodes the source as a plain PNG, which
9
+ * is decoded here, cut, and encoded again. Results are cached on disk by source
10
+ * and request, so each variant is computed once.
11
+ */
12
+ import { deflateSync, inflateSync } from 'node:zlib'
13
+ import type { MediaStore, StoredFile } from './media-store.ts'
14
+
15
+ // --------------------------------------------------------------------- PNG
16
+
17
+ export interface Pixels {
18
+ width: number
19
+ height: number
20
+ /** RGBA, 8 bits per channel, rows top to bottom. */
21
+ data: Uint8Array
22
+ }
23
+
24
+ const SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
25
+
26
+ const CRC_TABLE = (() => {
27
+ const table = new Uint32Array(256)
28
+ for (let n = 0; n < 256; n++) {
29
+ let c = n
30
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
31
+ table[n] = c >>> 0
32
+ }
33
+ return table
34
+ })()
35
+
36
+ function crc32(bytes: Uint8Array): number {
37
+ let c = 0xffffffff
38
+ for (const byte of bytes) c = (CRC_TABLE[(c ^ byte) & 0xff] as number) ^ (c >>> 8)
39
+ return (c ^ 0xffffffff) >>> 0
40
+ }
41
+
42
+ function paeth(a: number, b: number, c: number): number {
43
+ const p = a + b - c
44
+ const pa = Math.abs(p - a)
45
+ const pb = Math.abs(p - b)
46
+ const pc = Math.abs(p - c)
47
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c
48
+ }
49
+
50
+ /** Decodes an 8-bit, non-interlaced PNG of any colour type to RGBA. */
51
+ export function decodePng(bytes: Uint8Array): Pixels {
52
+ if (!SIGNATURE.every((b, i) => bytes[i] === b)) throw new Error('Not a PNG.')
53
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
54
+ let offset = 8
55
+ let width = 0
56
+ let height = 0
57
+ let colorType = 0
58
+ let palette: Uint8Array | undefined
59
+ let transparency: Uint8Array | undefined
60
+ const idat: Uint8Array[] = []
61
+ while (offset < bytes.length) {
62
+ const length = view.getUint32(offset)
63
+ const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8))
64
+ const data = bytes.subarray(offset + 8, offset + 8 + length)
65
+ if (type === 'IHDR') {
66
+ width = view.getUint32(offset + 8)
67
+ height = view.getUint32(offset + 12)
68
+ const bitDepth = data[8]
69
+ colorType = data[9] as number
70
+ if (bitDepth !== 8 || data[12] !== 0)
71
+ throw new Error('Only 8-bit, non-interlaced PNGs are decoded.')
72
+ } else if (type === 'PLTE') palette = data
73
+ else if (type === 'tRNS') transparency = data
74
+ else if (type === 'IDAT') idat.push(data)
75
+ else if (type === 'IEND') break
76
+ offset += 12 + length
77
+ }
78
+ const channels = ({ 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 } as Record<number, number>)[colorType]
79
+ if (!channels) throw new Error(`Unsupported PNG colour type ${colorType}.`)
80
+ const joined = new Uint8Array(idat.reduce((n, c) => n + c.length, 0))
81
+ let at = 0
82
+ for (const chunk of idat) {
83
+ joined.set(chunk, at)
84
+ at += chunk.length
85
+ }
86
+ const raw = new Uint8Array(inflateSync(joined))
87
+ const stride = width * channels
88
+ const rows = new Uint8Array(stride * height)
89
+ for (let y = 0; y < height; y++) {
90
+ const filter = raw[y * (stride + 1)] as number
91
+ const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1))
92
+ const out = rows.subarray(y * stride, (y + 1) * stride)
93
+ const prior = y > 0 ? rows.subarray((y - 1) * stride, y * stride) : undefined
94
+ for (let x = 0; x < stride; x++) {
95
+ const left = x >= channels ? (out[x - channels] as number) : 0
96
+ const up = prior ? (prior[x] as number) : 0
97
+ const upLeft = prior && x >= channels ? (prior[x - channels] as number) : 0
98
+ const value = line[x] as number
99
+ out[x] =
100
+ filter === 0
101
+ ? value
102
+ : filter === 1
103
+ ? value + left
104
+ : filter === 2
105
+ ? value + up
106
+ : filter === 3
107
+ ? value + ((left + up) >> 1)
108
+ : value + paeth(left, up, upLeft)
109
+ }
110
+ }
111
+ const data = new Uint8Array(width * height * 4)
112
+ for (let i = 0; i < width * height; i++) {
113
+ const s = i * channels
114
+ const d = i * 4
115
+ if (colorType === 6) data.set(rows.subarray(s, s + 4), d)
116
+ else if (colorType === 2) {
117
+ data.set(rows.subarray(s, s + 3), d)
118
+ data[d + 3] = 255
119
+ } else if (colorType === 0 || colorType === 4) {
120
+ const g = rows[s] as number
121
+ data[d] = g
122
+ data[d + 1] = g
123
+ data[d + 2] = g
124
+ data[d + 3] = colorType === 4 ? (rows[s + 1] as number) : 255
125
+ } else {
126
+ const index = rows[s] as number
127
+ data[d] = palette?.[index * 3] ?? 0
128
+ data[d + 1] = palette?.[index * 3 + 1] ?? 0
129
+ data[d + 2] = palette?.[index * 3 + 2] ?? 0
130
+ data[d + 3] = transparency?.[index] ?? 255
131
+ }
132
+ }
133
+ return { width, height, data }
134
+ }
135
+
136
+ function chunk(type: string, data: Uint8Array): Uint8Array {
137
+ const out = new Uint8Array(12 + data.length)
138
+ const view = new DataView(out.buffer)
139
+ view.setUint32(0, data.length)
140
+ out.set(new TextEncoder().encode(type), 4)
141
+ out.set(data, 8)
142
+ view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)))
143
+ return out
144
+ }
145
+
146
+ /** Encodes RGBA pixels as a PNG (no filtering; deflate does the work). */
147
+ export function encodePng(pixels: Pixels): Uint8Array {
148
+ const header = new Uint8Array(13)
149
+ const view = new DataView(header.buffer)
150
+ view.setUint32(0, pixels.width)
151
+ view.setUint32(4, pixels.height)
152
+ header.set([8, 6, 0, 0, 0], 8)
153
+ const stride = pixels.width * 4
154
+ const raw = new Uint8Array((stride + 1) * pixels.height)
155
+ for (let y = 0; y < pixels.height; y++)
156
+ raw.set(pixels.data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1)
157
+ const parts = [
158
+ SIGNATURE,
159
+ chunk('IHDR', header),
160
+ chunk('IDAT', new Uint8Array(deflateSync(raw))),
161
+ chunk('IEND', new Uint8Array()),
162
+ ]
163
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
164
+ let at = 0
165
+ for (const part of parts) {
166
+ out.set(part, at)
167
+ at += part.length
168
+ }
169
+ return out
170
+ }
171
+
172
+ export function cropPixels(
173
+ pixels: Pixels,
174
+ x: number,
175
+ y: number,
176
+ width: number,
177
+ height: number,
178
+ ): Pixels {
179
+ const w = Math.max(1, Math.min(width, pixels.width - x))
180
+ const h = Math.max(1, Math.min(height, pixels.height - y))
181
+ const data = new Uint8Array(w * h * 4)
182
+ for (let row = 0; row < h; row++) {
183
+ const from = ((y + row) * pixels.width + x) * 4
184
+ data.set(pixels.data.subarray(from, from + w * 4), row * w * 4)
185
+ }
186
+ return { width: w, height: h, data }
187
+ }
188
+
189
+ /** Places pixels centred on a canvas of the given size, filled with `background` (RGBA). */
190
+ export function padPixels(
191
+ pixels: Pixels,
192
+ width: number,
193
+ height: number,
194
+ background: [number, number, number, number] = [255, 255, 255, 0],
195
+ ): Pixels {
196
+ const data = new Uint8Array(width * height * 4)
197
+ for (let i = 0; i < width * height; i++) data.set(background, i * 4)
198
+ const x0 = Math.floor((width - pixels.width) / 2)
199
+ const y0 = Math.floor((height - pixels.height) / 2)
200
+ for (let row = 0; row < pixels.height; row++) {
201
+ const to = ((y0 + row) * width + x0) * 4
202
+ data.set(pixels.data.subarray(row * pixels.width * 4, (row + 1) * pixels.width * 4), to)
203
+ }
204
+ return { width, height, data }
205
+ }
206
+
207
+ // ------------------------------------------------------------ the request
208
+
209
+ export type ResizeMode = 'crop' | 'max' | 'stretch' | 'pad' | 'boxpad' | 'min'
210
+ export type OutputFormat = 'jpg' | 'png' | 'webp'
211
+
212
+ export interface ImagingRequest {
213
+ width?: number
214
+ height?: number
215
+ mode: ResizeMode
216
+ /** Focal point, 0–1 from the left and top. */
217
+ focalPoint?: { left: number; top: number }
218
+ /** Fractions trimmed from each edge. */
219
+ crop?: { left: number; top: number; right: number; bottom: number }
220
+ format?: OutputFormat
221
+ quality?: number
222
+ }
223
+
224
+ const MODES: readonly ResizeMode[] = ['crop', 'max', 'stretch', 'pad', 'boxpad', 'min']
225
+ const MAX_SIDE = 5000
226
+
227
+ /** The imaging request a query asks for, or undefined when it asks for the original. */
228
+ export function parseImagingQuery(params: URLSearchParams): ImagingRequest | undefined {
229
+ const size = (name: string) => {
230
+ const value = Number(params.get(name))
231
+ return Number.isFinite(value) && value > 0 ? Math.min(Math.round(value), MAX_SIDE) : undefined
232
+ }
233
+ const fractions = (name: string, count: number) => {
234
+ const parts = (params.get(name) ?? '').split(',').map(Number)
235
+ return parts.length === count && parts.every((n) => Number.isFinite(n) && n >= 0 && n <= 1)
236
+ ? parts
237
+ : undefined
238
+ }
239
+ const width = size('width')
240
+ const height = size('height')
241
+ const rxy = fractions('rxy', 2)
242
+ const cc = fractions('cc', 4)
243
+ const format = (params.get('format') ?? '').toLowerCase().replace('jpeg', 'jpg')
244
+ const quality = size('quality')
245
+ if (!width && !height && !cc && !format) return undefined
246
+ const mode = (params.get('rmode') ?? '').toLowerCase() as ResizeMode
247
+ return {
248
+ width,
249
+ height,
250
+ mode: MODES.includes(mode) ? mode : 'crop',
251
+ focalPoint: rxy ? { left: rxy[0] as number, top: rxy[1] as number } : undefined,
252
+ crop: cc
253
+ ? {
254
+ left: cc[0] as number,
255
+ top: cc[1] as number,
256
+ right: cc[2] as number,
257
+ bottom: cc[3] as number,
258
+ }
259
+ : undefined,
260
+ format: ['jpg', 'png', 'webp'].includes(format) ? (format as OutputFormat) : undefined,
261
+ quality: quality ? Math.min(quality, 100) : undefined,
262
+ }
263
+ }
264
+
265
+ const formatOf = (source: string): OutputFormat => {
266
+ const extension = source.split('.').pop()?.toLowerCase()
267
+ return extension === 'png' || extension === 'gif' ? 'png' : extension === 'webp' ? 'webp' : 'jpg'
268
+ }
269
+
270
+ /** The crop window, in source pixels, that fills width×height around the focal point. */
271
+ function coverWindow(
272
+ source: { width: number; height: number },
273
+ width: number,
274
+ height: number,
275
+ focal = { left: 0.5, top: 0.5 },
276
+ ) {
277
+ const scale = Math.max(width / source.width, height / source.height)
278
+ const w = Math.min(source.width, Math.round(width / scale))
279
+ const h = Math.min(source.height, Math.round(height / scale))
280
+ const x = Math.round(Math.min(Math.max(focal.left * source.width - w / 2, 0), source.width - w))
281
+ const y = Math.round(Math.min(Math.max(focal.top * source.height - h / 2, 0), source.height - h))
282
+ return { x, y, w, h }
283
+ }
284
+
285
+ /** Applies a request to an image's bytes; returns the new bytes and their content type. */
286
+ export async function processImage(
287
+ bytes: Uint8Array,
288
+ request: ImagingRequest,
289
+ sourceName: string,
290
+ ): Promise<{ bytes: Uint8Array; contentType: string }> {
291
+ const format = request.format ?? formatOf(sourceName)
292
+ let pixels: Pixels | undefined
293
+ const decode = async () => {
294
+ pixels ??= decodePng(await new Bun.Image(bytes).png().bytes())
295
+ return pixels
296
+ }
297
+
298
+ if (request.crop) {
299
+ const source = await decode()
300
+ const x = Math.round(request.crop.left * source.width)
301
+ const y = Math.round(request.crop.top * source.height)
302
+ const right = Math.round(request.crop.right * source.width)
303
+ const bottom = Math.round(request.crop.bottom * source.height)
304
+ pixels = cropPixels(source, x, y, source.width - x - right, source.height - y - bottom)
305
+ }
306
+
307
+ const current = pixels ?? (await new Bun.Image(bytes).metadata())
308
+ const { width, height } = request
309
+ let resize: { width: number; height?: number; fit: 'fill' | 'inside' } | undefined
310
+
311
+ if (width && height) {
312
+ switch (request.mode) {
313
+ case 'stretch':
314
+ resize = { width, height, fit: 'fill' }
315
+ break
316
+ case 'max':
317
+ resize = { width, height, fit: 'inside' }
318
+ break
319
+ case 'min': {
320
+ const scale = Math.min(1, Math.max(width / current.width, height / current.height))
321
+ resize = {
322
+ width: Math.max(1, Math.round(current.width * scale)),
323
+ height: Math.max(1, Math.round(current.height * scale)),
324
+ fit: 'fill',
325
+ }
326
+ break
327
+ }
328
+ case 'pad':
329
+ case 'boxpad': {
330
+ const scale = Math.min(width / current.width, height / current.height)
331
+ const fitted =
332
+ request.mode === 'boxpad' && scale > 1
333
+ ? { w: current.width, h: current.height }
334
+ : {
335
+ w: Math.max(1, Math.round(current.width * scale)),
336
+ h: Math.max(1, Math.round(current.height * scale)),
337
+ }
338
+ const scaled = decodePng(
339
+ await new Bun.Image(pixels ? encodePng(pixels) : bytes)
340
+ .resize(fitted.w, fitted.h, { fit: 'fill' })
341
+ .png()
342
+ .bytes(),
343
+ )
344
+ pixels = padPixels(scaled, width, height)
345
+ break
346
+ }
347
+ default: {
348
+ if (!request.crop) {
349
+ const source = await decode()
350
+ const window = coverWindow(source, width, height, request.focalPoint)
351
+ pixels = cropPixels(source, window.x, window.y, window.w, window.h)
352
+ }
353
+ resize = { width, height, fit: 'fill' }
354
+ }
355
+ }
356
+ } else if (width || height) {
357
+ const scale = width ? width / current.width : (height as number) / current.height
358
+ resize = {
359
+ width: Math.max(1, Math.round(current.width * scale)),
360
+ height: Math.max(1, Math.round(current.height * scale)),
361
+ fit: 'fill',
362
+ }
363
+ }
364
+
365
+ let image = new Bun.Image(pixels ? encodePng(pixels) : bytes)
366
+ if (resize) image = image.resize(resize.width, resize.height, { fit: resize.fit })
367
+ const quality = request.quality
368
+ const encoded =
369
+ format === 'png'
370
+ ? image.png()
371
+ : format === 'webp'
372
+ ? image.webp(quality ? { quality } : undefined)
373
+ : image.jpeg(quality ? { quality } : undefined)
374
+ return {
375
+ bytes: await encoded.bytes(),
376
+ contentType: format === 'png' ? 'image/png' : format === 'webp' ? 'image/webp' : 'image/jpeg',
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Serves processed variants from a cache in the media store, so each variant is
382
+ * computed once and — wherever the store keeps its bytes — is there for every
383
+ * node, not only the one that computed it.
384
+ *
385
+ * The cache key includes the source's etag, so replacing an image invalidates
386
+ * every crop of it without anything having to track that.
387
+ */
388
+ export class ImageProcessor {
389
+ #store: MediaStore
390
+
391
+ constructor(store: MediaStore) {
392
+ this.#store = store
393
+ }
394
+
395
+ async variant(
396
+ source: StoredFile,
397
+ request: ImagingRequest,
398
+ ): Promise<{ key: string; bytes: Uint8Array; contentType: string }> {
399
+ const format = request.format ?? formatOf(source.key)
400
+ const hash = new Bun.CryptoHasher('sha1')
401
+ .update(`${source.key}|${source.etag}|${JSON.stringify(request)}`)
402
+ .digest('hex')
403
+ const key = `.cache/${hash.slice(0, 2)}/${hash}.${format}`
404
+ const contentType =
405
+ format === 'png' ? 'image/png' : format === 'webp' ? 'image/webp' : 'image/jpeg'
406
+ const cached = await this.#store.get(key)
407
+ if (cached) return { key, bytes: await cached.bytes(), contentType }
408
+ const result = await processImage(await source.bytes(), request, source.key)
409
+ await this.#store.put(key, result.bytes, { contentType: result.contentType })
410
+ return { key, bytes: result.bytes, contentType: result.contentType }
411
+ }
412
+ }
413
+
414
+ /** The query Umbraco's URL generator would write for a resize. */
415
+ export function resizeQuery(options: {
416
+ width?: number
417
+ height?: number
418
+ mode?: string
419
+ format?: string
420
+ focalPoint?: { left: number; top: number } | null
421
+ crop?: { x1: number; y1: number; x2: number; y2: number } | null
422
+ }): string {
423
+ const params = new URLSearchParams()
424
+ if (options.crop)
425
+ params.set('cc', [options.crop.x1, options.crop.y1, options.crop.x2, options.crop.y2].join(','))
426
+ else if (options.focalPoint)
427
+ params.set('rxy', [options.focalPoint.left, options.focalPoint.top].join(','))
428
+ if (options.width) params.set('width', String(options.width))
429
+ if (options.height) params.set('height', String(options.height))
430
+ if (options.mode) params.set('rmode', options.mode.toLowerCase())
431
+ if (options.format) params.set('format', options.format)
432
+ const query = params.toString()
433
+ return query ? `?${query}` : ''
434
+ }
package/src/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ export * from './adapters/blueprints.ts'
2
+ export * from './adapters/bundles.ts'
3
+ export * from './adapters/content.ts'
4
+ export * from './adapters/documents.ts'
5
+ export * from './adapters/elements.ts'
6
+ export * from './adapters/media.ts'
7
+ export * from './adapters/member-groups.ts'
8
+ export * from './adapters/members.ts'
9
+ export * from './adapters/public-access.ts'
10
+ export * from './adapters/redirects.ts'
11
+ export * from './adapters/references.ts'
12
+ export * from './adapters/template-files.ts'
13
+ export * from './backup.ts'
14
+ export * from './bundles.ts'
15
+ export * from './components.ts'
16
+ export * from './config.ts'
17
+ export * from './content-type-udt.ts'
18
+ export * from './database.ts'
19
+ export * from './diagnostics.ts'
20
+ export * from './dictionary-transfer.ts'
21
+ export * from './domains.ts'
22
+ export * from './editor-types.ts'
23
+ export * from './email.ts'
24
+ export * from './email-http.ts'
25
+ export * from './email-notice.ts'
26
+ export * from './email-postmark.ts'
27
+ export * from './email-resend.ts'
28
+ export * from './email-ses.ts'
29
+ export * from './extensions-notice.ts'
30
+ export * from './file-intake.ts'
31
+ export * from './form-files.ts'
32
+ export * from './form-submit.ts'
33
+ export * from './form-workflows.ts'
34
+ export * from './forms.ts'
35
+ export * from './git.ts'
36
+ export * from './git-github.ts'
37
+ export * from './git-routes.ts'
38
+ export * from './imaging.ts'
39
+ export * from './jobs.ts'
40
+ export * from './listen.ts'
41
+ export * from './log-expressions.ts'
42
+ export * from './log-viewer.ts'
43
+ export * from './logging.ts'
44
+ export * from './marketplace.ts'
45
+ export * from './media-files.ts'
46
+ export * from './media-store.ts'
47
+ export * from './media-store-azure.ts'
48
+ export * from './media-store-s3.ts'
49
+ export * from './member-auth.ts'
50
+ export * from './models-builder.ts'
51
+ export * from './no-nodes.ts'
52
+ export * from './oembed.ts'
53
+ export * from './ports.ts'
54
+ export * from './redirects.ts'
55
+ export * from './schema.ts'
56
+ export * from './schema-store.ts'
57
+ export * from './seo.ts'
58
+ export * from './server.ts'
59
+ export * from './server-events.ts'
60
+ export * from './status.ts'
61
+ export * from './udt.ts'
62
+ export * from './view-runtime.ts'
package/src/jobs.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Background jobs, as Umbraco runs them: scheduled publishing every minute and
3
+ * version cleanup every hour when it is enabled. Each run can also be called directly, which is
4
+ * how tests drive them. On a multi-node Postgres site every node runs them;
5
+ * each schedule is claimed atomically, so it runs once.
6
+ */
7
+
8
+ import type { NodeSchemaState } from '@crombolo/data'
9
+ import { type Db, DocumentRepository, PublishBlockedError } from '@crombolo/data'
10
+ import type { PublishedCache } from '@crombolo/render'
11
+ import type { WorkflowRunner, WorkflowRunReport } from './form-workflows.ts'
12
+ import { logger } from './logging.ts'
13
+ import type { MediaFileStore } from './media-files.ts'
14
+
15
+ export interface JobsOptions {
16
+ db: Db
17
+ cache: PublishedCache
18
+ nodeState: NodeSchemaState
19
+ nodeId: string
20
+ /** Told about each document a job changed, for the server event hub. */
21
+ onDocumentChanged?: (key: string) => void
22
+ scheduledPublishingMs?: number
23
+ versionCleanup: {
24
+ enabled: boolean
25
+ keepAllVersionsNewerThanDays: number
26
+ keepLatestVersionPerDayForDays: number
27
+ }
28
+ /** Temporary uploads past their lifetime are dropped hourly. */
29
+ mediaFiles?: MediaFileStore
30
+ /**
31
+ * Drains the form workflow queue. Absent on a node that should not run them,
32
+ * which is the same rule the other jobs follow.
33
+ */
34
+ formWorkflows?: WorkflowRunner
35
+ formWorkflowsMs?: number
36
+ }
37
+
38
+ export interface ScheduledRun {
39
+ published: string[]
40
+ unpublished: string[]
41
+ failed: Array<{ key: string; reason: string }>
42
+ }
43
+
44
+ export interface BackgroundJobs {
45
+ runScheduledPublishing(now?: Date): Promise<ScheduledRun>
46
+ /** Runs the form workflows that are due; a no-op when none are configured. */
47
+ runFormWorkflows(now?: Date): Promise<WorkflowRunReport>
48
+ /** Runs whether or not the hourly job is enabled; the policy decides what goes. */
49
+ runVersionCleanup(now?: Date): Promise<{ nodes: number; versionsDeleted: number }>
50
+ start(): void
51
+ stop(): void
52
+ /** Whether the timers are armed; false on a node whose role is not to run them. */
53
+ started(): boolean
54
+ }
55
+
56
+ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
57
+ const documents = new DocumentRepository(options.db, {
58
+ nodeState: options.nodeState,
59
+ nodeId: options.nodeId,
60
+ })
61
+ const timers: Array<ReturnType<typeof setInterval>> = []
62
+ let running = false
63
+
64
+ async function runScheduledPublishing(now = new Date()): Promise<ScheduledRun> {
65
+ const run: ScheduledRun = { published: [], unpublished: [], failed: [] }
66
+ for (const due of await documents.dueSchedules(now)) {
67
+ if (!(await documents.claimSchedule(due.id))) continue
68
+ const cultures = due.culture ? [due.culture] : null
69
+ try {
70
+ if (due.action === 'Release') {
71
+ await documents.publish(due.key, cultures)
72
+ run.published.push(due.key)
73
+ } else {
74
+ await documents.unpublish(due.key, cultures)
75
+ run.unpublished.push(due.key)
76
+ }
77
+ options.onDocumentChanged?.(due.key)
78
+ } catch (error) {
79
+ if (!(error instanceof PublishBlockedError)) throw error
80
+ run.failed.push({ key: due.key, reason: error.message })
81
+ }
82
+ }
83
+ if (run.published.length + run.unpublished.length > 0) options.cache.invalidate()
84
+ return run
85
+ }
86
+
87
+ async function runVersionCleanup(now = new Date()) {
88
+ return documents.cleanupVersions(now, options.versionCleanup)
89
+ }
90
+
91
+ async function runFormWorkflows(now = new Date()): Promise<WorkflowRunReport> {
92
+ return (
93
+ (await options.formWorkflows?.runDue(now)) ?? {
94
+ claimed: 0,
95
+ done: 0,
96
+ retrying: 0,
97
+ failed: 0,
98
+ }
99
+ )
100
+ }
101
+
102
+ const guarded = (job: () => Promise<unknown>) => async () => {
103
+ if (running) return
104
+ running = true
105
+ try {
106
+ await job()
107
+ } catch (error) {
108
+ logger('jobs').error('A background job failed', { error })
109
+ } finally {
110
+ running = false
111
+ }
112
+ }
113
+
114
+ const every = (ms: number, job: () => Promise<unknown>) => {
115
+ const timer = setInterval(guarded(job), ms)
116
+ timer.unref()
117
+ timers.push(timer)
118
+ }
119
+
120
+ return {
121
+ runScheduledPublishing,
122
+ runVersionCleanup,
123
+ runFormWorkflows,
124
+ start() {
125
+ every(options.scheduledPublishingMs ?? 60_000, runScheduledPublishing)
126
+ // More often than the other jobs: somebody waiting on a form notification
127
+ // notices a minute, and a queue row is cheap to look for.
128
+ if (options.formWorkflows) every(options.formWorkflowsMs ?? 15_000, runFormWorkflows)
129
+ if (options.versionCleanup.enabled) every(60 * 60_000, runVersionCleanup)
130
+ const files = options.mediaFiles
131
+ if (files) every(60 * 60_000, () => files.cleanupExpired())
132
+ },
133
+ stop() {
134
+ for (const timer of timers.splice(0)) clearInterval(timer)
135
+ },
136
+ started() {
137
+ return timers.length > 0
138
+ },
139
+ }
140
+ }
package/src/listen.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The check that runs before a process binds its port.
3
+ *
4
+ * A local run and the container stack serve the same port, so exactly one of them
5
+ * can hold it — and "address in use" is then almost always the other one still
6
+ * running. Bun's own `EADDRINUSE` arrives after the database has been migrated and
7
+ * seeded, and says nothing about the likely cause, so this goes first.
8
+ */
9
+ import { connect } from 'node:net'
10
+
11
+ /** Whether something is already listening on the port locally. */
12
+ export function portInUse(port: number, timeoutMs = 500): Promise<boolean> {
13
+ return new Promise((resolve) => {
14
+ // A connect attempt rather than a trial bind: binding and releasing leaves a
15
+ // window in which the real listen can fail, which would be worse than the
16
+ // problem being detected.
17
+ const socket = connect({ host: '127.0.0.1', port })
18
+ const settle = (inUse: boolean) => {
19
+ socket.destroy()
20
+ resolve(inUse)
21
+ }
22
+ socket.once('connect', () => settle(true))
23
+ socket.once('error', () => settle(false))
24
+ socket.setTimeout(timeoutMs, () => settle(false))
25
+ })
26
+ }
27
+
28
+ /** Throws naming the likely cause when the port is taken. */
29
+ export async function assertPortAvailable(port: number): Promise<void> {
30
+ if (!(await portInUse(port))) return
31
+ throw new Error(
32
+ `Port ${port} is already in use, so this server cannot start.\n` +
33
+ ` The container stack publishes the same port — "bun run docker:down" stops it.\n` +
34
+ ` Otherwise set PORT (or CROMBOLO_PORT for the stack) to something else.`,
35
+ )
36
+ }