@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.
- package/LICENSE +18 -0
- package/README.md +73 -0
- package/dist/chunks/worker-entry.mjs +422 -0
- package/dist/container-fKxefO7p.d.ts +58 -0
- package/dist/contract/index.d.ts +49 -0
- package/dist/contract/index.js +187 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/hoard/index.d.ts +754 -0
- package/dist/hoard/index.js +1939 -0
- package/dist/hoard/index.js.map +1 -0
- package/dist/image-Dx2Be3Bj.d.ts +168 -0
- package/dist/index.d.ts +788 -0
- package/dist/index.js +3311 -0
- package/dist/index.js.map +1 -0
- package/dist/media/index.d.ts +96 -0
- package/dist/media/index.js +152 -0
- package/dist/media/index.js.map +1 -0
- package/dist/probe/index.d.ts +113 -0
- package/dist/probe/index.js +454 -0
- package/dist/probe/index.js.map +1 -0
- package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
- package/dist/render/index.d.ts +125 -0
- package/dist/render/index.js +430 -0
- package/dist/render/index.js.map +1 -0
- package/package.json +103 -0
- package/src/__testutils__/zip-fixtures.ts +139 -0
- package/src/activation.ts +116 -0
- package/src/api-types.ts +60 -0
- package/src/api.test.ts +564 -0
- package/src/api.ts +501 -0
- package/src/archive/7z.test.ts +222 -0
- package/src/archive/7z.ts +261 -0
- package/src/archive/archive-ops.test.ts +654 -0
- package/src/archive/extract-archive.test.ts +237 -0
- package/src/archive/extract-archive.ts +534 -0
- package/src/archive/extract.ts +693 -0
- package/src/archive/format.ts +49 -0
- package/src/archive/index.ts +116 -0
- package/src/archive/listing.ts +121 -0
- package/src/archive/materialize.ts +88 -0
- package/src/archive/name-decode.test.ts +34 -0
- package/src/archive/name-decode.ts +287 -0
- package/src/archive/nested-cd-cache.ts +55 -0
- package/src/archive/nested-entry.test.ts +194 -0
- package/src/archive/nested-entry.ts +254 -0
- package/src/archive/pack.ts +40 -0
- package/src/archive/zip-entries.test.ts +56 -0
- package/src/archive/zip-entries.ts +336 -0
- package/src/capability-guard.ts +73 -0
- package/src/container.ts +62 -0
- package/src/contract/container-fixture.ts +74 -0
- package/src/contract/index.ts +10 -0
- package/src/contract/suite.test.ts +101 -0
- package/src/contract/suite.ts +205 -0
- package/src/define-plugin.test.ts +53 -0
- package/src/define-plugin.ts +12 -0
- package/src/dev-runner.test.ts +98 -0
- package/src/dev-runner.ts +73 -0
- package/src/directory-api.test.ts +229 -0
- package/src/directory-api.ts +42 -0
- package/src/directory-container.ts +141 -0
- package/src/discovery.ts +295 -0
- package/src/errors.ts +74 -0
- package/src/fixtures.ts +8 -0
- package/src/hash.test.ts +301 -0
- package/src/hash.ts +167 -0
- package/src/hoard/dir-size.ts +58 -0
- package/src/hoard/index.ts +84 -0
- package/src/hoard/migrate-script.test.ts +539 -0
- package/src/hoard/order-manifest.test.ts +115 -0
- package/src/hoard/order-manifest.ts +131 -0
- package/src/hoard/paths.test.ts +226 -0
- package/src/hoard/paths.ts +569 -0
- package/src/hoard/plugin-vault.test.ts +160 -0
- package/src/hoard/plugin-vault.ts +233 -0
- package/src/hoard/sanitize.test.ts +149 -0
- package/src/hoard/sanitize.ts +230 -0
- package/src/hoard/staging-dir.ts +133 -0
- package/src/hoard/version.test.ts +285 -0
- package/src/hoard/version.ts +216 -0
- package/src/hoard/versioned-folder-ops.ts +152 -0
- package/src/hoard/write-versioned.test.ts +67 -0
- package/src/hoard/write-versioned.ts +39 -0
- package/src/hooks.test.ts +528 -0
- package/src/hooks.ts +428 -0
- package/src/index.ts +135 -0
- package/src/loader.test.ts +442 -0
- package/src/loader.ts +213 -0
- package/src/media/index.ts +16 -0
- package/src/media/render-cache.ts +27 -0
- package/src/media/seekable.ts +35 -0
- package/src/media/thumb-input.test.ts +109 -0
- package/src/media/thumb-input.ts +194 -0
- package/src/nested-view.test.ts +223 -0
- package/src/nested-view.ts +239 -0
- package/src/probe/audio.test.ts +135 -0
- package/src/probe/audio.ts +140 -0
- package/src/probe/av.ts +84 -0
- package/src/probe/ffprobe.ts +197 -0
- package/src/probe/image.test.ts +251 -0
- package/src/probe/image.ts +336 -0
- package/src/probe/index.ts +54 -0
- package/src/probe/sniff.test.ts +72 -0
- package/src/probe/sniff.ts +67 -0
- package/src/probe/video.test.ts +41 -0
- package/src/probe/video.ts +116 -0
- package/src/probe-cache.test.ts +89 -0
- package/src/probe-cache.ts +65 -0
- package/src/render/ffmpeg.ts +78 -0
- package/src/render/index.ts +34 -0
- package/src/render/pipeline.test.ts +187 -0
- package/src/render/pipeline.ts +479 -0
- package/src/sandbox/call-timers.ts +68 -0
- package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
- package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
- package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
- package/src/sandbox/fixtures/container-plugin.mjs +8 -0
- package/src/sandbox/fixtures/context-plugin.mjs +19 -0
- package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
- package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
- package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
- package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
- package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
- package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
- package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
- package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
- package/src/sandbox/fixtures/range-plugin.mjs +9 -0
- package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
- package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
- package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
- package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
- package/src/sandbox/host.test.ts +619 -0
- package/src/sandbox/host.ts +933 -0
- package/src/sandbox/host.unit.test.ts +330 -0
- package/src/sandbox/protocol.test.ts +85 -0
- package/src/sandbox/protocol.ts +147 -0
- package/src/sandbox/sandboxed-plugin.ts +30 -0
- package/src/sandbox/worker-entry.mjs +422 -0
- package/src/seed.test.ts +102 -0
- package/src/seed.ts +160 -0
- package/src/settings-store.ts +26 -0
- 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
|
+
}
|