@hoardodile/sdk-types 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 (57) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +59 -0
  3. package/dist/image-variant.d.ts +90 -0
  4. package/dist/image-variant.js +115 -0
  5. package/dist/image-variant.js.map +1 -0
  6. package/dist/index.d.ts +772 -0
  7. package/dist/index.js +353 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/manifest-Dk6_xyNy.d.ts +204 -0
  10. package/dist/media-exts.d.ts +92 -0
  11. package/dist/media-exts.js +160 -0
  12. package/dist/media-exts.js.map +1 -0
  13. package/dist/plugin-asset-limits.d.ts +16 -0
  14. package/dist/plugin-asset-limits.js +13 -0
  15. package/dist/plugin-asset-limits.js.map +1 -0
  16. package/dist/plugin-capabilities.d.ts +64 -0
  17. package/dist/plugin-capabilities.js +37 -0
  18. package/dist/plugin-capabilities.js.map +1 -0
  19. package/dist/plugin.d.ts +49 -0
  20. package/dist/plugin.js +12 -0
  21. package/dist/plugin.js.map +1 -0
  22. package/dist/resource.d.ts +26 -0
  23. package/dist/resource.js +9 -0
  24. package/dist/resource.js.map +1 -0
  25. package/dist/result.d.ts +47 -0
  26. package/dist/result.js +20 -0
  27. package/dist/result.js.map +1 -0
  28. package/dist/schema.d.ts +29 -0
  29. package/dist/schema.js +124 -0
  30. package/dist/schema.js.map +1 -0
  31. package/dist/template.d.ts +67 -0
  32. package/dist/template.js +137 -0
  33. package/dist/template.js.map +1 -0
  34. package/dist/text-limits.d.ts +11 -0
  35. package/dist/text-limits.js +7 -0
  36. package/dist/text-limits.js.map +1 -0
  37. package/package.json +102 -0
  38. package/src/file-list.ts +14 -0
  39. package/src/image-variant.test.ts +140 -0
  40. package/src/image-variant.ts +234 -0
  41. package/src/index.ts +115 -0
  42. package/src/manifest.ts +186 -0
  43. package/src/media-exts.ts +245 -0
  44. package/src/plugin-asset-limits.ts +23 -0
  45. package/src/plugin-asset.ts +127 -0
  46. package/src/plugin-capabilities.ts +91 -0
  47. package/src/plugin-definition.test.ts +117 -0
  48. package/src/plugin-definition.ts +902 -0
  49. package/src/plugin.ts +54 -0
  50. package/src/read-range.ts +12 -0
  51. package/src/resource.ts +28 -0
  52. package/src/result.test.ts +64 -0
  53. package/src/result.ts +73 -0
  54. package/src/schema.ts +29 -0
  55. package/src/template.test.ts +116 -0
  56. package/src/template.ts +199 -0
  57. package/src/text-limits.ts +11 -0
@@ -0,0 +1,902 @@
1
+ /**
2
+ * The plugin definition contract — the single source of truth shared by
3
+ * the authoring SDK (`@hoardodile/sdk-server`), the runtime host
4
+ * (`@hoardodile/host`) and the worker sandbox. Everything here is pure
5
+ * TypeScript with no node or DOM dependencies, so the same contract
6
+ * serves browser-facing packages and node runtimes alike.
7
+ */
8
+ import type { MediaKind } from "./media-exts.ts"
9
+ import { extToMime, mimeToKind } from "./media-exts.ts"
10
+ import type {
11
+ PluginAssetDeleteResult,
12
+ PluginDownloadRequest,
13
+ PluginDownloadResult,
14
+ } from "./plugin-asset.ts"
15
+ import { pluginAssetError } from "./plugin-asset.ts"
16
+ import type { ReadFileRange } from "./read-range.ts"
17
+ import type { Result } from "./result.ts"
18
+
19
+ export type { MediaKind }
20
+
21
+ /**
22
+ * Schema contract shared between server and web plugin APIs.
23
+ * Declared once per plugin and used to type both `definePlugin` and
24
+ * `WebPluginAPI`.
25
+ */
26
+ export interface PluginSchema {
27
+ readonly file?: unknown
28
+ readonly sourceMeta?: unknown
29
+ readonly searchMeta?: unknown
30
+ /**
31
+ * Plugin-defined payload the `detect` hook may carry on a
32
+ * successful match. The host keeps the last payload and exposes it
33
+ * to the plugin's other hooks as `api.context.detect` — classify
34
+ * once in `detect` instead of rescanning in every hook. Declaring
35
+ * this slot types the context; hooks must still handle the absent
36
+ * case (`undefined`: fresh worker, or detect never matched).
37
+ */
38
+ readonly detect?: unknown
39
+ /**
40
+ * Plugin-defined anchor location data: the payload carried inside the
41
+ * wire {@link AnchorData} envelope (see {@link anchorData}). Outgoing
42
+ * anchors are typed by this slot and passed raw (e.g.
43
+ * `createMessage({ anchor: { page } })`); incoming anchor data is
44
+ * validated by the plugin's `decodeAnchor` (see `definePluginAPI` in
45
+ * `@hoardodile/sdk-react`).
46
+ */
47
+ readonly anchor?: unknown
48
+ }
49
+
50
+ /**
51
+ * Server plugin detection result: the shared result vocabulary, where a
52
+ * match carries the schema's `detect` payload (when one is declared)
53
+ * and a miss carries its reasons. Plugins may return the literal
54
+ * `{ ok: true } as const` / `{ ok: false, reasons }` shapes directly,
55
+ * or use `ok()`/`err({ reasons })` from the result module.
56
+ *
57
+ * `TPayload` is the plugin's declared `detect` slot — the payload
58
+ * spread onto a match is checked against it at compile time, so a
59
+ * classification that drifts from the schema fails to build.
60
+ */
61
+ export type Detection<TPayload extends object = object> = Result<
62
+ TPayload,
63
+ { readonly reasons: readonly string[] }
64
+ >
65
+
66
+ /** Structured logger scoped to a single plugin. */
67
+ export type Logger = {
68
+ info(message: string, data?: Record<string, unknown>): void
69
+ warn(message: string, data?: Record<string, unknown>): void
70
+ error(message: string, data?: Record<string, unknown>): void
71
+ }
72
+
73
+ /** Image probe payload. */
74
+ export type ImageInfo = {
75
+ readonly width?: number
76
+ readonly height?: number
77
+ }
78
+
79
+ /** Video probe payload. */
80
+ export type VideoInfo = {
81
+ readonly width?: number
82
+ readonly height?: number
83
+ readonly durationMs?: number
84
+ }
85
+
86
+ /**
87
+ * Embedded container tags carried by an audio file (ID3, Vorbis
88
+ * comments, MP4 metadata atoms). Every field is optional — untagged
89
+ * files are normal.
90
+ */
91
+ export type AudioTags = {
92
+ readonly title?: string
93
+ readonly artist?: string
94
+ readonly album?: string
95
+ }
96
+
97
+ /**
98
+ * Embedded artwork carried by an audio file (ID3 APIC, FLAC PICTURE,
99
+ * MP4 `covr`). Its presence is the signal that the host can extract a
100
+ * real cover; the dimensions come from the same probe, so callers can
101
+ * pre-size the cover slot without decoding the picture.
102
+ */
103
+ export type AudioCoverArt = {
104
+ readonly width?: number
105
+ readonly height?: number
106
+ }
107
+
108
+ /**
109
+ * Audio probe payload. Any field can be absent when the container does
110
+ * not report it.
111
+ */
112
+ export type AudioInfo = {
113
+ readonly durationMs?: number
114
+ /** Codec name of the first audio stream, e.g. `"mp3"`, `"flac"`. */
115
+ readonly codec?: string
116
+ /** Container bit rate in bits per second. */
117
+ readonly bitRate?: number
118
+ /** Sample rate of the first audio stream, in Hz. */
119
+ readonly sampleRate?: number
120
+ /** Channel count of the first audio stream. */
121
+ readonly channels?: number
122
+ /** Present only when the file embeds artwork. */
123
+ readonly coverArt?: AudioCoverArt
124
+ readonly tags?: AudioTags
125
+ }
126
+
127
+ /**
128
+ * What a file's bytes say it is. Produced by {@link ResourceAPI.sniff}.
129
+ *
130
+ * `source` records who answered: `"magic"` means the file's own
131
+ * signature was recognized (authoritative), `"extension"` means the
132
+ * content carried no signature and the filename was used instead — the
133
+ * normal outcome for text-based formats, which have no magic bytes.
134
+ *
135
+ * `kind` is provisional for container formats that can hold either
136
+ * audio or video (Ogg, Matroska, ISO-BMFF); {@link ResourceAPI.probe}
137
+ * overrides it with the stream layout actually found in the file.
138
+ */
139
+ export type FileType = {
140
+ /** Canonical MIME type, e.g. `"image/jpeg"`. */
141
+ readonly mime: string
142
+ /** Canonical extension for {@link mime}, with leading dot. */
143
+ readonly ext: string
144
+ readonly kind: MediaKind
145
+ readonly source: "magic" | "extension"
146
+ }
147
+
148
+ /**
149
+ * Everything one media probe pass can say about a file, discriminated
150
+ * by the family the content really belongs to — the shape every
151
+ * mainstream prober uses (ffprobe's `format` + `streams`, sharp's
152
+ * `metadata()`, Tika's `MediaType`).
153
+ *
154
+ * `other` is a successful answer: the file was identified and is not
155
+ * decodable media (text, documents, archives). `unknown` is the failure
156
+ * branch and always carries a reason, so "this host has no probe
157
+ * backend" is never confused with "this file is not an image":
158
+ *
159
+ * - `unsupported` — identified, but no backend decodes this format
160
+ * - `unavailable` — the host wired no probe implementation (raw
161
+ * directory APIs and test fixtures)
162
+ * - `failed` — a backend ran and could not decode the bytes
163
+ */
164
+ export type ProbeResult =
165
+ | ({
166
+ readonly kind: "image"
167
+ readonly mime: string
168
+ /** Multi-frame source: animated GIF / WebP / APNG / AVIF. */
169
+ readonly animated: boolean
170
+ } & ImageInfo)
171
+ | ({ readonly kind: "video"; readonly mime: string } & VideoInfo)
172
+ | ({ readonly kind: "audio"; readonly mime: string } & AudioInfo)
173
+ | { readonly kind: "other"; readonly mime: string }
174
+ | {
175
+ readonly kind: "unknown"
176
+ readonly reason: "unsupported" | "unavailable" | "failed"
177
+ }
178
+
179
+ /**
180
+ * Perceptual hash kinds the host can compute for an image file.
181
+ * `dhash` (difference hash) and `phash` (DCT-based perceptual hash)
182
+ * are 64-bit similarity hashes compared by Hamming distance;
183
+ * `sha256` is an exact byte hash. Animated images hash their first
184
+ * frame. Plugins decide which kinds to request and which files to
185
+ * hash — the host only provides the computation.
186
+ */
187
+ export const IMAGE_HASH_KINDS = ["sha256", "dhash", "phash"] as const
188
+ export type ImageHashKind = (typeof IMAGE_HASH_KINDS)[number]
189
+
190
+ /**
191
+ * One content hash of a resource file, produced by the plugin's
192
+ * `imageHashes` hook. `scope` is the archive-relative file path,
193
+ * `type` the hash kind (`sha256`/`dhash`/`phash` or a plugin-defined
194
+ * extension), `value` the lowercase hex digest. A resource may expose
195
+ * several hashes (per file × per kind) or none.
196
+ */
197
+ export type ImageHash = {
198
+ readonly scope: string
199
+ readonly type: string
200
+ readonly value: string
201
+ /** Bit length of the hash; required for perceptual kinds. */
202
+ readonly bits?: number
203
+ }
204
+
205
+ /** Result of the `imageHashes` hook: hashes per file, possibly empty. */
206
+ export type ImageHashesResult = {
207
+ readonly hashes: readonly ImageHash[]
208
+ }
209
+
210
+ /**
211
+ * One file inside a container entry (zip/tar) as listed (or extracted)
212
+ * by the plugin API. `path` is the entry's path inside the archive;
213
+ * dimensions are present when the host probed the entry (image
214
+ * backends) — a listing-only result carries no dimensions.
215
+ */
216
+ export type ArchiveExtractionEntry = {
217
+ readonly path: string
218
+ readonly sizeBytes: number
219
+ readonly kind: MediaKind
220
+ readonly width?: number
221
+ readonly height?: number
222
+ readonly animated?: boolean
223
+ }
224
+
225
+ /**
226
+ * A container listing without materialization — the cheap counterpart of
227
+ * {@link ResourceAPI.extractArchive}. Carries entry names, sizes and
228
+ * kinds only; no dimensions (probing those requires the bytes).
229
+ */
230
+ export type ContainerListing = {
231
+ readonly entries: readonly ArchiveExtractionEntry[]
232
+ }
233
+
234
+ /**
235
+ * Result of {@link ResourceAPI.extractArchive}: the materialized
236
+ * entries of a container entry. A completed extraction is marked by the
237
+ * host's `index.json` manifest; extraction always writes the cache (the
238
+ * host's `local/cache` is derived data, writable in every view mode).
239
+ */
240
+ export type ArchiveExtraction = {
241
+ readonly entries: readonly ArchiveExtractionEntry[]
242
+ }
243
+
244
+ /**
245
+ * Resource-scoped API available to every plugin hook. All paths are
246
+ * relative to the resource's source directory; the host resolves
247
+ * absolute paths transparently.
248
+ *
249
+ * `TSchema` types the injected session context (`context.detect`); the
250
+ * default keeps the API compatible with code that never reads it.
251
+ */
252
+ export type ResourceAPI<TSchema extends PluginSchema = PluginSchema> = {
253
+ /** Write an informational log entry. */
254
+ readonly logInfo: (message: string, data?: Record<string, unknown>) => void
255
+ /** Write a warning log entry. */
256
+ readonly logWarn: (message: string, data?: Record<string, unknown>) => void
257
+ /** Write an error log entry. */
258
+ readonly logError: (message: string, data?: Record<string, unknown>) => void
259
+ /**
260
+ * List all regular-file names (flat list), in canonical display
261
+ * order: the resource's explicit upload order when one exists (the
262
+ * host's `.order` manifest), the natural name sort otherwise.
263
+ * Plugins that need their own ordering should sort explicitly.
264
+ *
265
+ * This is the raw name list — the `listFiles` hook of the plugin
266
+ * definition turns it into typed file entries.
267
+ */
268
+ readonly listFileNames: () => Promise<readonly string[]>
269
+ /**
270
+ * Read a regular file relative to the resource root.
271
+ *
272
+ * Without `range` the whole file is returned; hosts may reject
273
+ * oversized full reads — pass a range (or use `readFileChunks` from
274
+ * `@hoardodile/sdk-server/helpers`) for large files.
275
+ *
276
+ * Container addressing: a path of the form `outer!inner` reads the
277
+ * file *inside* a zip/tar entry (e.g. `manga.cbz!Chapter 1/001.jpg`)
278
+ * — the host streams the decompressed bytes. When `outer` is not a
279
+ * container, or the inner entry is absent, the whole path is treated
280
+ * as a literal filename.
281
+ */
282
+ readonly readFile: (
283
+ path: string,
284
+ range?: ReadFileRange,
285
+ ) => Promise<Uint8Array>
286
+ /**
287
+ * Return the byte size of `path` without reading the file contents.
288
+ * Resolves to `undefined` when the file does not exist or the artifact
289
+ * is not yet committed. Supports container addressing (`outer!inner`).
290
+ */
291
+ readonly statFile: (
292
+ path: string,
293
+ ) => Promise<{ readonly sizeBytes: number } | undefined>
294
+ /**
295
+ * Batch {@link statFile}: resolves every path in one host round-trip
296
+ * (positions preserved). Prefer this over a per-file fan-out of
297
+ * `statFile` when statting a whole archive — one RPC instead of N.
298
+ */
299
+ readonly statFiles: (
300
+ paths: readonly string[],
301
+ ) => Promise<readonly ({ readonly sizeBytes: number } | undefined)[]>
302
+ /**
303
+ * Identify the file at `path` from its content: magic-byte
304
+ * detection, falling back to the extension only for formats that
305
+ * carry no signature (text, subtitles). Resolves to `undefined` when
306
+ * neither can name the file. Supports container addressing.
307
+ *
308
+ * This is the cheap call — it reads a small header window, never
309
+ * decodes. Use it to route work; use {@link probe} when you need
310
+ * dimensions, duration or stream details.
311
+ */
312
+ readonly sniff: (path: string) => Promise<FileType | undefined>
313
+ /**
314
+ * Decode the media metadata of `path` in one pass, routed by
315
+ * {@link sniff} rather than by the filename: images resolve through
316
+ * sharp, audio and video through ffprobe (which also settles
317
+ * ambiguous containers — an `.ogg` holding only audio streams comes
318
+ * back as `kind: "audio"`). Supports container addressing.
319
+ *
320
+ * Always resolves, never rejects. Non-media files answer
321
+ * `{ kind: "other" }`; the `unknown` branch carries a `reason` that
322
+ * distinguishes "no backend wired" (`unavailable`, what raw
323
+ * directory APIs and fixtures return) from a real decode failure.
324
+ */
325
+ readonly probe: (path: string) => Promise<ProbeResult>
326
+ /**
327
+ * Stream-hash the file at `path` (any file kind). Rejects when the
328
+ * file is missing or the read fails; the host streams the entry so
329
+ * arbitrarily large files are safe. Supports container addressing.
330
+ */
331
+ readonly hashBytes: (path: string, algo: "md5" | "sha256") => Promise<string>
332
+ /**
333
+ * Compute the requested hashes of the image at `path` in one pass:
334
+ * `sha256` from the raw bytes, `dhash`/`phash` from a decoded
335
+ * grayscale rendition (animated images use their first frame).
336
+ * Resolves to `undefined` when the file is not a decodable image;
337
+ * `kinds` names a subset of {@link IMAGE_HASH_KINDS} and the result
338
+ * carries exactly those keys. Supports container addressing.
339
+ */
340
+ readonly computeImageHashes: (
341
+ path: string,
342
+ kinds: readonly ImageHashKind[],
343
+ ) => Promise<Readonly<Record<ImageHashKind, string>> | undefined>
344
+ /**
345
+ * List the file entries of a container entry (zip/tar) without
346
+ * materializing anything — the cheap call for metadata-only needs
347
+ * (detect, card counts). Rejects when `filename` is not a supported
348
+ * container.
349
+ */
350
+ readonly listContainer: (filename: string) => Promise<ContainerListing>
351
+ /**
352
+ * Materialize the contents of a container entry (zip/tar) into the
353
+ * host's extraction cache so the browser can serve the inner files
354
+ * over plain URLs. `filename` is a literal container entry — the
355
+ * cache holds one directory per archive with the inner paths
356
+ * preserved, plus a completion manifest.
357
+ *
358
+ * Idempotent: an already-materialized archive re-lists from the
359
+ * manifest without re-extracting. Rejects when the entry is not a
360
+ * supported container, exceeds the host's byte/entry budgets, or
361
+ * when this host wires no extraction cache (test fixtures, raw
362
+ * directory APIs).
363
+ */
364
+ readonly extractArchive: (filename: string) => Promise<ArchiveExtraction>
365
+ /**
366
+ * Ensure a remote asset exists in the plugin's own vault: when
367
+ * `dest` is already present the host answers `cached: true` without
368
+ * any dialog and without touching the network; otherwise the host
369
+ * asks the user (the web app shows the consent dialog with the URL
370
+ * verbatim) and downloads on approval. The file always lands inside
371
+ * `<plugin-dir>/vault/` — `dest` is vault-relative and can never
372
+ * reach the plugin's bundled files.
373
+ *
374
+ * Gated by the manifest `download` permission; rejections carry a
375
+ * machine-readable {@link PluginAssetErrorName} in `err.name`
376
+ * (`DENIED` / `UNAVAILABLE` / `POLICY`).
377
+ */
378
+ readonly download: (
379
+ request: PluginDownloadRequest,
380
+ ) => Promise<PluginDownloadResult>
381
+ /**
382
+ * Byte size of a vault file, or `undefined` when absent. The cheap
383
+ * presence check on top of which `download` resolves cached hits.
384
+ */
385
+ readonly statAsset: (
386
+ path: string,
387
+ ) => Promise<{ readonly sizeBytes: number } | undefined>
388
+ /** Read a vault file's bytes (bounded by the same cap as {@link readFile}). */
389
+ readonly readAsset: (path: string) => Promise<Uint8Array>
390
+ /**
391
+ * Remove a vault file; idempotent (absent files answer
392
+ * `{ existed: false }`). The plugin decides the vault's own
393
+ * lifecycle — e.g. cleaning stale layouts after a plugin update.
394
+ * No user consent is required: nothing leaves the host. Directories
395
+ * and paths outside the vault are rejected (`POLICY`).
396
+ */
397
+ readonly deleteAsset: (path: string) => Promise<PluginAssetDeleteResult>
398
+ /**
399
+ * Session context injected by the host. `detect` carries the payload
400
+ * the plugin's `detect` hook returned on its last successful match
401
+ * (worker-session scope): the one-pass classification every other
402
+ * hook can build on. `undefined` when detect has not matched in this
403
+ * session — a fresh worker — so hooks must always handle the absent
404
+ * case by re-deriving.
405
+ */
406
+ readonly context: { readonly detect: TSchema["detect"] | undefined }
407
+ }
408
+
409
+ /**
410
+ * Declarative description of a content plugin. Plugins export an instance
411
+ * of this shape as their default export; the host injects the resource
412
+ * API at call time and never invokes a factory function.
413
+ */
414
+ export type PluginDefinition<TSchema extends PluginSchema = PluginSchema> = {
415
+ /**
416
+ * Detect whether this plugin applies to the current resource. A
417
+ * successful match may carry a payload — `ok({ ...shape })` — which
418
+ * the host keeps and exposes to the other hooks as
419
+ * `api.context.detect`. The payload is checked against the schema's
420
+ * `detect` slot (when one is declared).
421
+ */
422
+ readonly detect: (
423
+ api: ResourceAPI<TSchema>,
424
+ ) => Promise<Detection<TSchema["detect"] & object>>
425
+ /** Optional source metadata builder. */
426
+ readonly sourceMeta?: (
427
+ api: ResourceAPI<TSchema>,
428
+ ) => Promise<TSchema["sourceMeta"] | undefined>
429
+ /** Optional search metadata builder. */
430
+ readonly searchMeta?: (
431
+ api: ResourceAPI<TSchema>,
432
+ ) => Promise<TSchema["searchMeta"] | undefined>
433
+ /** Optional local cover source resolver. */
434
+ readonly coverLocal?: (
435
+ api: ResourceAPI<TSchema>,
436
+ ) => Promise<string | undefined>
437
+ /**
438
+ * Optional custom file list builder. Results are cached verbatim in a
439
+ * sidecar. When absent the host falls back to a bare list of source
440
+ * filenames.
441
+ */
442
+ readonly listFiles?: (
443
+ api: ResourceAPI<TSchema>,
444
+ ) => Promise<readonly TSchema["file"][]>
445
+ /**
446
+ * Optional content hashes for duplicate detection and image
447
+ * similarity. The plugin decides the policy — which files to hash
448
+ * and which kinds — by calling the API's hash primitives; a plugin
449
+ * facing image-less resources simply omits this hook. Returning
450
+ * `undefined` (or a hook error) keeps the resource's hash rows empty.
451
+ */
452
+ readonly imageHashes?: (
453
+ api: ResourceAPI<TSchema>,
454
+ ) => Promise<ImageHashesResult | undefined>
455
+ }
456
+
457
+ /** Plugin hook names the host can invoke, in contract order. */
458
+ export const HOOK_NAMES = [
459
+ "detect",
460
+ "sourceMeta",
461
+ "searchMeta",
462
+ "coverLocal",
463
+ "listFiles",
464
+ "imageHashes",
465
+ ] as const
466
+
467
+ export type HookName = (typeof HOOK_NAMES)[number]
468
+
469
+ function isAsyncFunction(value: unknown): boolean {
470
+ return (
471
+ typeof value === "function" && value.constructor.name === "AsyncFunction"
472
+ )
473
+ }
474
+
475
+ /**
476
+ * Freeze and return a plugin definition. Runs shape validation upfront so
477
+ * a malformed plugin fails at load time with a friendly message instead
478
+ * of misbehaving at hook time.
479
+ */
480
+ export function definePlugin<TSchema extends PluginSchema = PluginSchema>(
481
+ definition: PluginDefinition<TSchema>,
482
+ ): PluginDefinition<TSchema> {
483
+ assertPluginShape(definition)
484
+ return Object.freeze({ ...definition })
485
+ }
486
+
487
+ /**
488
+ * Validate that a value satisfies the structural contract of a
489
+ * {@link PluginDefinition}: only known hooks, all hooks async functions,
490
+ * `detect` required. Does NOT exercise behaviour.
491
+ */
492
+ export function assertPluginShape(
493
+ value: unknown,
494
+ ): asserts value is PluginDefinition {
495
+ if (typeof value !== "object" || value === null) {
496
+ throw new Error("PluginDefinition: expected an object with hook functions")
497
+ }
498
+
499
+ const definition = value as Record<string, unknown>
500
+
501
+ const knownHooks = new Set<string>(HOOK_NAMES)
502
+ const unknown = Object.keys(definition).filter((key) => !knownHooks.has(key))
503
+ if (unknown.length > 0) {
504
+ throw new Error(
505
+ `PluginDefinition: unknown hook(s) ${unknown.map((k) => `"${k}"`).join(", ")} — expected one of: ${HOOK_NAMES.join(", ")}`,
506
+ )
507
+ }
508
+
509
+ for (const hook of HOOK_NAMES) {
510
+ const entry = definition[hook]
511
+ if (entry === undefined) {
512
+ if (hook === "detect") {
513
+ throw new Error("PluginDefinition: missing detect()")
514
+ }
515
+ continue
516
+ }
517
+ if (!isAsyncFunction(entry)) {
518
+ const kind =
519
+ typeof entry === "function" ? "a synchronous function" : typeof entry
520
+ throw new Error(
521
+ `PluginDefinition: "${hook}" must be an async function (got ${kind}) — hooks may do heavy work and the host awaits every hook, so declare it with \`async\`.`,
522
+ )
523
+ }
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Convenience wrapper that builds a failing plugin definition. Used by
529
+ * the host when a plugin directory is missing or its main.js cannot be
530
+ * loaded.
531
+ */
532
+ export function createFailingPlugin(
533
+ reasons: readonly string[],
534
+ ): PluginDefinition {
535
+ return definePlugin({
536
+ detect: async () => ({ ok: false, reasons }),
537
+ })
538
+ }
539
+
540
+ /** Type guard for the success branch of a {@link Detection}. */
541
+ export function isDetected(
542
+ detection: Detection,
543
+ ): detection is { readonly ok: true } {
544
+ return detection.ok
545
+ }
546
+
547
+ /** Type guard for the failure branch of a {@link Detection}. */
548
+ export function isMissed(
549
+ detection: Detection,
550
+ ): detection is { readonly ok: false; readonly reasons: readonly string[] } {
551
+ return !detection.ok
552
+ }
553
+
554
+ /** Declarative configuration for a {@link ResourceAPI} fixture. */
555
+ export type ResourceAPIFixtureConfig<
556
+ TSchema extends PluginSchema = PluginSchema,
557
+ > = {
558
+ /** File names returned by `listFileNames`. */
559
+ readonly files?: readonly string[]
560
+ /** File contents returned by `readFile`. */
561
+ readonly contents?: Readonly<Record<string, string | Uint8Array>>
562
+ /**
563
+ * `sniff` results keyed by file path — `{ "a.jpg": … }` matches only
564
+ * that file, keys starting with a dot match by extension suffix
565
+ * (`{ ".mp4": … }` applies to every .mp4 file, longest key wins),
566
+ * and `{ "": … }` matches every path (the usual way to express a
567
+ * default). Unconfigured paths fall back to the extension table,
568
+ * exactly like the host's extension branch.
569
+ */
570
+ readonly types?: Readonly<Record<string, FileType | undefined>>
571
+ /**
572
+ * `probe` results keyed by file path (same matching rules as
573
+ * {@link types}). Unconfigured paths mirror the host's routing:
574
+ * identified non-media answers `{ kind: "other" }`, identified media
575
+ * answers `{ kind: "unknown", reason: "unavailable" }` — the fixture
576
+ * decodes nothing, so a hook that needs real dimensions belongs in a
577
+ * sandbox test instead.
578
+ */
579
+ readonly probes?: Readonly<Record<string, ProbeResult | undefined>>
580
+ /** Stat results. A plain value is used as the default for all paths. */
581
+ readonly stats?:
582
+ | Readonly<Record<string, { readonly sizeBytes: number } | undefined>>
583
+ | { readonly sizeBytes: number }
584
+ | undefined
585
+ /** `hashBytes` results by path; a plain string is used for all paths. */
586
+ readonly byteHashes?: Readonly<Record<string, string>> | string
587
+ /**
588
+ * `computeImageHashes` results by path. A plain record is used as the
589
+ * default for all paths; absent paths resolve to `undefined`.
590
+ */
591
+ readonly imageHashes?:
592
+ | Readonly<Record<string, ImageHashesResult>>
593
+ | ImageHashesResult
594
+ /**
595
+ * `listContainer` results keyed by the archive filename. Absent
596
+ * names reject, mirroring the host's "not a supported archive" error.
597
+ */
598
+ readonly containerListings?: Readonly<Record<string, ContainerListing>>
599
+ /**
600
+ * `extractArchive` results keyed by the archive filename. Absent
601
+ * names reject, mirroring the host's "not a supported archive" error.
602
+ */
603
+ readonly extractions?: Readonly<Record<string, ArchiveExtraction>>
604
+ /**
605
+ * Vault file contents keyed by vault-relative path, backing
606
+ * `statAsset` / `readAsset` / `deleteAsset` in the fixture.
607
+ */
608
+ readonly assetFiles?: Readonly<Record<string, string | Uint8Array>>
609
+ /**
610
+ * Handler for `download`. Absent means the hosted runtime has no
611
+ * consent channel — `download` rejects with `UNAVAILABLE`, exactly
612
+ * like the CLI, workbench and offline mock hosts.
613
+ */
614
+ readonly downloadHandler?: (
615
+ request: PluginDownloadRequest,
616
+ ) => Promise<PluginDownloadResult>
617
+ /**
618
+ * Container addressing for the fixture: maps a virtual path
619
+ * (`outer!inner`) to stat/sniff/probe results, so hooks that browse
620
+ * inside archives can be tested without real archives. Matching rules
621
+ * mirror {@link types}: exact path keys, dot fragments by suffix,
622
+ * `{ "": … }` as the default.
623
+ */
624
+ readonly virtualEntries?: Readonly<Record<string, ArchiveExtractionEntry>>
625
+ /**
626
+ * Session context handed to hooks as `api.context` — mirrors the
627
+ * host injecting the payload of a prior successful `detect`. Typed
628
+ * by the schema generic when one is supplied.
629
+ */
630
+ readonly context?: { readonly detect?: TSchema["detect"] }
631
+ }
632
+
633
+ function resolveKeyed<T>(
634
+ path: string,
635
+ table: Readonly<Record<string, T | undefined>> | undefined,
636
+ ): T | undefined {
637
+ if (table === undefined) return undefined
638
+ // Keys match the path exactly, except keys that start with a dot —
639
+ // those are extension fragments matching any path ending with them
640
+ // (`.mp4` applies to every .mp4 file). The longest fragment wins so
641
+ // a shared default is never shadowed; the empty key `""` is the
642
+ // catch-all default. Plain-name keys never match by substring, so
643
+ // `"a.jpg"` cannot hijack `"ba.jpg"`.
644
+ if (Object.hasOwn(table, path)) return table[path]
645
+ let bestKey: string | undefined
646
+ let bestLen = -1
647
+ for (const key of Object.keys(table)) {
648
+ if (key.length > bestLen && key.startsWith(".") && path.endsWith(key)) {
649
+ bestKey = key
650
+ bestLen = key.length
651
+ }
652
+ }
653
+ if (bestKey !== undefined) return table[bestKey]
654
+ return table[""]
655
+ }
656
+
657
+ /** Lower-cased extension from the last dot, or `""` when there is none. */
658
+ function lastExt(path: string): string {
659
+ const dot = path.lastIndexOf(".")
660
+ return dot === -1 ? "" : path.slice(dot).toLowerCase()
661
+ }
662
+
663
+ /**
664
+ * Identify a file from its name alone: the extension branch of content
665
+ * sniffing, exposed on its own because it is also the honest answer for
666
+ * formats that carry no signature, and the shape test doubles need when
667
+ * standing in for a real {@link ResourceAPI}.
668
+ */
669
+ export function fileTypeFromName(path: string): FileType | undefined {
670
+ const ext = lastExt(path)
671
+ const mime = extToMime(ext)
672
+ if (mime === undefined) return undefined
673
+ return { mime, ext, kind: mimeToKind(mime), source: "extension" }
674
+ }
675
+
676
+ function resolveValue<T>(
677
+ path: string,
678
+ value: Readonly<Record<string, T | undefined>> | T | undefined,
679
+ defaultValue: T | undefined,
680
+ ): T | undefined {
681
+ if (value === undefined || value === null) return defaultValue
682
+ if (typeof value !== "object" || Array.isArray(value)) return value
683
+ // Matching rules mirror {@link resolveKeyed}: exact keys, dot
684
+ // fragments by suffix (longest wins), empty-key default.
685
+ const table = value as Readonly<Record<string, T | undefined>>
686
+ if (Object.hasOwn(table, path)) return table[path]
687
+ let bestKey: string | undefined
688
+ let bestLen = -1
689
+ for (const key of Object.keys(table)) {
690
+ if (key.length > bestLen && key.startsWith(".") && path.endsWith(key)) {
691
+ bestKey = key
692
+ bestLen = key.length
693
+ }
694
+ }
695
+ if (bestKey !== undefined) return table[bestKey]
696
+ return table[""] ?? defaultValue
697
+ }
698
+
699
+ function virtualStat(
700
+ path: string,
701
+ table:
702
+ | Readonly<Record<string, ArchiveExtractionEntry | undefined>>
703
+ | undefined,
704
+ ): { readonly sizeBytes: number } | undefined {
705
+ const entry = resolveKeyed(path, table)
706
+ return entry === undefined ? undefined : { sizeBytes: entry.sizeBytes }
707
+ }
708
+
709
+ function virtualType(
710
+ path: string,
711
+ table:
712
+ | Readonly<Record<string, ArchiveExtractionEntry | undefined>>
713
+ | undefined,
714
+ ): FileType | undefined {
715
+ if (resolveKeyed(path, table) === undefined) return undefined
716
+ return fileTypeFromName(path.slice(path.lastIndexOf("!") + 1))
717
+ }
718
+
719
+ /**
720
+ * Create a mutable {@link ResourceAPI} fixture driven by a declarative
721
+ * config. No filesystem involved — the standard way to unit-test plugin
722
+ * hooks.
723
+ *
724
+ * Pass the plugin's schema as the generic
725
+ * (`createResourceAPIFixture<MySchema>()`) so the returned api carries
726
+ * the typed session context — the same shape schema-typed hooks receive
727
+ * from the host.
728
+ */
729
+ export function createResourceAPIFixture<
730
+ TSchema extends PluginSchema = PluginSchema,
731
+ >(
732
+ initialConfig: ResourceAPIFixtureConfig<TSchema> = {},
733
+ ): {
734
+ readonly api: ResourceAPI<TSchema>
735
+ readonly setConfig: (next: ResourceAPIFixtureConfig<TSchema>) => void
736
+ } {
737
+ let config: ResourceAPIFixtureConfig<TSchema> = initialConfig
738
+
739
+ function setConfig(next: ResourceAPIFixtureConfig<TSchema>): void {
740
+ config = next
741
+ }
742
+
743
+ const api: ResourceAPI<TSchema> = {
744
+ logInfo() {},
745
+ logWarn() {},
746
+ logError() {},
747
+ context: { detect: config.context?.detect },
748
+ async listFileNames() {
749
+ return config.files ?? []
750
+ },
751
+ async readFile(path, range) {
752
+ const content = config.contents?.[path]
753
+ if (content === undefined) {
754
+ throw new Error(`ResourceAPIFixture: no content for "${path}"`)
755
+ }
756
+ const bytes =
757
+ typeof content === "string"
758
+ ? new TextEncoder().encode(content)
759
+ : content
760
+ if (range === undefined) return bytes
761
+ // Mirrors host semantics: the range is clamped to the content size.
762
+ return bytes.slice(range.start ?? 0, range.end)
763
+ },
764
+ async statFile(path) {
765
+ return (
766
+ resolveValue(path, config.stats, undefined) ??
767
+ virtualStat(path, config.virtualEntries)
768
+ )
769
+ },
770
+ async statFiles(paths) {
771
+ return Promise.all(
772
+ paths.map(
773
+ (path) =>
774
+ resolveValue(path, config.stats, undefined) ??
775
+ virtualStat(path, config.virtualEntries),
776
+ ),
777
+ )
778
+ },
779
+ async sniff(path) {
780
+ return (
781
+ resolveKeyed(path, config.types) ??
782
+ fileTypeFromName(path) ??
783
+ virtualType(path, config.virtualEntries)
784
+ )
785
+ },
786
+ async probe(path) {
787
+ const configured = resolveKeyed(path, config.probes)
788
+ if (configured !== undefined) return configured
789
+ // Unconfigured paths mirror the host's own routing: a file the
790
+ // fixture cannot identify is unsupported, an identified
791
+ // non-media file answers `other`, and identified media needs a
792
+ // real decode the fixture has no backend for.
793
+ const type =
794
+ resolveKeyed(path, config.types) ??
795
+ fileTypeFromName(path) ??
796
+ virtualType(path, config.virtualEntries)
797
+ if (type === undefined) return { kind: "unknown", reason: "unsupported" }
798
+ if (type.kind === "other") return { kind: "other", mime: type.mime }
799
+ const virtual = resolveKeyed(path, config.virtualEntries)
800
+ if (virtual !== undefined && type.kind === "image") {
801
+ return {
802
+ kind: "image",
803
+ mime: type.mime,
804
+ width: virtual.width,
805
+ height: virtual.height,
806
+ animated: virtual.animated ?? false,
807
+ }
808
+ }
809
+ return { kind: "unknown", reason: "unavailable" }
810
+ },
811
+ async hashBytes(path) {
812
+ const value = resolveValue(path, config.byteHashes, undefined)
813
+ if (value === undefined) {
814
+ throw new Error(`ResourceAPIFixture: no byte hash for "${path}"`)
815
+ }
816
+ return value
817
+ },
818
+ async computeImageHashes(path, kinds) {
819
+ const result = resolveValue(path, config.imageHashes, undefined)
820
+ if (result === undefined) return undefined
821
+ const hashes: Record<string, string> = {}
822
+ for (const entry of result.hashes) {
823
+ if ((kinds as readonly string[]).includes(entry.type)) {
824
+ hashes[entry.type] = entry.value
825
+ }
826
+ }
827
+ return hashes as Record<ImageHashKind, string>
828
+ },
829
+ async extractArchive(filename) {
830
+ const configured = config.extractions?.[filename]
831
+ if (configured === undefined) {
832
+ throw new Error(
833
+ `ResourceAPIFixture: no extraction configured for "${filename}"`,
834
+ )
835
+ }
836
+ return configured
837
+ },
838
+ async listContainer(filename) {
839
+ const configured = config.containerListings?.[filename]
840
+ if (configured === undefined) {
841
+ throw new Error(
842
+ `ResourceAPIFixture: no container listing configured for "${filename}"`,
843
+ )
844
+ }
845
+ return configured
846
+ },
847
+ async download(request) {
848
+ if (config.downloadHandler === undefined) {
849
+ throw pluginAssetError(
850
+ "UNAVAILABLE",
851
+ "ResourceAPIFixture: no download handler configured",
852
+ )
853
+ }
854
+ return config.downloadHandler(request)
855
+ },
856
+ async statAsset(path) {
857
+ const content = resolveValue(path, config.assetFiles, undefined)
858
+ if (content === undefined) return undefined
859
+ return { sizeBytes: fixtureContentBytes(content).byteLength }
860
+ },
861
+ async readAsset(path) {
862
+ const content = resolveValue(path, config.assetFiles, undefined)
863
+ if (content === undefined) {
864
+ throw new Error(`ResourceAPIFixture: no vault file "${path}"`)
865
+ }
866
+ return fixtureContentBytes(content)
867
+ },
868
+ async deleteAsset(path) {
869
+ const table = config.assetFiles
870
+ if (table === undefined || !Object.hasOwn(table, path)) {
871
+ return { existed: false }
872
+ }
873
+ // Fixture config is immutable in spirit — a delete is simulated
874
+ // by copying with the entry removed, so the next call sees it gone.
875
+ const next: Record<string, string | Uint8Array> = {}
876
+ for (const [key, value] of Object.entries(table)) {
877
+ if (key !== path) next[key] = value
878
+ }
879
+ config = { ...config, assetFiles: next }
880
+ return { existed: true }
881
+ },
882
+ }
883
+
884
+ return { api, setConfig }
885
+ }
886
+
887
+ /** Convert a fixture content entry (string or bytes) to `Uint8Array`. */
888
+ function fixtureContentBytes(content: string | Uint8Array): Uint8Array {
889
+ return typeof content === "string"
890
+ ? new TextEncoder().encode(content)
891
+ : content
892
+ }
893
+
894
+ /** Return a minimal {@link Logger} for tests. */
895
+ export function stubLogger(overrides?: Partial<Logger>): Logger {
896
+ return {
897
+ info() {},
898
+ warn() {},
899
+ error() {},
900
+ ...overrides,
901
+ }
902
+ }