@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,442 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs"
10
+ import { tmpdir } from "node:os"
11
+ import { join } from "node:path"
12
+ import type { PluginManifest } from "@hoardodile/sdk-types"
13
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
14
+ import { createPluginLoader } from "./loader.ts"
15
+ import type { PluginSandbox } from "./sandbox/host.ts"
16
+ import type { PluginSettingsStore } from "./settings-store.ts"
17
+
18
+ const SHARED_ID = "11111111-1111-4111-8111-111111111111"
19
+
20
+ /** Empty settings store — these tests never record plugin settings. */
21
+ function createTestSettingsStore(): PluginSettingsStore {
22
+ return {
23
+ get: () => undefined,
24
+ all: () => [],
25
+ }
26
+ }
27
+
28
+ function writePluginDir(dir: string, manifest: PluginManifest): void {
29
+ mkdirSync(dir, { recursive: true })
30
+ writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest))
31
+ }
32
+
33
+ function buildManifest(
34
+ overrides: Partial<PluginManifest> = {},
35
+ ): PluginManifest {
36
+ return {
37
+ id: SHARED_ID,
38
+ name: "Test Plugin",
39
+ description: "Loader test fixture",
40
+ version: "1.0.0",
41
+ permissions: {
42
+ sourceMeta: false,
43
+ searchMeta: false,
44
+ danmaku: false,
45
+ message: false,
46
+ imageHashes: false,
47
+ container: false,
48
+ download: false,
49
+ },
50
+ ...overrides,
51
+ }
52
+ }
53
+
54
+ describe("plugin loader: dev plugin overrides same-id disk plugin", () => {
55
+ let root: string
56
+ let pluginsDir: string
57
+ let devDir: string
58
+ let consoleWarnSpy: ReturnType<typeof vi.spyOn> | undefined
59
+ let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined
60
+
61
+ beforeEach(() => {
62
+ root = mkdtempSync(join(tmpdir(), "app-plugin-loader-"))
63
+ pluginsDir = join(root, "plugins")
64
+ devDir = join(root, "dev")
65
+ mkdirSync(pluginsDir, { recursive: true })
66
+ consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
67
+ consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => {})
68
+ })
69
+
70
+ afterEach(() => {
71
+ consoleWarnSpy?.mockRestore()
72
+ consoleInfoSpy?.mockRestore()
73
+ rmSync(root, { recursive: true, force: true })
74
+ })
75
+
76
+ test("only the dev entry survives, disk entry is skipped with a warning", async () => {
77
+ writePluginDir(join(pluginsDir, "shared"), buildManifest())
78
+ writePluginDir(devDir, buildManifest())
79
+
80
+ const loader = createPluginLoader({
81
+ pluginsDir,
82
+ devPluginDirs: [devDir],
83
+ settings: createTestSettingsStore(),
84
+ })
85
+ const registry = await loader.loadAll()
86
+
87
+ const matching = registry.getAll().filter((e) => e.id === SHARED_ID)
88
+ expect(matching).toHaveLength(1)
89
+ expect(matching[0]?.dev).toBe(true)
90
+ expect(matching[0]?.diskPath).toBe(devDir)
91
+
92
+ const skipLogs = consoleWarnSpy?.mock.calls.filter((args: unknown[]) => {
93
+ const first = args[0]
94
+ return (
95
+ typeof first === "string" &&
96
+ first.includes("skipping disk plugin") &&
97
+ first.includes(SHARED_ID)
98
+ )
99
+ })
100
+ expect(skipLogs).toHaveLength(1)
101
+ })
102
+
103
+ test("disk entry loads when no dev plugin overrides it", async () => {
104
+ writePluginDir(join(pluginsDir, "shared"), buildManifest())
105
+
106
+ const loader = createPluginLoader({
107
+ pluginsDir,
108
+ settings: createTestSettingsStore(),
109
+ })
110
+ const registry = await loader.loadAll()
111
+
112
+ const matching = registry.getAll().filter((e) => e.id === SHARED_ID)
113
+ expect(matching).toHaveLength(1)
114
+ expect(matching[0]?.dev).toBe(false)
115
+ expect(matching[0]?.diskPath).toBe(join(pluginsDir, "shared"))
116
+ })
117
+
118
+ test("dev plugins are ignored when disableDevPlugins is true", async () => {
119
+ writePluginDir(join(pluginsDir, "shared"), buildManifest())
120
+ writePluginDir(devDir, buildManifest({ name: "Dev Override" }))
121
+
122
+ const loader = createPluginLoader({
123
+ pluginsDir,
124
+ devPluginDirs: [devDir],
125
+ disableDevPlugins: true,
126
+ settings: createTestSettingsStore(),
127
+ })
128
+ const registry = await loader.loadAll()
129
+
130
+ const matching = registry.getAll().filter((e) => e.id === SHARED_ID)
131
+ expect(matching).toHaveLength(1)
132
+ expect(matching[0]?.dev).toBe(false)
133
+ expect(matching[0]?.diskPath).toBe(join(pluginsDir, "shared"))
134
+ })
135
+
136
+ test("manifest with ui.card loads and parses slot templates", async () => {
137
+ writePluginDir(
138
+ join(pluginsDir, "card"),
139
+ buildManifest({
140
+ id: "22222222-2222-4222-8222-222222222222",
141
+ ui: {
142
+ card: {
143
+ image: {
144
+ br: ["{{bytes(file.sizeBytes)}}"],
145
+ bl: ["{{source.width}}×{{source.height}}"],
146
+ },
147
+ video: {
148
+ br: ["{{bytes(file.sizeBytes)}}"],
149
+ tl: ["▶ {{duration(source.durationMs)}}"],
150
+ },
151
+ audio: {
152
+ br: ["{{bytes(file.sizeBytes)}}"],
153
+ },
154
+ },
155
+ },
156
+ }),
157
+ )
158
+
159
+ const loader = createPluginLoader({
160
+ pluginsDir,
161
+ settings: createTestSettingsStore(),
162
+ })
163
+ const registry = await loader.loadAll()
164
+
165
+ const entry = registry.getById("22222222-2222-4222-8222-222222222222")
166
+ expect(entry).toBeDefined()
167
+ expect(entry?.manifest.ui?.card?.image?.br).toEqual([
168
+ "{{bytes(file.sizeBytes)}}",
169
+ ])
170
+ expect(entry?.manifest.ui?.card?.video?.tl).toEqual([
171
+ "▶ {{duration(source.durationMs)}}",
172
+ ])
173
+ })
174
+
175
+ test("manifest with invalid ui.card kind strips unknown keys", async () => {
176
+ writePluginDir(
177
+ join(pluginsDir, "bad"),
178
+ buildManifest({
179
+ id: "33333333-3333-4333-8333-333333333333",
180
+ ui: {
181
+ card: {
182
+ // @ts-expect-error — injecting an invalid kind for testing
183
+ invalidKind: {
184
+ br: ["bad"],
185
+ },
186
+ },
187
+ },
188
+ }),
189
+ )
190
+
191
+ const loader = createPluginLoader({
192
+ pluginsDir,
193
+ settings: createTestSettingsStore(),
194
+ })
195
+ const registry = await loader.loadAll()
196
+
197
+ const entry = registry.getById("33333333-3333-4333-8333-333333333333")
198
+ expect(entry).toBeDefined()
199
+ // Zod strips unknown keys during parse, so invalidKind is discarded.
200
+ expect(
201
+ // @ts-expect-error — accessing a stripped key
202
+ entry?.manifest.ui?.card?.invalidKind,
203
+ ).toBeUndefined()
204
+ })
205
+ })
206
+
207
+ describe("plugin loader: plugin seeding", () => {
208
+ let root: string
209
+ let pluginsDir: string
210
+ let seedDir: string
211
+ let consoleWarnSpy: ReturnType<typeof vi.spyOn> | undefined
212
+ let consoleInfoSpy: ReturnType<typeof vi.spyOn> | undefined
213
+
214
+ beforeEach(() => {
215
+ root = mkdtempSync(join(tmpdir(), "app-plugin-seed-"))
216
+ pluginsDir = join(root, "plugins")
217
+ seedDir = join(root, "seed")
218
+ mkdirSync(pluginsDir, { recursive: true })
219
+ consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
220
+ consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => {})
221
+ })
222
+
223
+ afterEach(() => {
224
+ consoleWarnSpy?.mockRestore()
225
+ consoleInfoSpy?.mockRestore()
226
+ rmSync(root, { recursive: true, force: true })
227
+ })
228
+
229
+ test("each seed directory is copied into pluginsDir under its manifest id", async () => {
230
+ const firstId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
231
+ const secondId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
232
+ writePluginDir(join(seedDir, "first"), buildManifest({ id: firstId }))
233
+ writePluginDir(join(seedDir, "second"), buildManifest({ id: secondId }))
234
+
235
+ const loader = createPluginLoader({
236
+ pluginsDir,
237
+ seedPluginDirs: [join(seedDir, "first"), join(seedDir, "second")],
238
+ settings: createTestSettingsStore(),
239
+ })
240
+ const registry = await loader.loadAll()
241
+
242
+ expect(registry.getById(firstId)?.diskPath).toBe(join(pluginsDir, firstId))
243
+ expect(registry.getById(secondId)?.diskPath).toBe(
244
+ join(pluginsDir, secondId),
245
+ )
246
+ expect(existsSync(join(pluginsDir, firstId, "manifest.json"))).toBe(true)
247
+ })
248
+
249
+ test("seed replaces an existing installed copy", async () => {
250
+ writePluginDir(join(pluginsDir, SHARED_ID), buildManifest({ name: "old" }))
251
+ writePluginDir(join(seedDir, "plugin"), buildManifest({ name: "new" }))
252
+
253
+ const loader = createPluginLoader({
254
+ pluginsDir,
255
+ seedPluginDirs: [join(seedDir, "plugin")],
256
+ settings: createTestSettingsStore(),
257
+ })
258
+ await loader.loadAll()
259
+
260
+ const seeded = readFileSync(
261
+ join(pluginsDir, SHARED_ID, "manifest.json"),
262
+ "utf-8",
263
+ )
264
+ expect(JSON.parse(seeded).name).toBe("new")
265
+ })
266
+
267
+ test("dev plugin still overrides its seeded disk copy", async () => {
268
+ const devDir = join(root, "dev")
269
+ writePluginDir(join(seedDir, "plugin"), buildManifest({ name: "disk" }))
270
+ writePluginDir(devDir, buildManifest({ name: "dev" }))
271
+
272
+ const loader = createPluginLoader({
273
+ pluginsDir,
274
+ devPluginDirs: [devDir],
275
+ seedPluginDirs: [join(seedDir, "plugin")],
276
+ settings: createTestSettingsStore(),
277
+ })
278
+ const registry = await loader.loadAll()
279
+
280
+ const matching = registry.getAll().filter((e) => e.id === SHARED_ID)
281
+ expect(matching).toHaveLength(1)
282
+ expect(matching[0]?.dev).toBe(true)
283
+ expect(matching[0]?.diskPath).toBe(devDir)
284
+ })
285
+
286
+ test("invalid seed directories are skipped", async () => {
287
+ const loader = createPluginLoader({
288
+ pluginsDir,
289
+ seedPluginDirs: [join(root, "missing"), join(root, "invalid")],
290
+ settings: createTestSettingsStore(),
291
+ })
292
+ const registry = await loader.loadAll()
293
+
294
+ expect(registry.getAll()).toEqual([])
295
+ expect(readdirSync(pluginsDir)).toEqual([])
296
+ })
297
+ })
298
+
299
+ describe("plugin loader: same-id dedupe", () => {
300
+ let root: string
301
+ let pluginsDir: string
302
+ let consoleWarnSpy: ReturnType<typeof vi.spyOn> | undefined
303
+
304
+ beforeEach(() => {
305
+ root = mkdtempSync(join(tmpdir(), "app-plugin-dedupe-"))
306
+ pluginsDir = join(root, "plugins")
307
+ mkdirSync(pluginsDir, { recursive: true })
308
+ consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
309
+ })
310
+
311
+ afterEach(() => {
312
+ consoleWarnSpy?.mockRestore()
313
+ rmSync(root, { recursive: true, force: true })
314
+ })
315
+
316
+ test("two dev dirs with the same id: only the last one survives", async () => {
317
+ const devA = join(root, "dev-a")
318
+ const devB = join(root, "dev-b")
319
+ writePluginDir(devA, buildManifest({ name: "A" }))
320
+ writePluginDir(devB, buildManifest({ name: "B" }))
321
+
322
+ const loader = createPluginLoader({
323
+ pluginsDir,
324
+ devPluginDirs: [devA, devB],
325
+ settings: createTestSettingsStore(),
326
+ })
327
+ const registry = await loader.loadAll()
328
+
329
+ // One entry per id everywhere — the last dev dir wins.
330
+ expect(registry.getAll().filter((e) => e.id === SHARED_ID)).toHaveLength(1)
331
+ expect(registry.getById(SHARED_ID)?.diskPath).toBe(devB)
332
+ expect(registry.getEnabled()).toHaveLength(1)
333
+ })
334
+
335
+ test("a disk copy of the builtin plugin id is skipped", async () => {
336
+ const builtinDir = join(root, "builtin")
337
+ writePluginDir(builtinDir, buildManifest({ name: "Builtin" }))
338
+ writePluginDir(join(pluginsDir, "copy"), buildManifest({ name: "Copy" }))
339
+
340
+ const loader = createPluginLoader({
341
+ builtinDir,
342
+ pluginsDir,
343
+ settings: createTestSettingsStore(),
344
+ })
345
+ const registry = await loader.loadAll()
346
+
347
+ expect(registry.getAll().filter((e) => e.id === SHARED_ID)).toHaveLength(1)
348
+ expect(registry.getBuiltin()?.id).toBe(SHARED_ID)
349
+ expect(registry.getBuiltin()?.builtin).toBe(true)
350
+ expect(registry.getBuiltin()?.diskPath).toBe(builtinDir)
351
+ })
352
+
353
+ test("two disk dirs with the same id: only the first survives", async () => {
354
+ writePluginDir(join(pluginsDir, "first"), buildManifest({ name: "First" }))
355
+ writePluginDir(
356
+ join(pluginsDir, "second"),
357
+ buildManifest({ name: "Second" }),
358
+ )
359
+
360
+ const loader = createPluginLoader({
361
+ pluginsDir,
362
+ settings: createTestSettingsStore(),
363
+ })
364
+ const registry = await loader.loadAll()
365
+
366
+ expect(registry.getAll().filter((e) => e.id === SHARED_ID)).toHaveLength(1)
367
+ expect(registry.getById(SHARED_ID)?.diskPath).toBe(
368
+ join(pluginsDir, "first"),
369
+ )
370
+ })
371
+
372
+ test("a later loadAll after a broken builtin degrades without stranding the registry", async () => {
373
+ const builtinDir = join(root, "builtin")
374
+ writePluginDir(builtinDir, buildManifest({ name: "Builtin" }))
375
+
376
+ const loader = createPluginLoader({
377
+ builtinDir,
378
+ pluginsDir,
379
+ settings: createTestSettingsStore(),
380
+ })
381
+ const first = await loader.loadAll()
382
+ expect(first.getBuiltin()?.id).toBe(SHARED_ID)
383
+
384
+ // The builtin directory disappears mid-session: the FIRST load at
385
+ // boot fails fast (config error), but a later rescan must not throw
386
+ // — it degrades to a registry without the builtin.
387
+ rmSync(builtinDir, { recursive: true, force: true })
388
+ const second = await loader.rescan()
389
+ expect(second.getBuiltin()).toBeUndefined()
390
+ expect(second).not.toBe(first)
391
+ })
392
+ })
393
+
394
+ describe("plugin loader: loadAll serialization", () => {
395
+ let root: string
396
+ let consoleLogSpy: ReturnType<typeof vi.spyOn> | undefined
397
+
398
+ beforeEach(() => {
399
+ root = mkdtempSync(join(tmpdir(), "app-plugin-loader-"))
400
+ consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {})
401
+ })
402
+
403
+ afterEach(() => {
404
+ consoleLogSpy?.mockRestore()
405
+ rmSync(root, { recursive: true, force: true })
406
+ })
407
+
408
+ test("concurrent loadAll calls never interleave sandbox teardown", async () => {
409
+ const log: string[] = []
410
+ const sandbox: PluginSandbox = {
411
+ loadPlugin: async () => undefined,
412
+ unloadPlugin: () => {},
413
+ disposeExcept: async (keepIds) => {
414
+ log.push("dispose:start")
415
+ await new Promise((resolve) => setTimeout(resolve, 10))
416
+ log.push(`dispose:end:${[...keepIds].length}`)
417
+ },
418
+ disposeAll: async () => {},
419
+ }
420
+ const loader = createPluginLoader({
421
+ pluginsDir: join(root, "plugins"),
422
+ settings: createTestSettingsStore(),
423
+ sandbox,
424
+ })
425
+
426
+ const [first, second] = await Promise.all([
427
+ loader.loadAll(),
428
+ loader.loadAll(),
429
+ ])
430
+ expect(first).toBeDefined()
431
+ expect(loader.getRegistry()).toBe(second)
432
+
433
+ // Every teardown must complete before the next run starts one.
434
+ const starts = log.flatMap((event, i) =>
435
+ event === "dispose:start" ? [i] : [],
436
+ )
437
+ expect(starts).toHaveLength(2)
438
+ for (const i of starts) {
439
+ expect(log[i + 1]).toMatch(/^dispose:end:/)
440
+ }
441
+ })
442
+ })
package/src/loader.ts ADDED
@@ -0,0 +1,213 @@
1
+ import type { PluginManifestId } from "@hoardodile/sdk-types"
2
+ import { createPluginActivation } from "./activation.ts"
3
+ import type { PluginRegistry, PluginRegistryEntry } from "./api-types.ts"
4
+ import { createPluginDiscovery } from "./discovery.ts"
5
+ import { createPluginSandbox, type PluginSandbox } from "./sandbox/host.ts"
6
+ import { seedPlugins } from "./seed.ts"
7
+ import type { PluginSettingsStore } from "./settings-store.ts"
8
+
9
+ export { seedPlugins } from "./seed.ts"
10
+
11
+ /**
12
+ * Loads plugins into a live {@link PluginRegistry}: seed the configured
13
+ * plugin directories, discover, activate, and sort by priority.
14
+ * `loadAll`/`rescan` are serialized — an overlapping run would replace
15
+ * workers out from under the previous run's in-flight loads.
16
+ *
17
+ * The previous registry and its workers are only replaced AFTER the new
18
+ * registry finished activating: a failed load never strands a stale
19
+ * registry whose workers were already disposed (every hook would then
20
+ * fail until the next restart).
21
+ */
22
+ export type PluginLoader = {
23
+ readonly loadAll: () => Promise<PluginRegistry>
24
+ readonly rescan: () => Promise<PluginRegistry>
25
+ /** The current registry; throws when `loadAll` has not run yet. */
26
+ readonly getRegistry: () => PluginRegistry
27
+ }
28
+
29
+ export type PluginLoaderDeps = {
30
+ readonly builtinDir?: string
31
+ readonly devPluginDirs?: readonly string[]
32
+ /**
33
+ * Plugin directories (each with `manifest.json` at its root, e.g. a
34
+ * built plugin's `dist/`) that are seeded into `pluginsDir` on every
35
+ * load. Each plugin is copied into `pluginsDir/<manifest.id>` before
36
+ * discovery runs when the destination tree differs — so a seeded
37
+ * plugin behaves like a regular installed one (DB settings, caching,
38
+ * uninstall). The server passes an empty list and seeds through
39
+ * `writeVersioned` instead. When omitted, nothing is seeded.
40
+ */
41
+ readonly seedPluginDirs?: readonly string[]
42
+ readonly pluginsDir: string
43
+ readonly settings: PluginSettingsStore
44
+ readonly disableDevPlugins?: boolean
45
+ /**
46
+ * Worker-thread sandbox that executes plugin hooks. Optional so tests
47
+ * without any `main.js` on disk can omit it; the default spawns real
48
+ * workers when a loadable bundle is found.
49
+ */
50
+ readonly sandbox?: PluginSandbox
51
+ /**
52
+ * Optional timing sink for boot diagnostics — receives the duration of
53
+ * each `loadAll` step. Defaults to a no-op so tests stay quiet.
54
+ */
55
+ readonly onTiming?: (step: string, ms: number) => void
56
+ }
57
+
58
+ /**
59
+ * Create the plugin loader. Workers are respawned on every load so a
60
+ * rescan picks up changed plugin code — the new worker re-imports
61
+ * `main.js`, bypassing the main thread's ESM module cache. Configured
62
+ * {@link PluginLoaderDeps.seedPluginDirs} are copied into `pluginsDir`
63
+ * on each load.
64
+ */
65
+ export function createPluginLoader(deps: PluginLoaderDeps): PluginLoader {
66
+ let registry: PluginRegistry | undefined
67
+ // Serializes loadAll/rescan: an overlapping run would replace workers
68
+ // out from under the previous run's in-flight plugin loads.
69
+ let chain: Promise<unknown> = Promise.resolve()
70
+
71
+ const discovery = createPluginDiscovery(deps)
72
+ const sandbox = deps.sandbox ?? createPluginSandbox()
73
+ const activation = createPluginActivation({ sandbox })
74
+
75
+ function loadAll(): Promise<PluginRegistry> {
76
+ const run = chain.then(doLoadAll)
77
+ chain = run.catch(() => {})
78
+ return run
79
+ }
80
+
81
+ async function doLoadAll(): Promise<PluginRegistry> {
82
+ // Seed first — tolerant of individual failures: a locked directory
83
+ // (Windows/AV races with a plugin watch rebuilding dist) only skips
84
+ // that plugin instead of aborting the whole load.
85
+ const seedStart = performance.now()
86
+ seedPlugins(deps.pluginsDir, deps.seedPluginDirs)
87
+ deps.onTiming?.("seed", Math.round(performance.now() - seedStart))
88
+
89
+ const discoverStart = performance.now()
90
+ const { found, missing } = await discovery.discover()
91
+ deps.onTiming?.("discover", Math.round(performance.now() - discoverStart))
92
+
93
+ // Activate BEFORE disposing anything from the previous registry:
94
+ // same-id plugins replace their worker in the sandbox, and a
95
+ // failure anywhere above leaves the previous registry (and its
96
+ // workers) fully alive instead of stranding a stale registry with
97
+ // disposed workers.
98
+ const activateStart = performance.now()
99
+ const activated = await activation.activateAll(found)
100
+ deps.onTiming?.("activate", Math.round(performance.now() - activateStart))
101
+
102
+ const failing = activation.createFailingEntries(missing)
103
+
104
+ const entries = [...activated, ...failing]
105
+ entries.sort((a, b) => a.priority - b.priority)
106
+
107
+ const next = buildRegistry(entries)
108
+ // Plugins that left the registry (uninstalled/removed) free their
109
+ // workers; everything still registered keeps the worker activation
110
+ // just loaded.
111
+ await sandbox.disposeExcept(new Set(next.getAll().map((e) => e.id)))
112
+ registry = next
113
+ return registry
114
+ }
115
+
116
+ async function rescan(): Promise<PluginRegistry> {
117
+ return loadAll()
118
+ }
119
+
120
+ function getRegistry(): PluginRegistry {
121
+ if (registry === undefined) {
122
+ throw new Error("Plugin registry not initialised — call loadAll() first")
123
+ }
124
+ return registry
125
+ }
126
+
127
+ return { loadAll, rescan, getRegistry }
128
+ }
129
+
130
+ /**
131
+ * Build the immutable-ish registry from entries, keyed by plugin UUID.
132
+ * Duplicate ids keep the first entry and warn (the UUID contract is
133
+ * unique — duplicates signal a broken install). Duplicates are dropped
134
+ * from every accessor, not just the id map: keeping a duplicate in
135
+ * `getAll()` would make enabled-list iteration invoke a plugin whose
136
+ * worker activation already replaced (and disposed) the first load.
137
+ * `updateEntry` patches a single entry in place and re-sorts by priority.
138
+ */
139
+ export function buildRegistry(
140
+ entries: readonly PluginRegistryEntry[],
141
+ ): PluginRegistry {
142
+ const byId = new Map<PluginManifestId, PluginRegistryEntry>()
143
+ const deduped: PluginRegistryEntry[] = []
144
+ for (const entry of entries) {
145
+ const existing = byId.get(entry.id)
146
+ if (existing !== undefined) {
147
+ console.warn(
148
+ `[plugin-loader] skipping ${entry.id}: UUID conflicts with already registered plugin (${existing.manifest.name ?? existing.id}), keeping first`,
149
+ )
150
+ continue
151
+ }
152
+ byId.set(entry.id, entry)
153
+ deduped.push(entry)
154
+ }
155
+
156
+ let sortedEntries = deduped
157
+
158
+ return {
159
+ getAll(): readonly PluginRegistryEntry[] {
160
+ return sortedEntries
161
+ },
162
+ getEnabled(): readonly PluginRegistryEntry[] {
163
+ return sortedEntries.filter((e) => e.enabled)
164
+ },
165
+ getById(id: PluginManifestId): PluginRegistryEntry | undefined {
166
+ return byId.get(id)
167
+ },
168
+ getBuiltin(): PluginRegistryEntry | undefined {
169
+ return sortedEntries.find((e) => e.builtin)
170
+ },
171
+ getForResource(
172
+ resPluginId: PluginManifestId,
173
+ ): PluginRegistryEntry | undefined {
174
+ const entry = byId.get(resPluginId)
175
+ if (entry === undefined || !entry.enabled) return undefined
176
+ return entry
177
+ },
178
+ updateEntry(
179
+ id: PluginManifestId,
180
+ patch: Partial<
181
+ Pick<
182
+ PluginRegistryEntry,
183
+ "enabled" | "priority" | "pinned" | "color" | "missing"
184
+ >
185
+ >,
186
+ ): void {
187
+ const index = sortedEntries.findIndex((e) => e.id === id)
188
+ if (index === -1) return
189
+ const old = sortedEntries[index]
190
+ if (old === undefined) return
191
+ const updated: PluginRegistryEntry = {
192
+ id: old.id,
193
+ manifest: old.manifest,
194
+ plugin: old.plugin,
195
+ diskPath: old.diskPath,
196
+ enabled: patch.enabled ?? old.enabled,
197
+ priority: patch.priority ?? old.priority,
198
+ pinned: patch.pinned ?? old.pinned,
199
+ color: patch.color ?? old.color,
200
+ missing: patch.missing ?? old.missing,
201
+ builtin: old.builtin,
202
+ dev: old.dev,
203
+ }
204
+ sortedEntries = [
205
+ ...sortedEntries.slice(0, index),
206
+ updated,
207
+ ...sortedEntries.slice(index + 1),
208
+ ]
209
+ sortedEntries.sort((a, b) => a.priority - b.priority)
210
+ byId.set(id, updated)
211
+ },
212
+ }
213
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Media pipeline helpers: entry-to-render-input dispatch, the
3
+ * streamable-vs-materialized source gate, and the cache/queue render
4
+ * gate. Shared by the server's thumbnail service and cover-meta probes
5
+ * so both paths agree on how a zip entry becomes a renderable source.
6
+ */
7
+
8
+ export type { KeyedTaskQueue } from "./render-cache.ts"
9
+ export { withCacheAndQueue } from "./render-cache.ts"
10
+ export { withMediaSource } from "./seekable.ts"
11
+ export type { ThumbInput, WithThumbInputOptions } from "./thumb-input.ts"
12
+ export {
13
+ ffmpegThumbSource,
14
+ imageThumbSource,
15
+ withThumbInput,
16
+ } from "./thumb-input.ts"
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Minimal keyed task queue contract. Structural: the server's
3
+ * `createKeyedQueue` (and the CLI's bench queue) satisfy it.
4
+ */
5
+ export type KeyedTaskQueue<T> = {
6
+ readonly run: (key: string, job: () => Promise<T>) => Promise<T>
7
+ }
8
+
9
+ /**
10
+ * Cache-first render gate: return the cached result when present, else
11
+ * run the render job through the queue — re-checking the cache inside
12
+ * the queued task so concurrent callers coalesce onto one render.
13
+ */
14
+ export async function withCacheAndQueue<T>(
15
+ queue: KeyedTaskQueue<T>,
16
+ checkCache: () => Promise<T | undefined>,
17
+ queueKey: string,
18
+ job: () => Promise<T>,
19
+ ): Promise<T> {
20
+ const cached = await checkCache()
21
+ if (cached !== undefined) return cached
22
+ return queue.run(queueKey, async () => {
23
+ const raced = await checkCache()
24
+ if (raced !== undefined) return raced
25
+ return job()
26
+ })
27
+ }