@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
package/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wooloo <ayan0312000@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6
+ associated documentation files (the "Software"), to deal in the Software without restriction, including
7
+ without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial
12
+ portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
15
+ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
16
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
18
+ USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # @hoardodile/host
2
+
3
+ The plugin runtime host: capability sandbox (one restricted child process
4
+ per plugin, spawned via the package's own `worker-entry` subpath), hook
5
+ strategy, resource containers, probe cache, capability guard, and settings
6
+ store. Consumed by the app server and by the developer CLI
7
+ (`@hoardodile/cli`) — `hooks.ts` is the only way to invoke plugin hooks.
8
+
9
+ **Sandbox boundary.** Each plugin's `main.js` runs in a forked child
10
+ process under Node's permission model (fs read limited to the plugin's
11
+ own directory, no fs write, no child processes, no worker threads, no
12
+ native addons) plus a module policy hook (registered in the entry via
13
+ `module.registerHooks`, synchronous and main-thread — no additional grants
14
+ needed) that denies every module outside the plugin directory and every
15
+ `node:` builtin except `node:url`, a scrubbed global surface
16
+ (`fetch`/`WebSocket`/`EventSource` throw, `process.env` is empty), and a
17
+ startup self-check that refuses to run when the permission model or the
18
+ module policy is not active. The only privileged interface left is the
19
+ `ResourceAPI` RPC: paths are resource-relative and every call executes
20
+ host-side. The sandbox is a capability boundary against plugin code (fault
21
+ isolation: watchdog, hard timeout, memory cap, respawn budget), not an
22
+ OS-level sandbox — plugins still share the server's OS user.
23
+
24
+ > **Not a plugin SDK package.** This is the app-side runtime: the
25
+ > authoring surface (`definePlugin`, `ResourceAPI`, fixtures) lives in
26
+ > `@hoardodile/sdk-types` and is re-exported by `@hoardodile/sdk-server`;
27
+ > everything else here (loader, discovery, sandbox, containers) exists
28
+ > for the app server and the CLI. Plugin code imports `@hoardodile/sdk-server`
29
+ > — except `runPluginHook`, which plugins consume from this package as a
30
+ > devDependency for Layer-2 tests (never shipped in a plugin bundle).
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pnpm add @hoardodile/host
36
+ ```
37
+
38
+ The content sniffer and the image/video/audio probes (`sniffBytes`,
39
+ `probeImageSource`, `probeAvMedia`, ...) load `sharp` and the
40
+ `ffmpeg-static` / `@derhuerst/ffprobe-static` binaries lazily at runtime;
41
+ `sharp` is an optional peer, the binaries are optional dependencies and
42
+ install automatically (skip with `--no-optional`; the loaders fall back
43
+ to PATH).
44
+
45
+ ## Subpaths
46
+
47
+ | Entry | Contents |
48
+ | ----- | -------- |
49
+ | `@hoardodile/host` | Full runtime surface: loader, activation, discovery, sandbox, hook strategy, ResourceAPI builders, containers, probes, `runPluginHook` |
50
+ | `@hoardodile/host/probe` | Reusable probe implementations (image/video/audio metadata, animation detection) |
51
+ | `@hoardodile/host/contract` | The plugin contract vitest suite — validates a `PluginDefinition` against the real host. `vitest` is an optional peer |
52
+ | `@hoardodile/host/worker-entry` | Sandbox child-process entry (`worker-entry.mjs`, module policy inline); resolves from any bundle |
53
+
54
+ ## CLI
55
+
56
+ The developer CLI lives in `@hoardodile/cli`:
57
+
58
+ ```bash
59
+ hoardodile plugin run <hook> <data-dir> --plugin-dir dist
60
+ hoardodile plugin bench <hook> <data-dir> --plugin-dir dist --compare baseline.json
61
+ hoardodile plugin dev # watch-build + workbench at http://127.0.0.1:5199
62
+ ```
63
+
64
+ `run`/`bench` execute hooks through this package's sandbox — what
65
+ you test is what runs in production. `bench` writes JSON reports (with
66
+ machine fingerprint, peak RSS and warmup count); `--compare` exits 1 on
67
+ regression and warns on cross-machine baselines.
68
+
69
+ ## Licensing
70
+
71
+ MIT. This package is the terminal runtime (consumed by the GPL-3.0 app
72
+ server and by plugin dev tooling); it is not part of the SDK closure and
73
+ plugin code never links it.
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Plugin sandbox entry — the main script of the per-plugin child process.
3
+ * Plain ESM JS on purpose: sandbox files do NOT get vite-node/vitest
4
+ * transforms, so they must stay dependency-free (no workspace TS imports).
5
+ * Keep HOOK_NAMES / API_METHOD_NAMES in sync with protocol.ts.
6
+ *
7
+ * The child runs under the Node permission model (no fs write, no child
8
+ * processes, no native addons; fs reads limited to the plugin directory
9
+ * and the sandbox entry itself) and this entry adds three layers before
10
+ * the plugin bundle is ever imported:
11
+ *
12
+ * 1. Startup self-check — proves the permission model is actually active
13
+ * (the host probes the flag name, but a restricted process must also
14
+ * verify the restriction). Fail-closed: exit(1) when the model is off.
15
+ * 2. Module policy gate — `registerHooks` (synchronous, main-thread, no
16
+ * worker grant needed) installs a resolve hook so every later import
17
+ * (`node:fs`, `node:http`, bare packages, ...) is denied; only
18
+ * `node:url`, files under the plugin dir and the entry itself may
19
+ * load. A follow-up self-check proves the gate is actually armed.
20
+ * 3. Global scrub — `fetch`/`WebSocket`/`EventSource` throw and
21
+ * `process.env` is emptied, so ambient network/env capability never
22
+ * exists even without an import.
23
+ *
24
+ * The plugin's own data access goes through the RPC below (`process.send`
25
+ * of `api` requests); the host executes every call in its own process.
26
+ */
27
+ import { writeFileSync } from "node:fs"
28
+ import { registerHooks } from "node:module"
29
+ import { tmpdir } from "node:os"
30
+ import { join } from "node:path"
31
+ import { pathToFileURL } from "node:url"
32
+
33
+ if (process.send === undefined) {
34
+ throw new Error(
35
+ "worker-entry must run as a forked child process with an IPC channel",
36
+ )
37
+ }
38
+
39
+ const HOOK_NAMES = [
40
+ "detect",
41
+ "sourceMeta",
42
+ "searchMeta",
43
+ "coverLocal",
44
+ "listFiles",
45
+ "imageHashes",
46
+ ]
47
+
48
+ const API_METHOD_NAMES = [
49
+ "logInfo",
50
+ "logWarn",
51
+ "logError",
52
+ "listFileNames",
53
+ "readFile",
54
+ "statFile",
55
+ "statFiles",
56
+ "sniff",
57
+ "probe",
58
+ "hashBytes",
59
+ "computeImageHashes",
60
+ "listContainer",
61
+ "extractArchive",
62
+ "download",
63
+ "statAsset",
64
+ "readAsset",
65
+ "deleteAsset",
66
+ ]
67
+
68
+ const LOG_METHOD_NAMES = new Set(["logInfo", "logWarn", "logError"])
69
+
70
+ const pluginDir = process.argv[2]
71
+ if (typeof pluginDir !== "string" || pluginDir.length === 0) {
72
+ process.stderr.write("[plugin-sandbox] missing plugin directory argument\n")
73
+ process.exit(1)
74
+ }
75
+
76
+ // Optional third argument: the host-managed plugin vault directory
77
+ // (`<plugin-dir>/vault/` for disk plugins; for dev plugins it lives under
78
+ // the versioned storage instead of the dev directory). Passed so the
79
+ // module policy gate allows loading downloaded runtimes from the vault
80
+ // even when it sits outside the plugin directory.
81
+ const assetVaultDir = process.argv[3]
82
+
83
+ /** Absolute cap for one hook result crossing the IPC boundary. */
84
+ const MAX_RESULT_BYTES =
85
+ Number(process.env.HOARDODILE_PLUGIN_MAX_RESULT_BYTES) || 256 * 1024 * 1024
86
+
87
+ /** Cap on `log*` messages per hook invocation (log spam resets the watchdog). */
88
+ const MAX_LOGS_PER_HOOK =
89
+ Number(process.env.HOARDODILE_PLUGIN_MAX_LOGS_PER_HOOK) || 1000
90
+
91
+ /** Cap on ResourceAPI calls per hook invocation. */
92
+ const MAX_API_CALLS_PER_HOOK =
93
+ Number(process.env.HOARDODILE_PLUGIN_MAX_API_CALLS_PER_HOOK) || 100000
94
+
95
+ // Per-invocation budgets: reset at the start of every hook so a burst in
96
+ // one hook never counts against the next (they fail the hook, like the
97
+ // result cap — a runaway log/RPC loop must not pin the host CPU).
98
+ let logCount = 0
99
+ let apiCallCount = 0
100
+
101
+ // -- layer 1: startup self-check (fail-closed) --
102
+
103
+ function permissionModelActive() {
104
+ const probePath = join(
105
+ tmpdir(),
106
+ `hoardodile-sandbox-probe-${process.pid}-${Date.now()}`,
107
+ )
108
+ const api = process.permission
109
+ if (api !== undefined && typeof api.has === "function") {
110
+ try {
111
+ // No fs-write grant exists at all, so a write must be denied.
112
+ return api.has("fs.write", probePath) === false
113
+ } catch {
114
+ // Unsupported probe shape — fall through to the write probe.
115
+ }
116
+ }
117
+ try {
118
+ writeFileSync(probePath, "probe")
119
+ return false
120
+ } catch {
121
+ return true
122
+ }
123
+ }
124
+
125
+ if (!permissionModelActive()) {
126
+ process.stderr.write(
127
+ "[plugin-sandbox] startup self-check failed: the Node permission model is not active in this process — refusing to run untrusted plugin code. Check the Node version and that the sandbox flags reached the child.\n",
128
+ )
129
+ process.exit(1)
130
+ }
131
+
132
+ // -- layer 2: module policy gate (before any plugin code can import) --
133
+
134
+ if (typeof registerHooks !== "function") {
135
+ process.stderr.write(
136
+ "[plugin-sandbox] startup self-check failed: this Node build has no module.registerHooks — refusing to run plugin code unsandboxed.\n",
137
+ )
138
+ process.exit(1)
139
+ }
140
+
141
+ /** Case-insensitive comparison on Windows (drive letters, case folds). */
142
+ function normalizePath(value) {
143
+ return process.platform === "win32" ? value.toLowerCase() : value
144
+ }
145
+
146
+ /** On-disk path → its canonical `file://` URL, in Node's own encoding. */
147
+ function toFileUrl(path) {
148
+ // pathToFileURL is what the ESM loader uses for these paths, so the
149
+ // gate prefixes MUST use its exact encoding — encodeURI leaves `~`
150
+ // unescaped, which mismatched the loader's `%7E` (Temp dirs on
151
+ // Windows runners are the short name, e.g. RUNNER~1) and made every
152
+ // plugin under such a path look like it was outside the plugin dir.
153
+ return pathToFileURL(path).href
154
+ }
155
+
156
+ const pluginDirPrefix = normalizePath(toFileUrl(pluginDir) + "/")
157
+ const assetVaultPrefix =
158
+ typeof assetVaultDir === "string" && assetVaultDir.length > 0
159
+ ? normalizePath(toFileUrl(assetVaultDir) + "/")
160
+ : undefined
161
+ const entryUrl = normalizePath(import.meta.url)
162
+
163
+ /**
164
+ * The only modules a sandbox may load: `node:url` (bootstrap), files under
165
+ * the plugin directory (the bundle is a single self-contained ESM file),
166
+ * the host-managed plugin vault (downloaded runtimes — see the asset API
167
+ * contract; read-only by the permission model, and the vault contents are
168
+ * data the plugin itself requested under user consent), and the entry
169
+ * itself. Everything else — every other `node:` builtin, bare package
170
+ * names, `data:`/`blob:` URLs, absolute paths outside the plugin dir —
171
+ * is denied. The Node permission model stays the second, OS-level layer
172
+ * underneath.
173
+ */
174
+ function isAllowedModule(url) {
175
+ if (url === "node:url") return true
176
+ if (!url.startsWith("file:")) return false
177
+ const normalized = normalizePath(url)
178
+ if (normalized === entryUrl) return true
179
+ if (normalized.startsWith(pluginDirPrefix)) return true
180
+ if (assetVaultPrefix !== undefined) {
181
+ return normalized.startsWith(assetVaultPrefix)
182
+ }
183
+ return false
184
+ }
185
+
186
+ registerHooks({
187
+ resolve(specifier, _context, nextResolve) {
188
+ // Resolve first, then validate the FINAL destination — a relative
189
+ // specifier like `../outside.js` only reveals its target after the
190
+ // parent URL is folded in. Hooks must stay synchronous
191
+ // (registerHooks does not support async hooks).
192
+ const result = nextResolve(specifier, _context)
193
+ if (isAllowedModule(result.url)) return result
194
+ // Show the allowed prefixes so a canonicalization mismatch (URL
195
+ // encoding, symlinked roots, casing) is diagnosable in one line.
196
+ const allowed =
197
+ assetVaultPrefix !== undefined
198
+ ? `${pluginDirPrefix}, ${assetVaultPrefix}`
199
+ : pluginDirPrefix
200
+ throw new Error(
201
+ `[plugin-sandbox] module denied by policy: ${specifier} → ${result.url} (allowed: ${allowed})`,
202
+ )
203
+ },
204
+ })
205
+
206
+ // -- layer 2a: prove the policy gate is armed --
207
+ // A builtin the entry never imported (node:http): its import is not a
208
+ // permission-model operation, so a success means the plugin would run
209
+ // with full module access — only the gate above can deny it. (`node:fs`
210
+ // is already loaded by a top-level import, which would bypass the hook.)
211
+ try {
212
+ await import("node:http")
213
+ process.stderr.write(
214
+ "[plugin-sandbox] startup self-check failed: module policy gate is not active in this process\n",
215
+ )
216
+ process.exit(1)
217
+ } catch {
218
+ // Denied as expected — the gate is live.
219
+ }
220
+
221
+ // -- layer 3: global scrub --
222
+
223
+ function disabled(name) {
224
+ return () => {
225
+ throw new Error(
226
+ `[plugin-sandbox] ${name} is disabled inside the plugin sandbox — use the ResourceAPI instead`,
227
+ )
228
+ }
229
+ }
230
+
231
+ for (const name of ["fetch", "WebSocket", "EventSource"]) {
232
+ try {
233
+ Object.defineProperty(globalThis, name, {
234
+ value: disabled(name),
235
+ writable: true,
236
+ configurable: true,
237
+ })
238
+ } catch {
239
+ // A non-configurable global stays; the module path to it is still denied.
240
+ }
241
+ }
242
+
243
+ process.env = {}
244
+
245
+ /** @type {Record<string, unknown> | undefined} */
246
+ let plugin
247
+
248
+ /**
249
+ * Payload of the last successful `detect` invocation (the result
250
+ * without the `ok` marker), exposed to later hooks as
251
+ * `api.context.detect`. Reset on load and on a failed detection.
252
+ * @type {Record<string, unknown> | undefined}
253
+ */
254
+ let detectPayload
255
+
256
+ let nextApiCallId = 1
257
+ /** @type {Map<number, { resolve: (value: unknown) => void, reject: (err: Error) => void }>} */
258
+ const pendingApi = new Map()
259
+
260
+ function serializeError(err) {
261
+ if (err instanceof Error) {
262
+ return { name: err.name, message: err.message, stack: err.stack }
263
+ }
264
+ return { name: "Error", message: String(err) }
265
+ }
266
+
267
+ function deserializeError(err) {
268
+ const e = new Error(err.message)
269
+ e.name = err.name
270
+ if (err.stack !== undefined) e.stack = err.stack
271
+ return e
272
+ }
273
+
274
+ function send(message) {
275
+ try {
276
+ process.send(message)
277
+ } catch {
278
+ // The channel closed (host gone or killed us) — nothing to deliver.
279
+ }
280
+ }
281
+
282
+ /** Approximate serialized size of a value crossing to the host. */
283
+ function approxByteSize(value) {
284
+ if (value instanceof Uint8Array) return value.byteLength
285
+ try {
286
+ return JSON.stringify(value).length * 2
287
+ } catch {
288
+ return Infinity
289
+ }
290
+ }
291
+
292
+ function buildResourceApiProxy(callId) {
293
+ const api = { context: { detect: detectPayload } }
294
+ for (const name of API_METHOD_NAMES) {
295
+ if (LOG_METHOD_NAMES.has(name)) {
296
+ // Fire-and-forget: the contract types these as sync void, so the
297
+ // proxy must not hand the plugin a promise to await. A thrown
298
+ // budget error propagates into the plugin's hook — the hook
299
+ // fails loudly instead of spamming forever.
300
+ api[name] = (message, data) => {
301
+ logCount += 1
302
+ if (logCount > MAX_LOGS_PER_HOOK) {
303
+ throw new Error(
304
+ `[plugin-sandbox] log budget exceeded (${MAX_LOGS_PER_HOOK} per hook)`,
305
+ )
306
+ }
307
+ send({
308
+ type: "log",
309
+ callId,
310
+ method: name,
311
+ args: [message, data],
312
+ })
313
+ }
314
+ continue
315
+ }
316
+ api[name] = (...args) => {
317
+ apiCallCount += 1
318
+ if (apiCallCount > MAX_API_CALLS_PER_HOOK) {
319
+ return Promise.reject(
320
+ new Error(
321
+ `[plugin-sandbox] API call budget exceeded (${MAX_API_CALLS_PER_HOOK} per hook) — batch with statFiles or reduce per-file fan-out`,
322
+ ),
323
+ )
324
+ }
325
+ const apiCallId = nextApiCallId++
326
+ return new Promise((resolve, reject) => {
327
+ pendingApi.set(apiCallId, { resolve, reject })
328
+ send({ type: "api", callId, apiCallId, method: name, args })
329
+ })
330
+ }
331
+ }
332
+ return api
333
+ }
334
+
335
+ async function handleLoad(mainPath) {
336
+ try {
337
+ const mod = await import(pathToFileURL(mainPath).href)
338
+ const def =
339
+ mod !== null && typeof mod === "object" ? mod.default : undefined
340
+ if (
341
+ def === null ||
342
+ typeof def !== "object" ||
343
+ typeof def.detect !== "function"
344
+ ) {
345
+ throw new Error(
346
+ "plugin main.js must default-export a definition with detect()",
347
+ )
348
+ }
349
+ const hooks = HOOK_NAMES.filter((h) => typeof def[h] === "function")
350
+ plugin = def
351
+ detectPayload = undefined
352
+ send({ type: "loaded", ok: true, hooks })
353
+ } catch (err) {
354
+ send({ type: "loaded", ok: false, error: serializeError(err) })
355
+ }
356
+ }
357
+
358
+ async function handleInvoke(callId, hook) {
359
+ try {
360
+ if (plugin === undefined) throw new Error("plugin not loaded")
361
+ const fn = plugin[hook]
362
+ if (typeof fn !== "function") throw new Error(`plugin has no hook ${hook}`)
363
+ logCount = 0
364
+ apiCallCount = 0
365
+ const value = await fn(buildResourceApiProxy(callId))
366
+ // Keep the payload of a successful detect for the next hooks —
367
+ // the one-pass classification every other hook can build on. A
368
+ // failed or payload-less detection leaves the context absent.
369
+ if (hook === "detect" && isRecord(value) && value.ok === true) {
370
+ const { ok: _ok, ...payload } = value
371
+ detectPayload = Object.keys(payload).length > 0 ? payload : undefined
372
+ } else if (hook === "detect") {
373
+ detectPayload = undefined
374
+ }
375
+ if (approxByteSize(value) > MAX_RESULT_BYTES) {
376
+ throw new Error(
377
+ `[plugin-sandbox] hook result exceeds ${MAX_RESULT_BYTES} bytes — return a smaller payload or read large files by byte range`,
378
+ )
379
+ }
380
+ send({ type: "result", callId, ok: true, value })
381
+ } catch (err) {
382
+ send({
383
+ type: "result",
384
+ callId,
385
+ ok: false,
386
+ error: serializeError(err),
387
+ })
388
+ }
389
+ }
390
+
391
+ function isRecord(value) {
392
+ return value !== null && typeof value === "object" && !Array.isArray(value)
393
+ }
394
+
395
+ process.on("message", (msg) => {
396
+ if (msg === null || typeof msg !== "object") return
397
+ switch (msg.type) {
398
+ case "load":
399
+ void handleLoad(msg.mainPath)
400
+ return
401
+ case "invoke":
402
+ void handleInvoke(msg.callId, msg.hook)
403
+ return
404
+ case "apiResult": {
405
+ const pending = pendingApi.get(msg.apiCallId)
406
+ if (pending === undefined) return
407
+ pendingApi.delete(msg.apiCallId)
408
+ if (msg.ok) {
409
+ pending.resolve(msg.value)
410
+ } else {
411
+ pending.reject(deserializeError(msg.error))
412
+ }
413
+ return
414
+ }
415
+ }
416
+ })
417
+
418
+ // The host is the process lifetime: when it goes away (crash, shutdown,
419
+ // tree-kill), the IPC channel closes and this child must not linger.
420
+ process.on("disconnect", () => {
421
+ process.exit(0)
422
+ })
@@ -0,0 +1,58 @@
1
+ import { Readable } from 'node:stream';
2
+
3
+ /**
4
+ * Read-only view over a resource's source data, regardless of storage
5
+ * shape — a zip archive, a raw directory, or an in-memory fixture. The
6
+ * {@link ResourceAPI} builder consumes this interface, so every backend
7
+ * (fixture / directory / zip / the server's artifact view) behaves
8
+ * identically behind a plugin hook.
9
+ *
10
+ * `relPath` values are entry names as they appear in the container (may
11
+ * contain `/`). Containers are immutable for the lifetime of a build:
12
+ * the server's per-version archives never change, and the directory
13
+ * container documents reads as snapshot-only.
14
+ */
15
+ type ResourceContainer = {
16
+ /** List every entry name in the container. Always flat. */
17
+ readonly listEntries: () => Promise<readonly string[]>;
18
+ /** Read `relPath` in full. Throws when the entry does not exist. */
19
+ readonly readEntry: (relPath: string) => Promise<Buffer>;
20
+ /**
21
+ * Read the byte range `[start, end)` of `relPath` (`end` exclusive).
22
+ * The range is clamped to the entry size; an out-of-range start
23
+ * resolves to an empty buffer.
24
+ */
25
+ readonly readEntrySlice: (relPath: string, start: number, end: number) => Promise<Buffer>;
26
+ /**
27
+ * Stream entry bytes without buffering the whole entry. `path` is
28
+ * the absolute on-disk file backing a literal entry, when there is
29
+ * one — consumers serve ranges through `createReadStream(path,
30
+ * {start, end})` windows instead of draining + discarding the
31
+ * stream's prefix.
32
+ */
33
+ readonly openEntryStream: (relPath: string) => Promise<{
34
+ readonly stream: Readable;
35
+ readonly size: number;
36
+ /** Modification time of the underlying file, when known. */
37
+ readonly mtimeMs?: number;
38
+ /** Absolute path of the backing file, when literal and seekable. */
39
+ readonly path?: string;
40
+ }>;
41
+ /**
42
+ * Resolve `relPath` to its byte length. Returns `undefined` when the
43
+ * entry does not exist (the caller decides how to report that).
44
+ */
45
+ readonly resolveByteRange: (relPath: string) => Promise<{
46
+ readonly size: number;
47
+ } | undefined>;
48
+ /**
49
+ * Optional capability: resolve `relPath` to an absolute on-disk path
50
+ * when its bytes can be read as a seekable file (no extraction, no
51
+ * decompression). Consumers like the thumbnail pipeline hand such
52
+ * paths straight to ffmpeg/libvips instead of piping streams.
53
+ * Returns `undefined` when the container cannot provide one.
54
+ */
55
+ readonly resolveSeekablePath?: (relPath: string) => Promise<string | undefined>;
56
+ };
57
+
58
+ export type { ResourceContainer as R };
@@ -0,0 +1,49 @@
1
+ import { R as ResourceContainer } from '../container-fKxefO7p.js';
2
+ import { ResourceAPI } from '@hoardodile/sdk-types';
3
+ import 'node:stream';
4
+
5
+ /** Declarative in-memory content for a {@link ResourceContainer}. */
6
+ type ContainerFixtureConfig = {
7
+ /** Entry name → content. Names may contain `/` for nesting. */
8
+ readonly files?: Readonly<Record<string, string | Uint8Array>>;
9
+ };
10
+ /**
11
+ * An in-memory {@link ResourceContainer} driven by a declarative config.
12
+ * The test double in the container contract suite: every assertion runs
13
+ * against it, the directory container and the zip container, and the
14
+ * three must agree.
15
+ */
16
+ declare function createContainerFixture(config?: ContainerFixtureConfig): ResourceContainer;
17
+
18
+ /**
19
+ * Shared contract suite for the container-backed {@link ResourceAPI}
20
+ * implementations. Every backend (in-memory fixture, directory, zip
21
+ * archive, and the server's own artifact view) must behave identically:
22
+ * same entries, same bytes, same clamping, same probe semantics.
23
+ *
24
+ * Consumers build each backend from a declarative file map, then run the
25
+ * whole suite with `containerContractSuite([...])` inside a test file.
26
+ */
27
+ /** A declarative container case: entry name → content. */
28
+ type ContainerContractCase = {
29
+ readonly name: string;
30
+ readonly files: Readonly<Record<string, string | Uint8Array>>;
31
+ };
32
+ declare const CONTAINER_CONTRACT_CASES: readonly ContainerContractCase[];
33
+ type ContractBackend = {
34
+ readonly name: string;
35
+ /**
36
+ * Build a {@link ResourceAPI} from a declarative file map. Called
37
+ * once per case; must produce an independent instance each time.
38
+ */
39
+ readonly build: (files: Readonly<Record<string, string | Uint8Array>>) => Promise<ResourceAPI>;
40
+ /** Optional teardown for resources created by `build` calls. */
41
+ readonly cleanup?: () => Promise<void> | void;
42
+ };
43
+ /**
44
+ * Run the full container contract suite against the given backends.
45
+ * Must be called from within a vitest test file.
46
+ */
47
+ declare function containerContractSuite(backends: readonly ContractBackend[]): void;
48
+
49
+ export { CONTAINER_CONTRACT_CASES, type ContainerContractCase, type ContainerFixtureConfig, type ContractBackend, containerContractSuite, createContainerFixture };