@gotcos/glasses-server 6.3.1 → 6.5.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.
@@ -0,0 +1,458 @@
1
+ // Image ingestion safety — every byte that enters the media store passes
2
+ // through here. Magic-byte type detection (never trust MIME or filename),
3
+ // header dimension parsing (decompression-bomb defense BEFORE any decode),
4
+ // strict base64, and ffmpeg normalization (EXIF/metadata stripped, bounded
5
+ // output size, deterministic JPEG profile).
6
+ //
7
+ // ffmpeg runs via spawn with an argument array — never a shell string — with
8
+ // -nostdin, a hard timeout, bounded stderr, and private tmp paths. If ffmpeg
9
+ // is unavailable the caller gets a typed `media_processing_unavailable`
10
+ // failure; unnormalized originals are NEVER stored as a fallback.
11
+
12
+ import { spawn } from 'node:child_process'
13
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
14
+ import { tmpdir } from 'node:os'
15
+ import { join } from 'node:path'
16
+ import type { MediaMime } from '../../shared/media-attachment.js'
17
+
18
+ // ── Limits (Release A contract) ──────────────────────────────────────────────
19
+
20
+ export const MAX_IMAGE_BYTES = 2 * 1024 * 1024 // 2 MiB decoded per source image
21
+ // Agent artifacts are often lossless PNG/WebP/HEIC/AVIF and can be larger
22
+ // than the phone-upload contract before COS re-encodes them. This separate,
23
+ // trusted-local boundary stays bounded and still probes dimensions before a
24
+ // full decode; normalized bytes return to the original JPEG contract.
25
+ export const MAX_OUTPUT_ARTIFACT_BYTES = 16 * 1024 * 1024
26
+ export const MAX_BATCH_BYTES = 8 * 1024 * 1024 // 8 MiB decoded per request
27
+ export const MAX_MEGAPIXELS = 16_000_000 // 16 MP per source image
28
+ export const NORMALIZED_MAX_EDGE = 1024 // longest edge after normalization
29
+ export const THUMB_MAX_EDGE = 256
30
+ const FFMPEG_TIMEOUT_MS = 15_000
31
+ const FFMPEG_STDERR_CAP = 4096
32
+
33
+ // ── Typed failures ───────────────────────────────────────────────────────────
34
+
35
+ export type ImageSafetyErrorCode =
36
+ | 'invalid_base64'
37
+ | 'unsupported_format'
38
+ | 'image_too_large'
39
+ | 'dimensions_too_large'
40
+ | 'corrupt_image'
41
+ | 'media_processing_unavailable'
42
+ | 'normalization_failed'
43
+
44
+ export class ImageSafetyError extends Error {
45
+ readonly code: ImageSafetyErrorCode
46
+ constructor(code: ImageSafetyErrorCode, message: string) {
47
+ super(message)
48
+ this.code = code
49
+ this.name = 'ImageSafetyError'
50
+ }
51
+ }
52
+
53
+ // ── Strict base64 ────────────────────────────────────────────────────────────
54
+ // Node's Buffer.from(b64) silently tolerates garbage; reject malformed input
55
+ // explicitly so a truncated/hostile payload can't half-decode into the store.
56
+
57
+ const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/
58
+
59
+ export function strictBase64Decode(b64: unknown): Buffer {
60
+ if (typeof b64 !== 'string' || b64.length === 0) {
61
+ throw new ImageSafetyError('invalid_base64', 'image data must be a non-empty base64 string')
62
+ }
63
+ const clean = b64.replace(/\s+/g, '')
64
+ if (clean.length % 4 !== 0 || !BASE64_RE.test(clean)) {
65
+ throw new ImageSafetyError('invalid_base64', 'malformed base64 image data')
66
+ }
67
+ const buf = Buffer.from(clean, 'base64')
68
+ // Round-trip length check catches embedded padding / truncation.
69
+ const expected = (clean.length / 4) * 3 - (clean.endsWith('==') ? 2 : clean.endsWith('=') ? 1 : 0)
70
+ if (buf.length !== expected) {
71
+ throw new ImageSafetyError('invalid_base64', 'base64 image data failed round-trip validation')
72
+ }
73
+ return buf
74
+ }
75
+
76
+ // ── Magic-byte type detection ────────────────────────────────────────────────
77
+ // JPEG: FF D8 FF. PNG: 89 50 4E 47 0D 0A 1A 0A. Everything else — SVG, GIF,
78
+ // PDF, HEIC, polyglots, data URIs — is rejected at this gate.
79
+
80
+ export function sniffImageType(buf: Buffer): MediaMime | null {
81
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'
82
+ if (
83
+ buf.length >= 8 &&
84
+ buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
85
+ buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
86
+ ) return 'image/png'
87
+ return null
88
+ }
89
+
90
+ export type OutputArtifactType = MediaMime | 'image/webp' | 'image/heic' | 'image/avif'
91
+
92
+ /** Magic-byte allowlist for already-local agent artifacts. Filenames and
93
+ * claimed MIME are never trusted. SVG/GIF/PDF remain rejected. */
94
+ export function sniffOutputArtifactType(buf: Buffer): OutputArtifactType | null {
95
+ const baseline = sniffImageType(buf)
96
+ if (baseline) return baseline
97
+ if (buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') {
98
+ return 'image/webp'
99
+ }
100
+ if (buf.length >= 12 && buf.toString('ascii', 4, 8) === 'ftyp') {
101
+ const declaredBoxSize = buf.readUInt32BE(0)
102
+ const boxEnd = Math.min(buf.length, declaredBoxSize >= 16 ? declaredBoxSize : 16, 256)
103
+ const brands: string[] = [buf.toString('ascii', 8, 12)]
104
+ for (let offset = 16; offset + 4 <= boxEnd; offset += 4) brands.push(buf.toString('ascii', offset, offset + 4))
105
+ if (brands.some((brand) => brand === 'avif' || brand === 'avis')) return 'image/avif'
106
+ if (brands.some((brand) => ['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'mif1', 'msf1'].includes(brand))) {
107
+ return 'image/heic'
108
+ }
109
+ }
110
+ return null
111
+ }
112
+
113
+ // ── Header dimension parsing ─────────────────────────────────────────────────
114
+ // Read declared dimensions from the container header itself so the megapixel
115
+ // gate runs BEFORE any pixel decode (decompression-bomb defense).
116
+
117
+ export function parsePngDimensions(buf: Buffer): { width: number; height: number } | null {
118
+ // IHDR must be the first chunk: 8-byte signature, 4-byte len, 'IHDR', W, H.
119
+ if (buf.length < 24) return null
120
+ if (buf.toString('ascii', 12, 16) !== 'IHDR') return null
121
+ const width = buf.readUInt32BE(16)
122
+ const height = buf.readUInt32BE(20)
123
+ if (width === 0 || height === 0) return null
124
+ return { width, height }
125
+ }
126
+
127
+ export function parseJpegDimensions(buf: Buffer): { width: number; height: number } | null {
128
+ // Walk JPEG markers to the first SOF0-SOF15 frame header (excluding
129
+ // DHT/DAC/RST which share the 0xC0 nibble but aren't frames).
130
+ let off = 2
131
+ while (off + 9 < buf.length) {
132
+ if (buf[off] !== 0xff) return null
133
+ const marker = buf[off + 1]
134
+ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) {
135
+ off += 2
136
+ continue
137
+ }
138
+ const size = buf.readUInt16BE(off + 2)
139
+ if (size < 2) return null
140
+ const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc
141
+ if (isSof) {
142
+ if (off + 9 > buf.length) return null
143
+ const height = buf.readUInt16BE(off + 5)
144
+ const width = buf.readUInt16BE(off + 7)
145
+ if (width === 0 || height === 0) return null
146
+ return { width, height }
147
+ }
148
+ off += 2 + size
149
+ }
150
+ return null
151
+ }
152
+
153
+ export function parseImageDimensions(buf: Buffer, mime: MediaMime): { width: number; height: number } | null {
154
+ return mime === 'image/png' ? parsePngDimensions(buf) : parseJpegDimensions(buf)
155
+ }
156
+
157
+ // ── Pre-normalization validation ─────────────────────────────────────────────
158
+
159
+ export interface ValidatedImage {
160
+ bytes: Buffer
161
+ mime: MediaMime
162
+ width: number
163
+ height: number
164
+ }
165
+
166
+ /** Full ingestion gate for ONE source image: type sniff, size cap, header
167
+ * dimension consistency, megapixel bomb defense. Throws ImageSafetyError. */
168
+ export function validateSourceImage(bytes: Buffer): ValidatedImage {
169
+ if (bytes.length > MAX_IMAGE_BYTES) {
170
+ throw new ImageSafetyError('image_too_large', `image is ${bytes.length} bytes (max ${MAX_IMAGE_BYTES})`)
171
+ }
172
+ const mime = sniffImageType(bytes)
173
+ if (!mime) {
174
+ throw new ImageSafetyError('unsupported_format', 'only JPEG and PNG images are accepted')
175
+ }
176
+ const dims = parseImageDimensions(bytes, mime)
177
+ if (!dims) {
178
+ throw new ImageSafetyError('corrupt_image', 'image header has no readable dimensions')
179
+ }
180
+ if (dims.width * dims.height > MAX_MEGAPIXELS) {
181
+ throw new ImageSafetyError('dimensions_too_large', `${dims.width}x${dims.height} exceeds ${MAX_MEGAPIXELS / 1e6}MP limit`)
182
+ }
183
+ return { bytes, mime, width: dims.width, height: dims.height }
184
+ }
185
+
186
+ // ── ffmpeg availability ──────────────────────────────────────────────────────
187
+
188
+ let ffmpegReady: boolean | null = null
189
+
190
+ /** Probe ffmpeg once per process. Health surfaces this as mediaProcessingReady. */
191
+ export async function isMediaProcessingReady(): Promise<boolean> {
192
+ if (ffmpegReady !== null) return ffmpegReady
193
+ ffmpegReady = await new Promise<boolean>((resolve) => {
194
+ let settled = false
195
+ const settle = (ok: boolean) => { if (!settled) { settled = true; resolve(ok) } }
196
+ try {
197
+ const proc = spawn('ffmpeg', ['-version'], { stdio: ['ignore', 'ignore', 'ignore'] })
198
+ const timer = setTimeout(() => { proc.kill('SIGKILL'); settle(false) }, 5_000)
199
+ proc.on('close', (code) => { clearTimeout(timer); settle(code === 0) })
200
+ proc.on('error', () => { clearTimeout(timer); settle(false) })
201
+ } catch {
202
+ settle(false)
203
+ }
204
+ })
205
+ return ffmpegReady
206
+ }
207
+
208
+ /** Test hook — reset the cached probe result. */
209
+ export function _resetMediaProcessingProbe(): void {
210
+ ffmpegReady = null
211
+ }
212
+
213
+ // ── ffmpeg normalization ─────────────────────────────────────────────────────
214
+
215
+ function runFfmpeg(args: string[]): Promise<void> {
216
+ return new Promise((resolve, reject) => {
217
+ let stderr = ''
218
+ let settled = false
219
+ const settle = (err?: Error) => {
220
+ if (settled) return
221
+ settled = true
222
+ clearTimeout(timer)
223
+ if (err) reject(err)
224
+ else resolve()
225
+ }
226
+ const proc = spawn('ffmpeg', ['-nostdin', '-v', 'error', ...args], {
227
+ stdio: ['ignore', 'ignore', 'pipe'],
228
+ })
229
+ const timer = setTimeout(() => {
230
+ proc.kill('SIGKILL')
231
+ settle(new ImageSafetyError('normalization_failed', 'ffmpeg timed out'))
232
+ }, FFMPEG_TIMEOUT_MS)
233
+ proc.stderr.on('data', (chunk: Buffer) => {
234
+ if (stderr.length < FFMPEG_STDERR_CAP) stderr += chunk.toString().slice(0, FFMPEG_STDERR_CAP - stderr.length)
235
+ })
236
+ proc.on('close', (code) => {
237
+ if (code === 0) settle()
238
+ else settle(new ImageSafetyError('normalization_failed', `ffmpeg exit ${code}: ${stderr.trim().slice(0, 300)}`))
239
+ })
240
+ proc.on('error', (err) => {
241
+ settle(new ImageSafetyError('media_processing_unavailable', `ffmpeg unavailable: ${err.message}`))
242
+ })
243
+ })
244
+ }
245
+
246
+ function probeImageDimensions(path: string): Promise<{ width: number; height: number }> {
247
+ return new Promise((resolveProbe, rejectProbe) => {
248
+ let stdout = ''
249
+ let stderr = ''
250
+ let settled = false
251
+ const settle = (result?: { width: number; height: number }, err?: Error) => {
252
+ if (settled) return
253
+ settled = true
254
+ clearTimeout(timer)
255
+ if (err) rejectProbe(err)
256
+ else resolveProbe(result!)
257
+ }
258
+ const proc = spawn('ffprobe', [
259
+ '-v', 'error',
260
+ '-select_streams', 'v:0',
261
+ '-show_entries', 'stream=width,height',
262
+ '-of', 'json',
263
+ path,
264
+ ], { stdio: ['ignore', 'pipe', 'pipe'] })
265
+ const timer = setTimeout(() => {
266
+ proc.kill('SIGKILL')
267
+ settle(undefined, new ImageSafetyError('normalization_failed', 'ffprobe timed out'))
268
+ }, 5_000)
269
+ proc.stdout.on('data', (chunk: Buffer) => {
270
+ if (stdout.length < FFMPEG_STDERR_CAP) stdout += chunk.toString().slice(0, FFMPEG_STDERR_CAP - stdout.length)
271
+ })
272
+ proc.stderr.on('data', (chunk: Buffer) => {
273
+ if (stderr.length < FFMPEG_STDERR_CAP) stderr += chunk.toString().slice(0, FFMPEG_STDERR_CAP - stderr.length)
274
+ })
275
+ proc.on('close', (code) => {
276
+ if (code !== 0) {
277
+ settle(undefined, new ImageSafetyError('corrupt_image', `ffprobe rejected image: ${stderr.trim().slice(0, 200)}`))
278
+ return
279
+ }
280
+ try {
281
+ const parsed = JSON.parse(stdout) as { streams?: Array<{ width?: unknown; height?: unknown }> }
282
+ const width = parsed.streams?.[0]?.width
283
+ const height = parsed.streams?.[0]?.height
284
+ if (!Number.isInteger(width) || !Number.isInteger(height) || Number(width) <= 0 || Number(height) <= 0) {
285
+ throw new Error('missing dimensions')
286
+ }
287
+ settle({ width: Number(width), height: Number(height) })
288
+ } catch {
289
+ settle(undefined, new ImageSafetyError('corrupt_image', 'image probe returned no readable dimensions'))
290
+ }
291
+ })
292
+ proc.on('error', (err) => {
293
+ settle(undefined, new ImageSafetyError('media_processing_unavailable', `ffprobe unavailable: ${err.message}`))
294
+ })
295
+ })
296
+ }
297
+
298
+ export interface NormalizedImage {
299
+ /** Normalized primary asset — metadata-stripped JPEG, longest edge <= 1024. */
300
+ normalized: Buffer
301
+ /** Thumbnail — same profile, longest edge <= 256. */
302
+ thumb: Buffer
303
+ width: number
304
+ height: number
305
+ mime: 'image/jpeg'
306
+ }
307
+
308
+ // ── G2 lens variant (Release B) ──────────────────────────────────────────────
309
+ // The canary run of 2026-07-10 (G2 S211GABA296089, SDK 0.0.9) proved 288x144
310
+ // image containers on hardware. The lens variant is EXACTLY that size — the
311
+ // firmware TILES undersized data (canary injection evidence), so exact
312
+ // dimensions are a hard contract, guaranteed here by scale+pad.
313
+ export const G2_VARIANT_W = 288
314
+ export const G2_VARIANT_H = 144
315
+
316
+ /** Render the on-lens variant from an already-normalized asset: fit inside
317
+ * 288x144, pad to exactly 288x144 with black bars, grayscale, PNG (the
318
+ * hardware-proven payload format — the phone host converts to Gray4). */
319
+ export async function renderG2Variant(normalizedBytes: Buffer): Promise<Buffer> {
320
+ if (!(await isMediaProcessingReady())) {
321
+ throw new ImageSafetyError('media_processing_unavailable', 'G2 variant rendering requires ffmpeg')
322
+ }
323
+ const workDir = mkdtempSync(join(tmpdir(), 'cos-media-g2-'))
324
+ const inPath = join(workDir, 'in')
325
+ const outPath = join(workDir, 'g2.png')
326
+ try {
327
+ writeFileSync(inPath, normalizedBytes, { mode: 0o600 })
328
+ await runFfmpeg([
329
+ '-i', inPath,
330
+ '-frames:v', '1',
331
+ '-vf', [
332
+ `scale=${G2_VARIANT_W}:${G2_VARIANT_H}:force_original_aspect_ratio=decrease`,
333
+ `pad=${G2_VARIANT_W}:${G2_VARIANT_H}:(ow-iw)/2:(oh-ih)/2:black`,
334
+ 'format=gray',
335
+ ].join(','),
336
+ '-map_metadata', '-1',
337
+ '-c:v', 'png',
338
+ '-f', 'image2', '-y', outPath,
339
+ ])
340
+ const out = readFileSync(outPath)
341
+ const dims = parsePngDimensions(out)
342
+ if (!dims || dims.width !== G2_VARIANT_W || dims.height !== G2_VARIANT_H) {
343
+ throw new ImageSafetyError('normalization_failed', `G2 variant is ${dims?.width}x${dims?.height}, expected ${G2_VARIANT_W}x${G2_VARIANT_H}`)
344
+ }
345
+ return out
346
+ } finally {
347
+ try { rmSync(workDir, { recursive: true, force: true }) } catch { /* best effort */ }
348
+ }
349
+ }
350
+
351
+ /** Normalize a VALIDATED source image: strip all metadata/EXIF, clamp the
352
+ * longest edge, re-encode to a deterministic JPEG profile, and emit a
353
+ * thumbnail. Runs in a private tmp dir; one output frame per invocation. */
354
+ export async function normalizeImage(source: ValidatedImage): Promise<NormalizedImage> {
355
+ if (!(await isMediaProcessingReady())) {
356
+ throw new ImageSafetyError('media_processing_unavailable', 'image normalization requires ffmpeg')
357
+ }
358
+ const workDir = mkdtempSync(join(tmpdir(), 'cos-media-'))
359
+ const inPath = join(workDir, 'in')
360
+ const outPath = join(workDir, 'normalized.jpg')
361
+ const thumbPath = join(workDir, 'thumb.jpg')
362
+ try {
363
+ writeFileSync(inPath, source.bytes, { mode: 0o600 })
364
+ const scale = (edge: number) =>
365
+ `scale='min(${edge},iw)':'min(${edge},ih)':force_original_aspect_ratio=decrease`
366
+ await runFfmpeg([
367
+ '-i', inPath,
368
+ '-frames:v', '1',
369
+ '-vf', scale(NORMALIZED_MAX_EDGE),
370
+ '-map_metadata', '-1',
371
+ '-c:v', 'mjpeg', '-q:v', '3', '-pix_fmt', 'yuvj420p',
372
+ '-f', 'image2', '-y', outPath,
373
+ ])
374
+ await runFfmpeg([
375
+ '-i', outPath,
376
+ '-frames:v', '1',
377
+ '-vf', scale(THUMB_MAX_EDGE),
378
+ '-map_metadata', '-1',
379
+ '-c:v', 'mjpeg', '-q:v', '5', '-pix_fmt', 'yuvj420p',
380
+ '-f', 'image2', '-y', thumbPath,
381
+ ])
382
+ const normalized = readFileSync(outPath)
383
+ const thumb = readFileSync(thumbPath)
384
+ const dims = parseJpegDimensions(normalized)
385
+ if (!dims) {
386
+ throw new ImageSafetyError('normalization_failed', 'normalized output has no readable dimensions')
387
+ }
388
+ if (Math.max(dims.width, dims.height) > NORMALIZED_MAX_EDGE) {
389
+ throw new ImageSafetyError('normalization_failed', 'normalized output exceeded edge limit')
390
+ }
391
+ return { normalized, thumb, width: dims.width, height: dims.height, mime: 'image/jpeg' }
392
+ } finally {
393
+ try { rmSync(workDir, { recursive: true, force: true }) } catch { /* best effort */ }
394
+ }
395
+ }
396
+
397
+ /** Normalize a trusted, already-local model artifact. This is deliberately
398
+ * separate from the public phone-upload gate: up to 16 MiB JPEG/PNG/WebP/
399
+ * HEIC/AVIF is accepted by magic, probed for a <=16 MP first frame, stripped,
400
+ * clamped, and re-encoded into the same deterministic JPEG + thumbnail form. */
401
+ export async function normalizeOutputArtifact(bytes: Buffer): Promise<NormalizedImage> {
402
+ if (bytes.length <= 0 || bytes.length > MAX_OUTPUT_ARTIFACT_BYTES) {
403
+ throw new ImageSafetyError('image_too_large', `output artifact is ${bytes.length} bytes (max ${MAX_OUTPUT_ARTIFACT_BYTES})`)
404
+ }
405
+ if (!sniffOutputArtifactType(bytes)) {
406
+ throw new ImageSafetyError('unsupported_format', 'output artifact must be JPEG, PNG, WebP, HEIC/HEIF, or AVIF')
407
+ }
408
+ if (!(await isMediaProcessingReady())) {
409
+ throw new ImageSafetyError('media_processing_unavailable', 'image normalization requires ffmpeg')
410
+ }
411
+
412
+ const workDir = mkdtempSync(join(tmpdir(), 'cos-output-media-'))
413
+ const inPath = join(workDir, 'in')
414
+ const outPath = join(workDir, 'normalized.jpg')
415
+ const thumbPath = join(workDir, 'thumb.jpg')
416
+ try {
417
+ writeFileSync(inPath, bytes, { mode: 0o600 })
418
+ const dims = await probeImageDimensions(inPath)
419
+ if (dims.width * dims.height > MAX_MEGAPIXELS) {
420
+ throw new ImageSafetyError('dimensions_too_large', `${dims.width}x${dims.height} exceeds ${MAX_MEGAPIXELS / 1e6}MP limit`)
421
+ }
422
+ const scale = (edge: number) =>
423
+ `scale='min(${edge},iw)':'min(${edge},ih)':force_original_aspect_ratio=decrease`
424
+ await runFfmpeg([
425
+ '-i', inPath,
426
+ '-frames:v', '1',
427
+ '-vf', scale(NORMALIZED_MAX_EDGE),
428
+ '-map_metadata', '-1',
429
+ '-c:v', 'mjpeg', '-q:v', '3', '-pix_fmt', 'yuvj420p',
430
+ '-f', 'image2', '-y', outPath,
431
+ ])
432
+ await runFfmpeg([
433
+ '-i', outPath,
434
+ '-frames:v', '1',
435
+ '-vf', scale(THUMB_MAX_EDGE),
436
+ '-map_metadata', '-1',
437
+ '-c:v', 'mjpeg', '-q:v', '5', '-pix_fmt', 'yuvj420p',
438
+ '-f', 'image2', '-y', thumbPath,
439
+ ])
440
+ const normalized = readFileSync(outPath)
441
+ const thumb = readFileSync(thumbPath)
442
+ const normalizedDims = parseJpegDimensions(normalized)
443
+ if (!normalizedDims || Math.max(normalizedDims.width, normalizedDims.height) > NORMALIZED_MAX_EDGE) {
444
+ throw new ImageSafetyError('normalization_failed', 'normalized output dimensions are invalid')
445
+ }
446
+ // Re-enter the original byte/type/dimension contract before publication.
447
+ validateSourceImage(normalized)
448
+ return {
449
+ normalized,
450
+ thumb,
451
+ width: normalizedDims.width,
452
+ height: normalizedDims.height,
453
+ mime: 'image/jpeg',
454
+ }
455
+ } finally {
456
+ try { rmSync(workDir, { recursive: true, force: true }) } catch { /* best effort */ }
457
+ }
458
+ }
@@ -0,0 +1,29 @@
1
+ import type { Server } from 'node:net'
2
+
3
+ export interface RequiredListener { server: Server; port: number; host: string; label: string }
4
+
5
+ function listenOne({ server, port, host, label }: RequiredListener): Promise<void> {
6
+ return new Promise((resolve, reject) => {
7
+ const cleanup = () => { server.off('error', onError); server.off('listening', onListening) }
8
+ const onError = (error: NodeJS.ErrnoException) => { cleanup(); error.message = `${label} listener failed on ${host}:${port}: ${error.message}`; reject(error) }
9
+ const onListening = () => { cleanup(); resolve() }
10
+ server.once('error', onError)
11
+ server.once('listening', onListening)
12
+ server.listen(port, host)
13
+ })
14
+ }
15
+
16
+ async function closeOne(server: Server): Promise<void> {
17
+ if (!server.listening) return
18
+ await new Promise<void>(resolve => server.close(() => resolve()))
19
+ }
20
+
21
+ export async function listenRequiredServers(listeners: RequiredListener[]): Promise<void> {
22
+ const started: Server[] = []
23
+ try {
24
+ for (const listener of listeners) { await listenOne(listener); started.push(listener.server) }
25
+ } catch (error) {
26
+ await Promise.allSettled(started.map(closeOne))
27
+ throw error
28
+ }
29
+ }