@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.
- 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 +432 -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/bundles.ts +508 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +548 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +109 -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 +62 -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 +297 -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 +1579 -0
- package/src/status.ts +246 -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
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
|
+
}
|