@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,330 @@
1
+ import { tmpdir } from "node:os"
2
+ import { join, resolve, sep } from "node:path"
3
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
4
+ import type { ResourceAPI } from "../types.ts"
5
+ import {
6
+ createPluginSandbox,
7
+ DEFAULT_SANDBOX_CONFIG,
8
+ type PluginSandbox,
9
+ type PluginSandboxConfig,
10
+ } from "./host.ts"
11
+
12
+ /**
13
+ * Deterministic lifecycle tests: a scripted fake ChildProcess lets us hit
14
+ * race windows (stale messages, concurrent loads) that real forked
15
+ * processes only reproduce flakily. Only this file sees the mock — the
16
+ * real `spawn` probe answers the first permission-flag candidate so the
17
+ * sandbox never actually starts a process here.
18
+ */
19
+ const mocks = vi.hoisted(() => {
20
+ class FakeChild {
21
+ static instances: FakeChild[] = []
22
+ private readonly listeners = new Map<
23
+ string,
24
+ ((...args: unknown[]) => void)[]
25
+ >()
26
+ readonly sent: unknown[] = []
27
+ readonly args: unknown[]
28
+ killed = false
29
+
30
+ constructor(...args: unknown[]) {
31
+ this.args = args
32
+ FakeChild.instances.push(this)
33
+ }
34
+
35
+ on(event: string, fn: (...args: unknown[]) => void): this {
36
+ const list = this.listeners.get(event) ?? []
37
+ list.push(fn)
38
+ this.listeners.set(event, list)
39
+ return this
40
+ }
41
+
42
+ emit(event: string, ...args: unknown[]): void {
43
+ for (const fn of this.listeners.get(event) ?? []) fn(...args)
44
+ }
45
+
46
+ send(msg: unknown): void {
47
+ this.sent.push(msg)
48
+ }
49
+
50
+ kill(): void {
51
+ this.killed = true
52
+ }
53
+
54
+ ref(): this {
55
+ return this
56
+ }
57
+
58
+ unref(): this {
59
+ return this
60
+ }
61
+ }
62
+ return { FakeChild }
63
+ })
64
+
65
+ vi.mock("node:child_process", () => ({
66
+ fork: (...args: unknown[]) => new mocks.FakeChild(...args),
67
+ spawn: (...args: unknown[]) => {
68
+ const child = new mocks.FakeChild(...args)
69
+ // The permission-flag probe resolves as soon as the listeners are
70
+ // attached — report the first candidate as accepted.
71
+ queueMicrotask(() => child.emit("exit", 0))
72
+ return child
73
+ },
74
+ }))
75
+
76
+ function unitConfig(overrides: Partial<PluginSandboxConfig> = {}) {
77
+ return {
78
+ ...DEFAULT_SANDBOX_CONFIG,
79
+ ...overrides,
80
+ } satisfies PluginSandboxConfig
81
+ }
82
+
83
+ function lastChild(): InstanceType<typeof mocks.FakeChild> {
84
+ const child = mocks.FakeChild.instances.at(-1)
85
+ if (child === undefined) throw new Error("no sandbox child spawned")
86
+ return child
87
+ }
88
+
89
+ function createStubApi(overrides: Partial<ResourceAPI> = {}): ResourceAPI {
90
+ return {
91
+ logInfo() {},
92
+ logWarn() {},
93
+ logError() {},
94
+ context: { detect: undefined },
95
+ listFileNames: async () => [],
96
+ readFile: async () => new Uint8Array(),
97
+ statFile: async () => ({ sizeBytes: 0 }),
98
+ statFiles: async (paths) => paths.map(() => ({ sizeBytes: 0 })),
99
+ sniff: async () => undefined,
100
+ probe: async () => ({ kind: "unknown", reason: "unavailable" }),
101
+ hashBytes: async () => "ab",
102
+ computeImageHashes: async () => undefined,
103
+ listContainer: async () => ({ entries: [] }),
104
+ extractArchive: async () => ({ entries: [] }),
105
+ download: async () => {
106
+ throw new Error("stub: download not configured")
107
+ },
108
+ statAsset: async () => undefined,
109
+ readAsset: async () => new Uint8Array(),
110
+ deleteAsset: async () => ({ existed: false }),
111
+ ...overrides,
112
+ }
113
+ }
114
+
115
+ /** Flush microtasks and pending macrotasks. */
116
+ function flush(): Promise<void> {
117
+ return new Promise((resolve) => setTimeout(resolve, 0))
118
+ }
119
+
120
+ describe("plugin sandbox lifecycle (fake child process)", () => {
121
+ let sandbox: PluginSandbox | undefined
122
+
123
+ beforeEach(() => {
124
+ mocks.FakeChild.instances.length = 0
125
+ })
126
+
127
+ afterEach(async () => {
128
+ await sandbox?.disposeAll()
129
+ sandbox = undefined
130
+ vi.restoreAllMocks()
131
+ })
132
+
133
+ test("a plugin that fails to load has its sandbox child terminated", async () => {
134
+ vi.spyOn(console, "error").mockImplementation(() => {})
135
+ sandbox = createPluginSandbox(unitConfig())
136
+ const load = sandbox.loadPlugin({
137
+ id: "bad",
138
+ mainPath: "/plugins/bad/main.js",
139
+ eager: true,
140
+ })
141
+ // The fork happens after the (mocked) permission-flag probe settles.
142
+ await flush()
143
+ const child = lastChild()
144
+ child.emit("message", {
145
+ type: "loaded",
146
+ ok: false,
147
+ error: { name: "Error", message: "import exploded" },
148
+ })
149
+ await expect(load).resolves.toBeUndefined()
150
+ expect(child.killed).toBe(true)
151
+ })
152
+
153
+ test("messages from a stale child are ignored", async () => {
154
+ vi.spyOn(console, "error").mockImplementation(() => {})
155
+ sandbox = createPluginSandbox(unitConfig())
156
+ // Unload mid-load: the first child's waiter rejects, load returns
157
+ // undefined, and the child is terminated.
158
+ const first = sandbox.loadPlugin({
159
+ id: "p",
160
+ mainPath: "/p/main.js",
161
+ eager: true,
162
+ })
163
+ await flush()
164
+ const stale = lastChild()
165
+ sandbox.unloadPlugin("p")
166
+ await expect(first).resolves.toBeUndefined()
167
+ expect(stale.killed).toBe(true)
168
+
169
+ // Respawn for the same id.
170
+ const second = sandbox.loadPlugin({
171
+ id: "p",
172
+ mainPath: "/p/main.js",
173
+ eager: true,
174
+ })
175
+ await flush()
176
+ const current = lastChild()
177
+ expect(current).not.toBe(stale)
178
+
179
+ // The stale child's late "loaded" must not resolve the new spawn's
180
+ // load waiter — the second load stays pending until ITS child loads.
181
+ let secondSettled = false
182
+ void second.then(() => {
183
+ secondSettled = true
184
+ })
185
+ stale.emit("message", { type: "loaded", ok: true, hooks: ["detect"] })
186
+ await flush()
187
+ expect(secondSettled).toBe(false)
188
+
189
+ current.emit("message", { type: "loaded", ok: true, hooks: ["detect"] })
190
+ const plugin = await second
191
+ if (plugin === undefined) throw new Error("plugin load failed")
192
+
193
+ // A stale "result" must not resolve a pending call on the new child.
194
+ const detect = plugin.detect(createStubApi())
195
+ // Let invoke() register the pending call before delivering results.
196
+ await flush()
197
+ stale.emit("message", {
198
+ type: "result",
199
+ callId: 1,
200
+ ok: true,
201
+ value: { ok: false, reasons: ["stale"] },
202
+ })
203
+ current.emit("message", {
204
+ type: "result",
205
+ callId: 1,
206
+ ok: true,
207
+ value: { ok: true },
208
+ })
209
+ await expect(detect).resolves.toEqual({ ok: true })
210
+ })
211
+
212
+ test("concurrent loadPlugin calls for the same id keep the newer state alive", async () => {
213
+ vi.spyOn(console, "error").mockImplementation(() => {})
214
+ sandbox = createPluginSandbox(unitConfig())
215
+ const first = sandbox.loadPlugin({
216
+ id: "p",
217
+ mainPath: "/p/old.js",
218
+ eager: true,
219
+ })
220
+ await flush()
221
+ const c1 = lastChild()
222
+
223
+ // Second load for the same id while the first is still in flight:
224
+ // the old child is NOT torn down until the new bundle loads — a
225
+ // failed reload must never strand the previous child.
226
+ const second = sandbox.loadPlugin({
227
+ id: "p",
228
+ mainPath: "/p/new.js",
229
+ eager: true,
230
+ })
231
+ await flush()
232
+ expect(c1.killed).toBe(false)
233
+ const c2 = lastChild()
234
+ expect(c2).not.toBe(c1)
235
+
236
+ // Both bundles load; the newer state owns the id and retires the
237
+ // previous child only then.
238
+ c1.emit("message", { type: "loaded", ok: true, hooks: ["detect"] })
239
+ await first
240
+ c2.emit("message", { type: "loaded", ok: true, hooks: ["detect"] })
241
+ const plugin = await second
242
+ if (plugin === undefined) throw new Error("plugin load failed")
243
+ expect(c1.killed).toBe(true)
244
+
245
+ const detect = plugin.detect(createStubApi())
246
+ await flush()
247
+ c2.emit("message", {
248
+ type: "result",
249
+ callId: 1,
250
+ ok: true,
251
+ value: { ok: true },
252
+ })
253
+ await expect(detect).resolves.toEqual({ ok: true })
254
+
255
+ // The newer state is still tracked — dispose terminates its child.
256
+ await sandbox.disposeAll()
257
+ expect(c2.killed).toBe(true)
258
+ })
259
+
260
+ test("a failed reload keeps the previous child alive and serving", async () => {
261
+ vi.spyOn(console, "error").mockImplementation(() => {})
262
+ sandbox = createPluginSandbox(unitConfig())
263
+ const first = sandbox.loadPlugin({
264
+ id: "p",
265
+ mainPath: "/p/old.js",
266
+ eager: true,
267
+ })
268
+ await flush()
269
+ const c1 = lastChild()
270
+ c1.emit("message", { type: "loaded", ok: true, hooks: ["detect"] })
271
+ await expect(first).resolves.toBeDefined()
272
+
273
+ // Reload with a bundle that fails to import: the load rejects, the
274
+ // previous child must survive and keep answering hooks.
275
+ const reload = sandbox.loadPlugin({
276
+ id: "p",
277
+ mainPath: "/p/broken.js",
278
+ eager: true,
279
+ })
280
+ await flush()
281
+ const c2 = lastChild()
282
+ expect(c2).not.toBe(c1)
283
+ expect(c1.killed).toBe(false)
284
+
285
+ c2.emit("message", {
286
+ type: "loaded",
287
+ ok: false,
288
+ error: { name: "Error", message: "import exploded" },
289
+ })
290
+ const restored = await reload
291
+ if (restored === undefined) throw new Error("reload should fall back")
292
+ expect(c1.killed).toBe(false)
293
+
294
+ // The restored definition still routes through the old child.
295
+ const detect = restored.detect(createStubApi())
296
+ await flush()
297
+ c1.emit("message", {
298
+ type: "result",
299
+ callId: 1,
300
+ ok: true,
301
+ value: { ok: true },
302
+ })
303
+ await expect(detect).resolves.toEqual({ ok: true })
304
+ })
305
+
306
+ test("spawn passes the asset vault dir as an extra read grant and argv", async () => {
307
+ const vaultDir = join(tmpdir(), "vaults", "p")
308
+ sandbox = createPluginSandbox(unitConfig({ assetVaultDir: vaultDir }))
309
+ const plugin = sandbox.loadPlugin({
310
+ id: "vault-grant",
311
+ mainPath: "/p/main.js",
312
+ eager: true,
313
+ })
314
+ await flush()
315
+ const child = lastChild()
316
+ const [, spawnArgs, spawnOpts] = child.args as [
317
+ string,
318
+ string[],
319
+ { execArgv: string[] },
320
+ ]
321
+ expect(spawnArgs.at(2)).toBe(vaultDir)
322
+ // host.ts grants `${resolve(assetVaultDir)}${sep}` — the asserts
323
+ // mirror it so POSIX (sep "/") and Windows (sep "\\") both hold.
324
+ expect(
325
+ spawnOpts.execArgv.includes(`--allow-fs-read=${resolve(vaultDir)}${sep}`),
326
+ ).toBe(true)
327
+ child.emit("message", { type: "loaded", ok: true, hooks: ["detect"] })
328
+ await expect(plugin).resolves.toBeDefined()
329
+ })
330
+ })
@@ -0,0 +1,85 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { fileURLToPath } from "node:url"
3
+ import { describe, expect, test } from "vitest"
4
+ import {
5
+ API_METHOD_NAMES,
6
+ deserializeError,
7
+ HOOK_NAMES,
8
+ LOG_METHOD_NAMES,
9
+ serializeError,
10
+ } from "./protocol.ts"
11
+
12
+ /**
13
+ * worker-entry.mjs is plain JS without access to workspace TS sources, so it
14
+ * keeps its own copies of the hook/API name lists. A drift between the two
15
+ * copies only surfaces at runtime as "unknown API method" RPC failures —
16
+ * this test is the compile-time sync guarantee.
17
+ */
18
+ const workerEntrySource = readFileSync(
19
+ fileURLToPath(new URL("./worker-entry.mjs", import.meta.url)),
20
+ "utf-8",
21
+ )
22
+
23
+ /** Extract the double-quoted items of the first `[...]` list after `name`. */
24
+ function extractStringList(source: string, name: string): string[] {
25
+ // Anchor on the declaration — the file header mentions the same names.
26
+ const start = source.indexOf(`const ${name}`)
27
+ if (start === -1) throw new Error(`${name} not found in worker-entry.mjs`)
28
+ const open = source.indexOf("[", start)
29
+ const close = source.indexOf("]", open)
30
+ if (open === -1 || close === -1) {
31
+ throw new Error(`${name} list not found in worker-entry.mjs`)
32
+ }
33
+ const out: string[] = []
34
+ for (const m of source.slice(open, close).matchAll(/"([^"]+)"/g)) {
35
+ if (m[1] !== undefined) out.push(m[1])
36
+ }
37
+ return out
38
+ }
39
+
40
+ describe("protocol ↔ worker-entry name lists", () => {
41
+ test("HOOK_NAMES match in contract order", () => {
42
+ expect(extractStringList(workerEntrySource, "HOOK_NAMES")).toEqual([
43
+ ...HOOK_NAMES,
44
+ ])
45
+ })
46
+
47
+ test("API_METHOD_NAMES match in contract order", () => {
48
+ expect(extractStringList(workerEntrySource, "API_METHOD_NAMES")).toEqual([
49
+ ...API_METHOD_NAMES,
50
+ ])
51
+ })
52
+
53
+ test("LOG_METHOD_NAMES match", () => {
54
+ expect(
55
+ extractStringList(workerEntrySource, "LOG_METHOD_NAMES").sort(),
56
+ ).toEqual([...LOG_METHOD_NAMES].sort())
57
+ })
58
+ })
59
+
60
+ describe("error code over the sandbox IPC", () => {
61
+ test("the asset vocabulary round-trips via `code` (name restored verbatim)", () => {
62
+ const denied = new Error("declined")
63
+ denied.name = "DENIED"
64
+ const serialized = serializeError(denied)
65
+ expect(serialized.code).toBe("DENIED")
66
+ const restored = deserializeError(serialized)
67
+ expect(restored.name).toBe("DENIED")
68
+ expect(restored.message).toBe("declined")
69
+ })
70
+
71
+ test("plain errors carry no code and keep their own name", () => {
72
+ const serialized = serializeError(new TypeError("boom"))
73
+ expect(serialized.code).toBeUndefined()
74
+ expect(deserializeError(serialized).name).toBe("TypeError")
75
+ })
76
+
77
+ test("an explicit wire code wins over the legacy name field", () => {
78
+ const restored = deserializeError({
79
+ name: "SomeOldName",
80
+ code: "POLICY",
81
+ message: "nope",
82
+ })
83
+ expect(restored.name).toBe("POLICY")
84
+ })
85
+ })
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Wire protocol between the plugin sandbox host (server process) and the
3
+ * sandbox child entry (`worker-entry.mjs`), which runs as a forked child
4
+ * process with structured-clone IPC. The entry file is plain JS without
5
+ * access to workspace TS sources, so it keeps its own copy of the
6
+ * method/hook name lists — keep the two in sync (enforced by
7
+ * `protocol.test.ts`).
8
+ */
9
+
10
+ /**
11
+ * Plugin hook names the host can invoke, in contract order — defined in
12
+ * `@hoardodile/sdk-types`, re-exported so host internals keep one import
13
+ * point.
14
+ */
15
+ import { HOOK_NAMES, type HookName } from "@hoardodile/sdk-types"
16
+ import { PLUGIN_ASSET_ERROR_NAMES } from "@hoardodile/sdk-types/plugin-asset-limits"
17
+
18
+ export { HOOK_NAMES, type HookName }
19
+
20
+ /** ResourceAPI method names bridged over RPC. */
21
+ export const API_METHOD_NAMES = [
22
+ "logInfo",
23
+ "logWarn",
24
+ "logError",
25
+ "listFileNames",
26
+ "readFile",
27
+ "statFile",
28
+ "statFiles",
29
+ "sniff",
30
+ "probe",
31
+ "hashBytes",
32
+ "computeImageHashes",
33
+ "listContainer",
34
+ "extractArchive",
35
+ "download",
36
+ "statAsset",
37
+ "readAsset",
38
+ "deleteAsset",
39
+ ] as const
40
+
41
+ export type ApiMethodName = (typeof API_METHOD_NAMES)[number]
42
+
43
+ /** Fire-and-forget log methods — no response round-trip. */
44
+ export const LOG_METHOD_NAMES: ReadonlySet<ApiMethodName> = new Set([
45
+ "logInfo",
46
+ "logWarn",
47
+ "logError",
48
+ ])
49
+
50
+ export type SerializedError = {
51
+ readonly name: string
52
+ readonly message: string
53
+ readonly stack?: string
54
+ /**
55
+ * Machine-readable plugin error code (`DENIED`/`UNAVAILABLE`/`POLICY`)
56
+ * carried verbatim on the wire — the single field the host side reads
57
+ * to restore the plugin-facing `err.name`.
58
+ */
59
+ readonly code?: string
60
+ }
61
+
62
+ // -- host → worker --
63
+
64
+ export type LoadRequest = {
65
+ readonly type: "load"
66
+ readonly mainPath: string
67
+ }
68
+
69
+ export type InvokeRequest = {
70
+ readonly type: "invoke"
71
+ readonly callId: number
72
+ readonly hook: HookName
73
+ }
74
+
75
+ export type ApiResponse = {
76
+ readonly type: "apiResult"
77
+ readonly apiCallId: number
78
+ readonly ok: boolean
79
+ readonly value?: unknown
80
+ readonly error?: SerializedError
81
+ }
82
+
83
+ // -- worker → host --
84
+
85
+ export type LoadResponse = {
86
+ readonly type: "loaded"
87
+ readonly ok: boolean
88
+ readonly hooks?: readonly HookName[]
89
+ readonly error?: SerializedError
90
+ }
91
+
92
+ export type InvokeResponse = {
93
+ readonly type: "result"
94
+ readonly callId: number
95
+ readonly ok: boolean
96
+ readonly value?: unknown
97
+ readonly error?: SerializedError
98
+ }
99
+
100
+ export type ApiRequest = {
101
+ readonly type: "api"
102
+ readonly callId: number
103
+ readonly apiCallId: number
104
+ readonly method: ApiMethodName
105
+ readonly args: readonly unknown[]
106
+ }
107
+
108
+ export type LogRequest = {
109
+ readonly type: "log"
110
+ readonly callId: number
111
+ readonly method: "logInfo" | "logWarn" | "logError"
112
+ readonly args: readonly unknown[]
113
+ }
114
+
115
+ export type WorkerMessage =
116
+ | LoadResponse
117
+ | InvokeResponse
118
+ | ApiRequest
119
+ | LogRequest
120
+
121
+ export function serializeError(err: unknown): SerializedError {
122
+ if (err instanceof Error) {
123
+ return {
124
+ name: err.name,
125
+ message: err.message,
126
+ stack: err.stack,
127
+ // The vocabulary errors carry their code in `name`; the wire
128
+ // keeps an explicit `code` field so the receiving side never
129
+ // re-parses or re-derives it.
130
+ code: isAssetErrorName(err.name) ? err.name : undefined,
131
+ }
132
+ }
133
+ return { name: "Error", message: String(err) }
134
+ }
135
+
136
+ export function deserializeError(err: SerializedError): Error {
137
+ const e = new Error(err.message)
138
+ // `code` is authoritative when present (the vocabulary survived);
139
+ // `name` falls back for plain host errors.
140
+ e.name = err.code ?? err.name
141
+ if (err.stack !== undefined) e.stack = err.stack
142
+ return e
143
+ }
144
+
145
+ function isAssetErrorName(name: string): boolean {
146
+ return (PLUGIN_ASSET_ERROR_NAMES as readonly string[]).includes(name)
147
+ }
@@ -0,0 +1,30 @@
1
+ import { definePlugin } from "../define-plugin.ts"
2
+ import type { PluginDefinition, ResourceAPI } from "../types.ts"
3
+ import type { HookName } from "./protocol.ts"
4
+
5
+ export type InvokeFn = (hook: HookName, api: ResourceAPI) => Promise<unknown>
6
+
7
+ /**
8
+ * Build a contract-compliant {@link PluginDefinition} that forwards hooks
9
+ * into a sandboxed worker. Only hooks the plugin actually implements are
10
+ * exposed — presence is semantic (orchestrators branch on e.g.
11
+ * `plugin.listFiles === undefined`).
12
+ */
13
+ export function createSandboxedPlugin(
14
+ hooks: readonly HookName[],
15
+ invoke: InvokeFn,
16
+ ): PluginDefinition {
17
+ // RPC boundary: hook signatures share the shape `(api) => Promise<unknown>`;
18
+ // the concrete return type is enforced by the plugin contract at runtime.
19
+ const definition: Record<string, (api: ResourceAPI) => Promise<unknown>> = {}
20
+ for (const hook of hooks) {
21
+ definition[hook] = async (api: ResourceAPI) => invoke(hook, api)
22
+ }
23
+ if (definition.detect === undefined) {
24
+ definition.detect = async () => ({
25
+ ok: false,
26
+ reasons: ["plugin does not implement detect()"],
27
+ })
28
+ }
29
+ return definePlugin(definition as unknown as PluginDefinition)
30
+ }