@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,197 @@
1
+ /**
2
+ * Shared ffprobe plumbing for the media probes. `video.ts` and `audio.ts`
3
+ * both spawn the same binary with the same JSON output flags and only
4
+ * differ in how they read the payload, so the process handling and the
5
+ * payload shapes live here.
6
+ *
7
+ * The ffprobe binary path is resolved lazily. `@derhuerst/ffprobe-static`
8
+ * is an optional dependency of this package — not shipped in the host
9
+ * tarball. Dev resolves it from this package's `node_modules`; `pnpm
10
+ * start` and the desktop sidecar resolve it from the server
11
+ * `dist/node_modules` copy.
12
+ */
13
+ import { createRequire } from "node:module"
14
+ import type { Readable } from "node:stream"
15
+ import { execa } from "execa"
16
+
17
+ type ResolveFfprobeDeps = {
18
+ readonly env?: NodeJS.ProcessEnv
19
+ /** Override for tests so they never hit the real module. */
20
+ readonly loadStatic?: () => string | undefined
21
+ }
22
+
23
+ const requireCjs = createRequire(import.meta.url)
24
+
25
+ /**
26
+ * Resolve the absolute path to `ffprobe` for this host.
27
+ *
28
+ * Precedence:
29
+ * 1. The `FFPROBE_PATH` env var.
30
+ * 2. `@derhuerst/ffprobe-static` via lazy `createRequire` — the
31
+ * installer package stays out of the host tarball.
32
+ * 3. A bare command name, letting operators with ffprobe on PATH run
33
+ * the CLI or server directly.
34
+ */
35
+ export function resolveFfprobePath(deps: ResolveFfprobeDeps = {}): string {
36
+ const env = deps.env ?? process.env
37
+ const fromEnv = env.FFPROBE_PATH
38
+ if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv
39
+ const staticPath = (deps.loadStatic ?? loadInstallerFfprobe)()
40
+ return staticPath ?? "ffprobe"
41
+ }
42
+
43
+ function loadInstallerFfprobe(): string | undefined {
44
+ try {
45
+ const mod: unknown = requireCjs("@derhuerst/ffprobe-static")
46
+ if (typeof mod === "string" && mod.length > 0) return mod
47
+ if (
48
+ mod !== null &&
49
+ typeof mod === "object" &&
50
+ "path" in mod &&
51
+ typeof (mod as { path: unknown }).path === "string"
52
+ ) {
53
+ return (mod as { path: string }).path
54
+ }
55
+ return undefined
56
+ } catch {
57
+ return undefined
58
+ }
59
+ }
60
+
61
+ let ffprobePathCache: string | undefined
62
+
63
+ /** Memoized {@link resolveFfprobePath} for the probe entry points. */
64
+ export function getFfprobePath(): string {
65
+ if (ffprobePathCache === undefined) ffprobePathCache = resolveFfprobePath()
66
+ return ffprobePathCache
67
+ }
68
+
69
+ export type FfprobeStream = {
70
+ readonly codec_type?: unknown
71
+ readonly codec_name?: unknown
72
+ readonly width?: unknown
73
+ readonly height?: unknown
74
+ readonly sample_rate?: unknown
75
+ readonly channels?: unknown
76
+ readonly disposition?: unknown
77
+ readonly tags?: unknown
78
+ }
79
+
80
+ export type FfprobeFormat = {
81
+ readonly duration?: unknown
82
+ readonly bit_rate?: unknown
83
+ readonly tags?: unknown
84
+ }
85
+
86
+ export type FfprobePayload = {
87
+ readonly streams?: readonly FfprobeStream[]
88
+ readonly format?: FfprobeFormat
89
+ }
90
+
91
+ /**
92
+ * Parse ffprobe's JSON output into the raw payload shape. Anything that
93
+ * is not a JSON object yields an empty payload — partial and malformed
94
+ * payloads are normal for damaged media, and callers prefer surfacing
95
+ * whatever survives narrowing over rejecting the whole probe.
96
+ */
97
+ export function parseFfprobePayload(json: string): FfprobePayload {
98
+ const payload: unknown = JSON.parse(json)
99
+ if (typeof payload !== "object" || payload === null) return {}
100
+ return payload
101
+ }
102
+
103
+ /** Read `format.duration` (ffprobe emits it as a string) in milliseconds. */
104
+ export function ffprobeDurationMs(
105
+ format: FfprobeFormat | undefined,
106
+ ): number | undefined {
107
+ const raw = format?.duration
108
+ if (typeof raw !== "string") return undefined
109
+ const seconds = Number.parseFloat(raw)
110
+ if (!Number.isFinite(seconds) || seconds < 0) return undefined
111
+ return Math.round(seconds * 1000)
112
+ }
113
+
114
+ /** Read a positive integer out of an ffprobe field (string or number). */
115
+ export function ffprobeInt(value: unknown): number | undefined {
116
+ const parsed =
117
+ typeof value === "number"
118
+ ? value
119
+ : typeof value === "string"
120
+ ? Number.parseInt(value, 10)
121
+ : Number.NaN
122
+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined
123
+ return Math.round(parsed)
124
+ }
125
+
126
+ /**
127
+ * Run `ffprobe` against `source` and return its parsed JSON payload.
128
+ * Failure paths reject (callers wrap the call and treat a missing probe
129
+ * as non-fatal) so this function only returns with a payload in hand.
130
+ *
131
+ * Why JSON / `-print_format json`: the alternative `-show_entries` flat
132
+ * format requires brittle line splitting and breaks on locale-specific
133
+ * decimal separators in `duration`. JSON gives the values verbatim.
134
+ *
135
+ * @throws `Error` with stderr when ffprobe exits non-zero or emits no JSON.
136
+ */
137
+ export function runFfprobeJson(
138
+ source: string | Readable,
139
+ ffprobePath: string,
140
+ inputFormat?: string,
141
+ ): Promise<FfprobePayload> {
142
+ const fromStream = typeof source !== "string"
143
+ return new Promise((resolve, reject) => {
144
+ const args = [
145
+ "-hide_banner",
146
+ "-loglevel",
147
+ "error",
148
+ "-print_format",
149
+ "json",
150
+ "-show_streams",
151
+ "-show_format",
152
+ ]
153
+ if (fromStream) {
154
+ if (inputFormat === undefined) {
155
+ reject(new Error("stream ffprobe requires an input format hint"))
156
+ return
157
+ }
158
+ args.push("-probesize", "100M", "-analyzeduration", "100M")
159
+ args.push("-f", inputFormat)
160
+ }
161
+ args.push(fromStream ? "pipe:0" : source)
162
+ const child = execa(ffprobePath, args, {
163
+ stdin: fromStream ? "pipe" : "ignore",
164
+ reject: false,
165
+ stripFinalNewline: false,
166
+ })
167
+ if (fromStream) {
168
+ source.on("error", reject)
169
+ const stdin = child.stdin
170
+ if (stdin !== null) {
171
+ source.pipe(stdin)
172
+ stdin.on("error", () => {})
173
+ }
174
+ }
175
+ void child.then(
176
+ ({ stdout, exitCode, stderr }) => {
177
+ if (exitCode !== 0) {
178
+ const msg = stderr.trim()
179
+ reject(
180
+ new Error(`ffprobe exited ${exitCode}${msg ? `: ${msg}` : ""}`),
181
+ )
182
+ return
183
+ }
184
+ if (stdout.length === 0) {
185
+ reject(new Error("ffprobe produced no output"))
186
+ return
187
+ }
188
+ try {
189
+ resolve(parseFfprobePayload(stdout))
190
+ } catch (err) {
191
+ reject(err instanceof Error ? err : new Error(String(err)))
192
+ }
193
+ },
194
+ (err) => reject(err instanceof Error ? err : new Error(String(err))),
195
+ )
196
+ })
197
+ }
@@ -0,0 +1,251 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { Readable } from "node:stream"
5
+ import sharp from "sharp"
6
+ import { afterEach, beforeEach, describe, expect, test } from "vitest"
7
+ import {
8
+ needsFullAnimationScan,
9
+ PROBE_HEADER_BYTES,
10
+ probeAnimatedImage,
11
+ probeImageSource,
12
+ readImageMetadata,
13
+ THUMB_BUFFER_MAX_BYTES,
14
+ } from "./image.ts"
15
+
16
+ describe("probeAnimatedImage", () => {
17
+ test("returns false for video extensions without invoking sharp", async () => {
18
+ const dir = mkdtempSync(join(tmpdir(), "probe-anim-"))
19
+ try {
20
+ const path = join(dir, "clip.mp4")
21
+ writeFileSync(path, "not-a-real-mp4")
22
+ await expect(probeAnimatedImage(path)).resolves.toBe(false)
23
+ } finally {
24
+ rmSync(dir, { recursive: true, force: true })
25
+ }
26
+ })
27
+ })
28
+
29
+ describe("probeImageSource", () => {
30
+ test("static PNG is not animated after a single-page read", async () => {
31
+ const png = await sharp({
32
+ create: {
33
+ width: 32,
34
+ height: 32,
35
+ channels: 3,
36
+ background: { r: 10, g: 20, b: 30 },
37
+ },
38
+ })
39
+ .png()
40
+ .toBuffer()
41
+ const probe = await probeImageSource(png, ".png")
42
+ expect(probe).toEqual({
43
+ width: 32,
44
+ height: 32,
45
+ animated: false,
46
+ })
47
+ })
48
+
49
+ test("stream input probes normally", async () => {
50
+ const png = await sharp({
51
+ create: {
52
+ width: 16,
53
+ height: 8,
54
+ channels: 3,
55
+ background: { r: 1, g: 2, b: 3 },
56
+ },
57
+ })
58
+ .png()
59
+ .toBuffer()
60
+ const probe = await probeImageSource(Readable.from(png), ".png")
61
+ expect(probe).toEqual({ width: 16, height: 8, animated: false })
62
+ })
63
+
64
+ test("reopenable stream input probes normally", async () => {
65
+ const png = await sharp({
66
+ create: {
67
+ width: 16,
68
+ height: 8,
69
+ channels: 3,
70
+ background: { r: 1, g: 2, b: 3 },
71
+ },
72
+ })
73
+ .png()
74
+ .toBuffer()
75
+ const probe = await probeImageSource(
76
+ { openStream: async () => Readable.from(png) },
77
+ ".png",
78
+ )
79
+ expect(probe).toEqual({ width: 16, height: 8, animated: false })
80
+ })
81
+
82
+ test("valid stream input beyond the byte cap still probes (header-only read)", async () => {
83
+ const png = await sharp({
84
+ create: {
85
+ width: 16,
86
+ height: 8,
87
+ channels: 3,
88
+ background: { r: 1, g: 2, b: 3 },
89
+ },
90
+ })
91
+ .png()
92
+ .toBuffer()
93
+ // The PNG header sits at the start; the probe must read only what
94
+ // it needs instead of buffering the whole (oversized) entry.
95
+ const oversized = Buffer.concat([
96
+ png,
97
+ Buffer.alloc(THUMB_BUFFER_MAX_BYTES + 1),
98
+ ])
99
+ const probe = await probeImageSource(Readable.from(oversized), ".png")
100
+ expect(probe).toEqual({ width: 16, height: 8, animated: false })
101
+ })
102
+
103
+ test("garbage stream input degrades to undefined", async () => {
104
+ const probe = await probeImageSource(
105
+ Readable.from(Buffer.alloc(THUMB_BUFFER_MAX_BYTES + 1, 0xab)),
106
+ ".png",
107
+ )
108
+ expect(probe).toBeUndefined()
109
+ })
110
+ })
111
+
112
+ describe("needsFullAnimationScan", () => {
113
+ test("gif always escalates to full scan", () => {
114
+ expect(needsFullAnimationScan({ width: 1, height: 1 }, ".gif")).toBe(true)
115
+ })
116
+
117
+ test("static jpeg does not escalate", () => {
118
+ expect(
119
+ needsFullAnimationScan({ width: 4000, height: 3000, pages: 1 }, ".jpg"),
120
+ ).toBe(false)
121
+ })
122
+ })
123
+
124
+ describe("readImageMetadata", () => {
125
+ let root: string
126
+
127
+ beforeEach(() => {
128
+ root = mkdtempSync(join(tmpdir(), "probe-meta-"))
129
+ })
130
+
131
+ afterEach(() => {
132
+ rmSync(root, { recursive: true, force: true })
133
+ })
134
+
135
+ test("reads static jpeg dimensions from buffer", async () => {
136
+ const jpeg = await sharp({
137
+ create: {
138
+ width: 120,
139
+ height: 80,
140
+ channels: 3,
141
+ background: { r: 1, g: 2, b: 3 },
142
+ },
143
+ })
144
+ .jpeg()
145
+ .toBuffer()
146
+ const { meta, animated } = await readImageMetadata(jpeg, ".jpg")
147
+ expect(animated).toBe(false)
148
+ expect(meta.width).toBe(120)
149
+ expect(meta.height).toBe(80)
150
+ })
151
+ })
152
+
153
+ describe("readImageMetadata header-slice fast path", () => {
154
+ async function makePng(): Promise<Buffer> {
155
+ return sharp({
156
+ create: {
157
+ width: 16,
158
+ height: 8,
159
+ channels: 3,
160
+ background: { r: 1, g: 2, b: 3 },
161
+ },
162
+ })
163
+ .png()
164
+ .toBuffer()
165
+ }
166
+
167
+ test("static image probes from the header slice without opening a stream", async () => {
168
+ const png = await makePng()
169
+ let streams = 0
170
+ const { meta, animated } = await readImageMetadata(
171
+ {
172
+ openStream: async () => {
173
+ streams++
174
+ return Readable.from(png)
175
+ },
176
+ readRange: async (start, end) => png.subarray(start, end),
177
+ },
178
+ ".png",
179
+ )
180
+ expect(meta.width).toBe(16)
181
+ expect(meta.height).toBe(8)
182
+ expect(animated).toBe(false)
183
+ expect(streams).toBe(0)
184
+ })
185
+
186
+ test("an inconclusive slice falls back to the stream", async () => {
187
+ const png = await makePng()
188
+ const { meta, animated } = await readImageMetadata(
189
+ {
190
+ openStream: async () => Readable.from(png),
191
+ readRange: async () => Buffer.from([0xde, 0xad]),
192
+ },
193
+ ".png",
194
+ )
195
+ expect(meta.width).toBe(16)
196
+ expect(animated).toBe(false)
197
+ })
198
+
199
+ test("gif probes never use the header slice (frame count needs the full scan)", async () => {
200
+ const gif = await sharp({
201
+ create: {
202
+ width: 8,
203
+ height: 8,
204
+ channels: 3,
205
+ background: { r: 1, g: 2, b: 3 },
206
+ },
207
+ })
208
+ .gif()
209
+ .toBuffer()
210
+ let sliceCalls = 0
211
+ const { meta } = await readImageMetadata(
212
+ {
213
+ openStream: async () => Readable.from(gif),
214
+ readRange: async () => {
215
+ sliceCalls++
216
+ return gif.subarray(0, PROBE_HEADER_BYTES)
217
+ },
218
+ },
219
+ ".gif",
220
+ )
221
+ expect(meta.width).toBe(8)
222
+ expect(sliceCalls).toBe(0)
223
+ })
224
+
225
+ test("static webp probes from the header slice too", async () => {
226
+ const webp = await sharp({
227
+ create: {
228
+ width: 12,
229
+ height: 10,
230
+ channels: 3,
231
+ background: { r: 1, g: 2, b: 3 },
232
+ },
233
+ })
234
+ .webp()
235
+ .toBuffer()
236
+ let streams = 0
237
+ const { meta, animated } = await readImageMetadata(
238
+ {
239
+ openStream: async () => {
240
+ streams++
241
+ return Readable.from(webp)
242
+ },
243
+ readRange: async (start, end) => webp.subarray(start, end),
244
+ },
245
+ ".webp",
246
+ )
247
+ expect(meta.width).toBe(12)
248
+ expect(animated).toBe(false)
249
+ expect(streams).toBe(0)
250
+ })
251
+ })