@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
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ImageHash,
|
|
3
|
+
ImageHashesResult,
|
|
4
|
+
PluginManifestId,
|
|
5
|
+
SerializedFileList,
|
|
6
|
+
} from "@hoardodile/sdk-types"
|
|
7
|
+
import type { PluginRegistry, PluginRegistryEntry } from "./api-types.ts"
|
|
8
|
+
import { createCapabilityGuard } from "./capability-guard.ts"
|
|
9
|
+
import type { Detection, ResourceAPI } from "./types.ts"
|
|
10
|
+
|
|
11
|
+
/** Absolute cap of hash rows one resource may contribute (host policy). */
|
|
12
|
+
export const MAX_IMAGE_HASHES_PER_RESOURCE = 2000
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run `detect` over the given registry entries in order; returns the id of
|
|
16
|
+
* the first matching plugin, or `undefined` when none match. A throwing
|
|
17
|
+
* detector is logged (tagged with `action`) and skipped — one crash must
|
|
18
|
+
* never abort the scan.
|
|
19
|
+
*/
|
|
20
|
+
async function runDetectors(
|
|
21
|
+
entries: readonly PluginRegistryEntry[],
|
|
22
|
+
api: ResourceAPI,
|
|
23
|
+
action: string,
|
|
24
|
+
): Promise<PluginManifestId | undefined> {
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
const result = await invokeHook(entry, entry.plugin.detect, api, action)
|
|
27
|
+
if (result?.ok) return entry.id
|
|
28
|
+
}
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Invoke one hook of a registry entry with the uniform policy: an
|
|
34
|
+
* unimplemented hook answers `undefined` without running, a throw is
|
|
35
|
+
* logged via {@link hookFailureText} and also answers `undefined`. This
|
|
36
|
+
* is the "throw means absence" policy — callers that must tell a hook
|
|
37
|
+
* failure apart from a hook that ran (meta-hook key presence) keep
|
|
38
|
+
* their own try/catch instead.
|
|
39
|
+
*/
|
|
40
|
+
async function invokeHook<T>(
|
|
41
|
+
entry: PluginRegistryEntry,
|
|
42
|
+
hook: ((api: ResourceAPI) => T | Promise<T>) | undefined,
|
|
43
|
+
api: ResourceAPI,
|
|
44
|
+
action: string,
|
|
45
|
+
level: "warn" | "error" = "error",
|
|
46
|
+
): Promise<T | undefined> {
|
|
47
|
+
if (hook === undefined) return undefined
|
|
48
|
+
try {
|
|
49
|
+
return await hook(api)
|
|
50
|
+
} catch (err) {
|
|
51
|
+
const log = level === "warn" ? console.warn : console.error
|
|
52
|
+
log(hookFailureText(action, entry.id, err))
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function errorText(err: unknown): string {
|
|
58
|
+
return err instanceof Error ? err.message : String(err)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hookFailureText(
|
|
62
|
+
action: string,
|
|
63
|
+
pluginId: PluginManifestId,
|
|
64
|
+
err: unknown,
|
|
65
|
+
): string {
|
|
66
|
+
return `[plugin-hooks] ${action} failed for plugin ${pluginId}: ${errorText(err)}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type PluginHooksDeps = {
|
|
70
|
+
/**
|
|
71
|
+
* Live accessor for the current registry — called on every hook
|
|
72
|
+
* invocation so a `rescan()` replacing the registry never leaves
|
|
73
|
+
* consumers holding a stale snapshot.
|
|
74
|
+
*/
|
|
75
|
+
readonly getRegistry: () => PluginRegistry
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Result of {@link PluginHooks.runMetaHooks}. A present key means the hook
|
|
80
|
+
* ran (permission granted + implemented); `value` is the raw hook result,
|
|
81
|
+
* which may still be `undefined` when the hook itself returned nothing.
|
|
82
|
+
*/
|
|
83
|
+
export type PluginMetaHookResults = {
|
|
84
|
+
readonly sourceMeta?: { readonly value: unknown }
|
|
85
|
+
readonly searchMeta?: { readonly value: unknown }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The single entry point for executing plugin hooks. Owns every
|
|
90
|
+
* hook-invocation policy: priority iteration, builtin fallback, error
|
|
91
|
+
* swallowing/logging, capability checks, and result validation.
|
|
92
|
+
*
|
|
93
|
+
* Methods receive a ready-built {@link ResourceAPI} — this module knows
|
|
94
|
+
* nothing about resources, archives, or paths.
|
|
95
|
+
*/
|
|
96
|
+
export type PluginHooks = {
|
|
97
|
+
/** The builtin fallback plugin id. Throws when no builtin is registered. */
|
|
98
|
+
readonly defaultPluginId: () => PluginManifestId
|
|
99
|
+
/**
|
|
100
|
+
* The registry entry that should serve a resource's read paths right
|
|
101
|
+
* now: the resource's stored plugin when it is healthy (registered,
|
|
102
|
+
* enabled, not missing), otherwise the builtin fallback plugin.
|
|
103
|
+
* Throws when no builtin is registered.
|
|
104
|
+
*/
|
|
105
|
+
readonly getEffectiveEntry: (
|
|
106
|
+
resPluginId: PluginManifestId | null,
|
|
107
|
+
) => PluginRegistryEntry
|
|
108
|
+
/** Run all enabled plugins' detectors in priority order. Returns the first matching plugin id. Throws if no match (builtin should always match). */
|
|
109
|
+
readonly detectFirstMatch: (api: ResourceAPI) => Promise<PluginManifestId>
|
|
110
|
+
/** Validate that the current plugin still matches. Returns confirmed plugin id or falls back to builtin id. */
|
|
111
|
+
readonly revalidate: (
|
|
112
|
+
api: ResourceAPI,
|
|
113
|
+
currentPluginId: PluginManifestId,
|
|
114
|
+
) => Promise<PluginManifestId>
|
|
115
|
+
/** Run a specific plugin's detector. */
|
|
116
|
+
readonly detectForPlugin: (
|
|
117
|
+
api: ResourceAPI,
|
|
118
|
+
pluginId: PluginManifestId,
|
|
119
|
+
) => Promise<Detection>
|
|
120
|
+
/**
|
|
121
|
+
* Detector pass for folder-import candidates: non-builtin detectors in
|
|
122
|
+
* priority order, falling back to the builtin plugin without invoking
|
|
123
|
+
* its detector.
|
|
124
|
+
*/
|
|
125
|
+
readonly detectForImportDir: (api: ResourceAPI) => Promise<PluginManifestId>
|
|
126
|
+
/**
|
|
127
|
+
* Ask the owning plugin for a custom file list. Returns `undefined`
|
|
128
|
+
* when the plugin has no file list hook (or it failed).
|
|
129
|
+
*/
|
|
130
|
+
readonly buildFileList: (
|
|
131
|
+
api: ResourceAPI,
|
|
132
|
+
pluginId: PluginManifestId,
|
|
133
|
+
) => Promise<SerializedFileList | undefined>
|
|
134
|
+
/** Ask the owning plugin which file should be used for the local cover. */
|
|
135
|
+
readonly resolveLocalCoverSource: (
|
|
136
|
+
api: ResourceAPI,
|
|
137
|
+
pluginId: PluginManifestId,
|
|
138
|
+
) => Promise<string | undefined>
|
|
139
|
+
/**
|
|
140
|
+
* Run the capability-gated meta hooks (`sourceMeta`, `searchMeta`) of
|
|
141
|
+
* the owning plugin. Keys are absent when the permission is not
|
|
142
|
+
* granted or the hook is not implemented.
|
|
143
|
+
*/
|
|
144
|
+
readonly runMetaHooks: (
|
|
145
|
+
api: ResourceAPI,
|
|
146
|
+
pluginId: PluginManifestId,
|
|
147
|
+
) => Promise<PluginMetaHookResults>
|
|
148
|
+
/**
|
|
149
|
+
* True when the plugin's manifest grants `imageHashes` and the plugin
|
|
150
|
+
* implements the hook. Used to decide whether a missing hash state is
|
|
151
|
+
* a rebuild gap or the plugin's legitimate choice.
|
|
152
|
+
*/
|
|
153
|
+
readonly supportsImageHashes: (pluginId: PluginManifestId) => boolean
|
|
154
|
+
/**
|
|
155
|
+
* Run the capability-gated `imageHashes` hook of the owning plugin.
|
|
156
|
+
* Resolves to `undefined` when the permission is not granted, the
|
|
157
|
+
* hook is not implemented, or it threw. The returned entries are
|
|
158
|
+
* shape-validated (hex values, per-resource cap).
|
|
159
|
+
*/
|
|
160
|
+
readonly runImageHashes: (
|
|
161
|
+
api: ResourceAPI,
|
|
162
|
+
pluginId: PluginManifestId,
|
|
163
|
+
) => Promise<ImageHashesResult | undefined>
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function createPluginHooks(deps: PluginHooksDeps): PluginHooks {
|
|
167
|
+
const { getRegistry } = deps
|
|
168
|
+
const guard = createCapabilityGuard()
|
|
169
|
+
|
|
170
|
+
function defaultPluginId(): PluginManifestId {
|
|
171
|
+
const builtin = getRegistry().getBuiltin()
|
|
172
|
+
if (builtin === undefined) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
"No builtin plugin in registry — cannot determine default plugin",
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
return builtin.id
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getEffectiveEntry(
|
|
181
|
+
resPluginId: PluginManifestId | null,
|
|
182
|
+
): PluginRegistryEntry {
|
|
183
|
+
if (resPluginId !== null) {
|
|
184
|
+
const entry = getRegistry().getById(resPluginId)
|
|
185
|
+
if (entry?.enabled === true && !entry.missing) {
|
|
186
|
+
return entry
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const builtin = getRegistry().getBuiltin()
|
|
190
|
+
if (builtin === undefined) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
"No builtin plugin in registry — cannot determine effective plugin",
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
return builtin
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function detectFirstMatch(api: ResourceAPI): Promise<PluginManifestId> {
|
|
199
|
+
const matched = await runDetectors(
|
|
200
|
+
getRegistry().getEnabled(),
|
|
201
|
+
api,
|
|
202
|
+
"detect",
|
|
203
|
+
)
|
|
204
|
+
if (matched !== undefined) return matched
|
|
205
|
+
const builtin = getRegistry().getBuiltin()
|
|
206
|
+
throw new Error(
|
|
207
|
+
`No plugin matched resource. Builtin plugin ${builtin?.id ?? "unknown"} should have matched but did not.`,
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function revalidate(
|
|
212
|
+
api: ResourceAPI,
|
|
213
|
+
currentPluginId: PluginManifestId,
|
|
214
|
+
): Promise<PluginManifestId> {
|
|
215
|
+
const registry = getRegistry()
|
|
216
|
+
const builtin = registry.getBuiltin()
|
|
217
|
+
if (builtin === undefined) {
|
|
218
|
+
throw new Error("No builtin plugin available for fallback")
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const enabled = registry.getEnabled()
|
|
222
|
+
const startIndex = enabled.findIndex((e) => e.id === currentPluginId)
|
|
223
|
+
if (startIndex < 0) {
|
|
224
|
+
return builtin.id
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const matched = await runDetectors(
|
|
228
|
+
enabled.slice(startIndex),
|
|
229
|
+
api,
|
|
230
|
+
"revalidate detect",
|
|
231
|
+
)
|
|
232
|
+
return matched ?? builtin.id
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function detectForPlugin(
|
|
236
|
+
api: ResourceAPI,
|
|
237
|
+
pluginId: PluginManifestId,
|
|
238
|
+
): Promise<Detection> {
|
|
239
|
+
const entry = getRegistry().getById(pluginId)
|
|
240
|
+
if (entry === undefined) {
|
|
241
|
+
return { ok: false, reasons: [`unknown plugin: ${pluginId}`] }
|
|
242
|
+
}
|
|
243
|
+
const result = await invokeHook(
|
|
244
|
+
entry,
|
|
245
|
+
entry.plugin.detect,
|
|
246
|
+
api,
|
|
247
|
+
"detectForPlugin",
|
|
248
|
+
)
|
|
249
|
+
if (result === undefined) {
|
|
250
|
+
return { ok: false, reasons: ["detect threw an exception"] }
|
|
251
|
+
}
|
|
252
|
+
if (result.ok) return { ok: true }
|
|
253
|
+
return { ok: false, reasons: result.reasons.slice() }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function detectForImportDir(
|
|
257
|
+
api: ResourceAPI,
|
|
258
|
+
): Promise<PluginManifestId> {
|
|
259
|
+
const fallback = defaultPluginId()
|
|
260
|
+
const detectors = getRegistry()
|
|
261
|
+
.getEnabled()
|
|
262
|
+
.filter((e) => !e.builtin)
|
|
263
|
+
.sort((a, b) => a.priority - b.priority)
|
|
264
|
+
const matched = await runDetectors(detectors, api, "import detect")
|
|
265
|
+
return matched ?? fallback
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function buildFileList(
|
|
269
|
+
api: ResourceAPI,
|
|
270
|
+
pluginId: PluginManifestId,
|
|
271
|
+
): Promise<SerializedFileList | undefined> {
|
|
272
|
+
const entry = getRegistry().getById(pluginId)
|
|
273
|
+
if (entry === undefined) return undefined
|
|
274
|
+
const pluginResult = await invokeHook(
|
|
275
|
+
entry,
|
|
276
|
+
entry.plugin.listFiles,
|
|
277
|
+
api,
|
|
278
|
+
"listFiles",
|
|
279
|
+
)
|
|
280
|
+
if (pluginResult === undefined) return undefined
|
|
281
|
+
for (const item of pluginResult) {
|
|
282
|
+
if (typeof item === "string") continue
|
|
283
|
+
if (typeof item === "object" && item !== null && !Array.isArray(item)) {
|
|
284
|
+
for (const value of Object.values(item)) {
|
|
285
|
+
if (value === undefined) continue
|
|
286
|
+
const t = typeof value
|
|
287
|
+
if (t === "string" || t === "number" || t === "boolean") continue
|
|
288
|
+
throw new Error(
|
|
289
|
+
`Plugin ${String(pluginId)} returned an invalid file list item value type: ${t}`,
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
throw new Error(
|
|
295
|
+
`Plugin ${String(pluginId)} returned an invalid file list item`,
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
return pluginResult as SerializedFileList
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function resolveLocalCoverSource(
|
|
302
|
+
api: ResourceAPI,
|
|
303
|
+
pluginId: PluginManifestId,
|
|
304
|
+
): Promise<string | undefined> {
|
|
305
|
+
const entry = getRegistry().getById(pluginId)
|
|
306
|
+
if (entry === undefined) return undefined
|
|
307
|
+
return invokeHook(entry, entry.plugin.coverLocal, api, "coverLocal", "warn")
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function runMetaHooks(
|
|
311
|
+
api: ResourceAPI,
|
|
312
|
+
pluginId: PluginManifestId,
|
|
313
|
+
): Promise<PluginMetaHookResults> {
|
|
314
|
+
const entry = getRegistry().getById(pluginId)
|
|
315
|
+
if (entry === undefined) return {}
|
|
316
|
+
const { manifest, plugin } = entry
|
|
317
|
+
const results: {
|
|
318
|
+
sourceMeta?: { readonly value: unknown }
|
|
319
|
+
searchMeta?: { readonly value: unknown }
|
|
320
|
+
} = {}
|
|
321
|
+
/**
|
|
322
|
+
* Run one capability-gated meta hook. A present result key means
|
|
323
|
+
* the hook ran; a throw leaves the key absent (a failed hook must
|
|
324
|
+
* not wipe previously computed meta — the patch stays stale).
|
|
325
|
+
*/
|
|
326
|
+
async function runMetaHook(
|
|
327
|
+
name: "sourceMeta" | "searchMeta",
|
|
328
|
+
): Promise<void> {
|
|
329
|
+
const hook = plugin[name]
|
|
330
|
+
if (!guard.check(manifest, name) || hook === undefined) return
|
|
331
|
+
try {
|
|
332
|
+
results[name] = { value: await hook(api) }
|
|
333
|
+
} catch (err) {
|
|
334
|
+
console.error(hookFailureText(name, pluginId, err))
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
await runMetaHook("sourceMeta")
|
|
338
|
+
await runMetaHook("searchMeta")
|
|
339
|
+
return results
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function entrySupportsImageHashes(entry: PluginRegistryEntry): boolean {
|
|
343
|
+
return (
|
|
344
|
+
guard.check(entry.manifest, "imageHashes") &&
|
|
345
|
+
entry.plugin.imageHashes !== undefined
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function supportsImageHashes(pluginId: PluginManifestId): boolean {
|
|
350
|
+
const entry = getRegistry().getById(pluginId)
|
|
351
|
+
return entry !== undefined && entrySupportsImageHashes(entry)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function runImageHashes(
|
|
355
|
+
api: ResourceAPI,
|
|
356
|
+
pluginId: PluginManifestId,
|
|
357
|
+
): Promise<ImageHashesResult | undefined> {
|
|
358
|
+
const entry = getRegistry().getById(pluginId)
|
|
359
|
+
if (entry === undefined || !entrySupportsImageHashes(entry))
|
|
360
|
+
return undefined
|
|
361
|
+
return sanitizeImageHashes(
|
|
362
|
+
await invokeHook(entry, entry.plugin.imageHashes, api, "imageHashes"),
|
|
363
|
+
pluginId,
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
defaultPluginId,
|
|
369
|
+
getEffectiveEntry,
|
|
370
|
+
detectFirstMatch,
|
|
371
|
+
revalidate,
|
|
372
|
+
detectForPlugin,
|
|
373
|
+
detectForImportDir,
|
|
374
|
+
buildFileList,
|
|
375
|
+
resolveLocalCoverSource,
|
|
376
|
+
runMetaHooks,
|
|
377
|
+
supportsImageHashes,
|
|
378
|
+
runImageHashes,
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const HEX_VALUE = /^[0-9a-f]+$/
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Validate the raw hook result: a `{ hashes }` shape, hex string values,
|
|
386
|
+
* and a bounded row count (excess entries are dropped with a warning —
|
|
387
|
+
* the hook must never be able to bloat the hashes table).
|
|
388
|
+
*/
|
|
389
|
+
function sanitizeImageHashes(
|
|
390
|
+
value: unknown,
|
|
391
|
+
pluginId: string,
|
|
392
|
+
): ImageHashesResult | undefined {
|
|
393
|
+
if (typeof value !== "object" || value === null) return undefined
|
|
394
|
+
const hashes = (value as { hashes?: unknown }).hashes
|
|
395
|
+
if (!Array.isArray(hashes)) return undefined
|
|
396
|
+
const valid: ImageHash[] = []
|
|
397
|
+
for (const item of hashes) {
|
|
398
|
+
if (typeof item !== "object" || item === null) continue
|
|
399
|
+
const { scope, type, value: hex, bits } = item as Record<string, unknown>
|
|
400
|
+
if (
|
|
401
|
+
typeof scope === "string" &&
|
|
402
|
+
scope.length > 0 &&
|
|
403
|
+
typeof type === "string" &&
|
|
404
|
+
type.length > 0 &&
|
|
405
|
+
typeof hex === "string" &&
|
|
406
|
+
HEX_VALUE.test(hex)
|
|
407
|
+
) {
|
|
408
|
+
// `bits` always defaults to the hex length (4 bits per char) so
|
|
409
|
+
// consumers can classify perceptual vs exact hashes by bits.
|
|
410
|
+
valid.push({
|
|
411
|
+
scope,
|
|
412
|
+
type,
|
|
413
|
+
value: hex,
|
|
414
|
+
bits:
|
|
415
|
+
typeof bits === "number" && Number.isInteger(bits) && bits > 0
|
|
416
|
+
? bits
|
|
417
|
+
: hex.length * 4,
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (valid.length > MAX_IMAGE_HASHES_PER_RESOURCE) {
|
|
422
|
+
console.warn(
|
|
423
|
+
`[plugin-hooks] imageHashes for plugin ${pluginId} produced ${valid.length} entries — capped at ${MAX_IMAGE_HASHES_PER_RESOURCE}`,
|
|
424
|
+
)
|
|
425
|
+
valid.length = MAX_IMAGE_HASHES_PER_RESOURCE
|
|
426
|
+
}
|
|
427
|
+
return { hashes: valid }
|
|
428
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hoardodile/host — the plugin runtime host: plugin loading,
|
|
3
|
+
* capability-sandboxed child processes (Node permission model + module
|
|
4
|
+
* policy), hook invocation strategy, ResourceAPI builders, probe caching,
|
|
5
|
+
* and capability gating. The server, the CLI, and future shells (desktop,
|
|
6
|
+
* MCP) all consume this same implementation so that "what you test is
|
|
7
|
+
* what runs in production".
|
|
8
|
+
*
|
|
9
|
+
* This root entry is the app-side runtime surface — the plugin
|
|
10
|
+
* authoring API lives in `@hoardodile/sdk-server` (which re-exports the
|
|
11
|
+
* authoring subset of the contract from `@hoardodile/sdk-types`).
|
|
12
|
+
* `runPluginHook` is the one dev-only helper plugin authors use from
|
|
13
|
+
* tests (as a devDependency — never shipped in a plugin bundle).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type {
|
|
17
|
+
PluginActivation,
|
|
18
|
+
PluginActivationDeps,
|
|
19
|
+
} from "./activation.ts"
|
|
20
|
+
export { createPluginActivation } from "./activation.ts"
|
|
21
|
+
export {
|
|
22
|
+
type CreatePluginResourceAPIDeps,
|
|
23
|
+
createImportResourceAPI,
|
|
24
|
+
createPluginResourceAPI,
|
|
25
|
+
DEFAULT_PLUGIN_EXTRACT_MAX_BYTES,
|
|
26
|
+
DEFAULT_PLUGIN_EXTRACT_MAX_ENTRIES,
|
|
27
|
+
} from "./api.ts"
|
|
28
|
+
export type {
|
|
29
|
+
FoundPlugin,
|
|
30
|
+
MissingPlugin,
|
|
31
|
+
PluginDefinition,
|
|
32
|
+
PluginRegistry,
|
|
33
|
+
PluginRegistryEntry,
|
|
34
|
+
} from "./api-types.ts"
|
|
35
|
+
export type { NestedCdCache } from "./archive/index.ts"
|
|
36
|
+
export {
|
|
37
|
+
createNestedCdCache,
|
|
38
|
+
listZipEntries,
|
|
39
|
+
materializeFile,
|
|
40
|
+
} from "./archive/index.ts"
|
|
41
|
+
export type {
|
|
42
|
+
CapabilityGuard,
|
|
43
|
+
PluginCapability,
|
|
44
|
+
} from "./capability-guard.ts"
|
|
45
|
+
export { createCapabilityGuard } from "./capability-guard.ts"
|
|
46
|
+
export type { ResourceContainer } from "./container.ts"
|
|
47
|
+
export {
|
|
48
|
+
assertPluginShape,
|
|
49
|
+
createFailingPlugin,
|
|
50
|
+
definePlugin,
|
|
51
|
+
isDetected,
|
|
52
|
+
isMissed,
|
|
53
|
+
} from "./define-plugin.ts"
|
|
54
|
+
export { type PluginHookName, runPluginHook } from "./dev-runner.ts"
|
|
55
|
+
export {
|
|
56
|
+
createDirectoryResourceAPI,
|
|
57
|
+
resolveSafeImportPath,
|
|
58
|
+
} from "./directory-api.ts"
|
|
59
|
+
export { createDirectoryContainer } from "./directory-container.ts"
|
|
60
|
+
export type {
|
|
61
|
+
PluginDiscovery,
|
|
62
|
+
PluginDiscoveryDeps,
|
|
63
|
+
} from "./discovery.ts"
|
|
64
|
+
export { createPluginDiscovery, parseManifest } from "./discovery.ts"
|
|
65
|
+
export type { DomainErrorCode } from "./errors.ts"
|
|
66
|
+
export {
|
|
67
|
+
conflict,
|
|
68
|
+
DomainError,
|
|
69
|
+
invalid,
|
|
70
|
+
notFound,
|
|
71
|
+
} from "./errors.ts"
|
|
72
|
+
export type { ResourceAPIFixtureConfig } from "./fixtures.ts"
|
|
73
|
+
export { createResourceAPIFixture, stubLogger } from "./fixtures.ts"
|
|
74
|
+
export {
|
|
75
|
+
computeDHash,
|
|
76
|
+
computePHash,
|
|
77
|
+
grayStddev,
|
|
78
|
+
MIN_PERCEPTUAL_STDDEV,
|
|
79
|
+
PERCEPTUAL_HASH_KINDS,
|
|
80
|
+
PHASH_GRID,
|
|
81
|
+
} from "./hash.ts"
|
|
82
|
+
export type {
|
|
83
|
+
PluginHooks,
|
|
84
|
+
PluginHooksDeps,
|
|
85
|
+
PluginMetaHookResults,
|
|
86
|
+
} from "./hooks.ts"
|
|
87
|
+
export { createPluginHooks } from "./hooks.ts"
|
|
88
|
+
export type {
|
|
89
|
+
PluginLoader,
|
|
90
|
+
PluginLoaderDeps,
|
|
91
|
+
} from "./loader.ts"
|
|
92
|
+
export {
|
|
93
|
+
buildRegistry,
|
|
94
|
+
createPluginLoader,
|
|
95
|
+
seedPlugins,
|
|
96
|
+
} from "./loader.ts"
|
|
97
|
+
export { createNestedAwareContainer } from "./nested-view.ts"
|
|
98
|
+
export {
|
|
99
|
+
createProbeCache,
|
|
100
|
+
PLUGIN_PROBE_CACHE_MAX_ENTRIES,
|
|
101
|
+
type PluginProbeCache,
|
|
102
|
+
} from "./probe-cache.ts"
|
|
103
|
+
export type {
|
|
104
|
+
PluginAssetHandler,
|
|
105
|
+
PluginSandbox,
|
|
106
|
+
PluginSandboxConfig,
|
|
107
|
+
} from "./sandbox/host.ts"
|
|
108
|
+
export {
|
|
109
|
+
createPluginSandbox,
|
|
110
|
+
DEFAULT_SANDBOX_CONFIG,
|
|
111
|
+
PLUGIN_HOOK_HARD_TIMEOUT_MS,
|
|
112
|
+
PLUGIN_MAX_API_CALLS_PER_HOOK,
|
|
113
|
+
PLUGIN_MAX_LOGS_PER_HOOK,
|
|
114
|
+
PLUGIN_MAX_RESULT_BYTES,
|
|
115
|
+
PLUGIN_WATCHDOG_TIMEOUT_MS,
|
|
116
|
+
PLUGIN_WORKER_MAX_OLD_SPACE_MB,
|
|
117
|
+
PLUGIN_WORKER_MAX_RESPAWNS,
|
|
118
|
+
PLUGIN_WORKER_RESPAWN_WINDOW_MS,
|
|
119
|
+
} from "./sandbox/host.ts"
|
|
120
|
+
export { HOOK_NAMES, type HookName } from "./sandbox/protocol.ts"
|
|
121
|
+
export type {
|
|
122
|
+
PluginSettingsRow,
|
|
123
|
+
PluginSettingsStore,
|
|
124
|
+
} from "./settings-store.ts"
|
|
125
|
+
export type {
|
|
126
|
+
AudioCoverArt,
|
|
127
|
+
AudioInfo,
|
|
128
|
+
AudioTags,
|
|
129
|
+
Detection,
|
|
130
|
+
ImageInfo,
|
|
131
|
+
Logger,
|
|
132
|
+
ReadFileRange,
|
|
133
|
+
ResourceAPI,
|
|
134
|
+
VideoInfo,
|
|
135
|
+
} from "./types.ts"
|