@hoardodile/host 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +73 -0
  3. package/dist/chunks/worker-entry.mjs +422 -0
  4. package/dist/container-fKxefO7p.d.ts +58 -0
  5. package/dist/contract/index.d.ts +49 -0
  6. package/dist/contract/index.js +187 -0
  7. package/dist/contract/index.js.map +1 -0
  8. package/dist/hoard/index.d.ts +754 -0
  9. package/dist/hoard/index.js +1939 -0
  10. package/dist/hoard/index.js.map +1 -0
  11. package/dist/image-Dx2Be3Bj.d.ts +168 -0
  12. package/dist/index.d.ts +788 -0
  13. package/dist/index.js +3311 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/media/index.d.ts +96 -0
  16. package/dist/media/index.js +152 -0
  17. package/dist/media/index.js.map +1 -0
  18. package/dist/probe/index.d.ts +113 -0
  19. package/dist/probe/index.js +454 -0
  20. package/dist/probe/index.js.map +1 -0
  21. package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
  22. package/dist/render/index.d.ts +125 -0
  23. package/dist/render/index.js +430 -0
  24. package/dist/render/index.js.map +1 -0
  25. package/package.json +103 -0
  26. package/src/__testutils__/zip-fixtures.ts +139 -0
  27. package/src/activation.ts +116 -0
  28. package/src/api-types.ts +60 -0
  29. package/src/api.test.ts +564 -0
  30. package/src/api.ts +501 -0
  31. package/src/archive/7z.test.ts +222 -0
  32. package/src/archive/7z.ts +261 -0
  33. package/src/archive/archive-ops.test.ts +654 -0
  34. package/src/archive/extract-archive.test.ts +237 -0
  35. package/src/archive/extract-archive.ts +534 -0
  36. package/src/archive/extract.ts +693 -0
  37. package/src/archive/format.ts +49 -0
  38. package/src/archive/index.ts +116 -0
  39. package/src/archive/listing.ts +121 -0
  40. package/src/archive/materialize.ts +88 -0
  41. package/src/archive/name-decode.test.ts +34 -0
  42. package/src/archive/name-decode.ts +287 -0
  43. package/src/archive/nested-cd-cache.ts +55 -0
  44. package/src/archive/nested-entry.test.ts +194 -0
  45. package/src/archive/nested-entry.ts +254 -0
  46. package/src/archive/pack.ts +40 -0
  47. package/src/archive/zip-entries.test.ts +56 -0
  48. package/src/archive/zip-entries.ts +336 -0
  49. package/src/capability-guard.ts +73 -0
  50. package/src/container.ts +62 -0
  51. package/src/contract/container-fixture.ts +74 -0
  52. package/src/contract/index.ts +10 -0
  53. package/src/contract/suite.test.ts +101 -0
  54. package/src/contract/suite.ts +205 -0
  55. package/src/define-plugin.test.ts +53 -0
  56. package/src/define-plugin.ts +12 -0
  57. package/src/dev-runner.test.ts +98 -0
  58. package/src/dev-runner.ts +73 -0
  59. package/src/directory-api.test.ts +229 -0
  60. package/src/directory-api.ts +42 -0
  61. package/src/directory-container.ts +141 -0
  62. package/src/discovery.ts +295 -0
  63. package/src/errors.ts +74 -0
  64. package/src/fixtures.ts +8 -0
  65. package/src/hash.test.ts +301 -0
  66. package/src/hash.ts +167 -0
  67. package/src/hoard/dir-size.ts +58 -0
  68. package/src/hoard/index.ts +84 -0
  69. package/src/hoard/migrate-script.test.ts +539 -0
  70. package/src/hoard/order-manifest.test.ts +115 -0
  71. package/src/hoard/order-manifest.ts +131 -0
  72. package/src/hoard/paths.test.ts +226 -0
  73. package/src/hoard/paths.ts +569 -0
  74. package/src/hoard/plugin-vault.test.ts +160 -0
  75. package/src/hoard/plugin-vault.ts +233 -0
  76. package/src/hoard/sanitize.test.ts +149 -0
  77. package/src/hoard/sanitize.ts +230 -0
  78. package/src/hoard/staging-dir.ts +133 -0
  79. package/src/hoard/version.test.ts +285 -0
  80. package/src/hoard/version.ts +216 -0
  81. package/src/hoard/versioned-folder-ops.ts +152 -0
  82. package/src/hoard/write-versioned.test.ts +67 -0
  83. package/src/hoard/write-versioned.ts +39 -0
  84. package/src/hooks.test.ts +528 -0
  85. package/src/hooks.ts +428 -0
  86. package/src/index.ts +135 -0
  87. package/src/loader.test.ts +442 -0
  88. package/src/loader.ts +213 -0
  89. package/src/media/index.ts +16 -0
  90. package/src/media/render-cache.ts +27 -0
  91. package/src/media/seekable.ts +35 -0
  92. package/src/media/thumb-input.test.ts +109 -0
  93. package/src/media/thumb-input.ts +194 -0
  94. package/src/nested-view.test.ts +223 -0
  95. package/src/nested-view.ts +239 -0
  96. package/src/probe/audio.test.ts +135 -0
  97. package/src/probe/audio.ts +140 -0
  98. package/src/probe/av.ts +84 -0
  99. package/src/probe/ffprobe.ts +197 -0
  100. package/src/probe/image.test.ts +251 -0
  101. package/src/probe/image.ts +336 -0
  102. package/src/probe/index.ts +54 -0
  103. package/src/probe/sniff.test.ts +72 -0
  104. package/src/probe/sniff.ts +67 -0
  105. package/src/probe/video.test.ts +41 -0
  106. package/src/probe/video.ts +116 -0
  107. package/src/probe-cache.test.ts +89 -0
  108. package/src/probe-cache.ts +65 -0
  109. package/src/render/ffmpeg.ts +78 -0
  110. package/src/render/index.ts +34 -0
  111. package/src/render/pipeline.test.ts +187 -0
  112. package/src/render/pipeline.ts +479 -0
  113. package/src/sandbox/call-timers.ts +68 -0
  114. package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
  115. package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
  116. package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
  117. package/src/sandbox/fixtures/container-plugin.mjs +8 -0
  118. package/src/sandbox/fixtures/context-plugin.mjs +19 -0
  119. package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
  120. package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
  121. package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
  122. package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
  123. package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
  124. package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
  125. package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
  126. package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
  127. package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
  128. package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
  129. package/src/sandbox/fixtures/range-plugin.mjs +9 -0
  130. package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
  131. package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
  132. package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
  133. package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
  134. package/src/sandbox/host.test.ts +619 -0
  135. package/src/sandbox/host.ts +933 -0
  136. package/src/sandbox/host.unit.test.ts +330 -0
  137. package/src/sandbox/protocol.test.ts +85 -0
  138. package/src/sandbox/protocol.ts +147 -0
  139. package/src/sandbox/sandboxed-plugin.ts +30 -0
  140. package/src/sandbox/worker-entry.mjs +422 -0
  141. package/src/seed.test.ts +102 -0
  142. package/src/seed.ts +160 -0
  143. package/src/settings-store.ts +26 -0
  144. package/src/types.ts +21 -0
@@ -0,0 +1,336 @@
1
+ import { createReadStream, type ReadStream } from "node:fs"
2
+ import { stat } from "node:fs/promises"
3
+ import { Readable } from "node:stream"
4
+ import { buffer } from "node:stream/consumers"
5
+ import { createInflateRaw } from "node:zlib"
6
+ import yauzl from "yauzl"
7
+ import { invalid } from "../errors.ts"
8
+
9
+ /**
10
+ * Central directory entry shape exposed for the read-path cache. Mirrors
11
+ * the fields yauzl surfaces, plus `dataOffset` resolved from the local
12
+ * header. `dataOffset` and `dataSize` together pinpoint the entry's raw
13
+ * bytes inside the zip file; for STORED entries those bytes are the file
14
+ * itself and can be streamed via {@link streamRange} with a
15
+ * `[start, end]` window.
16
+ *
17
+ * The central directory is parsed by yauzl (the only listing engine —
18
+ * the hand-rolled fast parser was removed; ZIP64 falls out naturally).
19
+ */
20
+ export type ZipEntry = {
21
+ readonly name: string
22
+ readonly compressionMethod: number
23
+ /** General-purpose bit 0: the entry data is encrypted. */
24
+ readonly encrypted: boolean
25
+ readonly uncompressedSize: number
26
+ readonly compressedSize: number
27
+ readonly crc32: number
28
+ readonly localHeaderOffset: number
29
+ /**
30
+ * Absolute byte offset of the entry's raw data inside the zip file.
31
+ * Computed as `localHeaderOffset + 30 + nameLen + extraLen` where
32
+ * nameLen and extraLen are read from the local file header (not the
33
+ * central directory) because they can differ between the two.
34
+ */
35
+ readonly dataOffset: number
36
+ readonly dataSize: number
37
+ readonly modifiedAt: number
38
+ }
39
+
40
+ /**
41
+ * Random-access byte view over a zip archive. File-backed archives
42
+ * (uploaded archive files and CLI fixtures) read via the filesystem;
43
+ * nested archives — a zip entry inside the container — implement the same
44
+ * range reads over the outer entry's inflated bytes. Everything in this
45
+ * module that needs the archive's bytes takes a {@link ArchiveSource},
46
+ * so the container and the plugin API resolve nested zips through one
47
+ * code path.
48
+ */
49
+ export type ArchiveSource = {
50
+ /** Total archive size in bytes. */
51
+ readonly size: number
52
+ /** Read the inclusive byte range `[start, end]`. */
53
+ readonly readRange: (start: number, end: number) => Promise<Buffer>
54
+ }
55
+
56
+ const RANGE_READ_CHUNK_BYTES = 256 * 1024
57
+
58
+ /** Wrap an on-disk zip in a {@link ArchiveSource}. */
59
+ export async function createFileArchiveSource(
60
+ zipPath: string,
61
+ ): Promise<ArchiveSource> {
62
+ const info = await stat(zipPath)
63
+ return {
64
+ size: info.size,
65
+ readRange: (start, end) => readFileRange(zipPath, start, end),
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Read the central directory of the zip behind `source`, plus enough of
71
+ * each local file header to compute `dataOffset`. Returns a flat list of
72
+ * records ready for caching. Directory entries (names ending in `/`) are
73
+ * excluded.
74
+ *
75
+ * The listing goes through yauzl over the {@link ArchiveSource} (via its
76
+ * random-access-reader bridge); yauzl handles ZIP64 and validates entry
77
+ * names while parsing. The `dataOffset` for byte-range access is
78
+ * resolved from each local file header afterwards — callers that only
79
+ * need names and sizes (e.g. the legacy-name decode truth) pass
80
+ * `{ dataOffsets: false }` so the per-entry random reads are skipped and
81
+ * `dataOffset` is left at 0.
82
+ *
83
+ * @throws DomainError `resource.archive_open_failed` when the zip cannot
84
+ * be parsed.
85
+ */
86
+ export async function listZipEntriesFromSource(
87
+ source: ArchiveSource,
88
+ opts: { readonly dataOffsets?: boolean } = {},
89
+ ): Promise<readonly ZipEntry[]> {
90
+ const zipfile = await openZipFromSource(source)
91
+ const records: ZipEntry[] = []
92
+ try {
93
+ for await (const entry of zipfile.eachEntry()) {
94
+ if (entry.fileName.endsWith("/")) continue
95
+ const dataOffset =
96
+ opts.dataOffsets === false
97
+ ? 0
98
+ : await resolveDataOffsetFromSource(
99
+ source,
100
+ entry.relativeOffsetOfLocalHeader,
101
+ )
102
+ records.push({
103
+ name: entry.fileName,
104
+ compressionMethod: entry.compressionMethod,
105
+ encrypted: entry.isEncrypted(),
106
+ uncompressedSize: entry.uncompressedSize,
107
+ compressedSize: entry.compressedSize,
108
+ crc32: entry.crc32,
109
+ localHeaderOffset: entry.relativeOffsetOfLocalHeader,
110
+ dataOffset,
111
+ dataSize: entry.compressedSize,
112
+ modifiedAt: entry.getLastModDate().getTime(),
113
+ })
114
+ }
115
+ } catch (err) {
116
+ // yauzl surfaces malformed central directories as plain errors
117
+ // (e.g. encrypted stored entries with inconsistent sizes) — map
118
+ // them onto the documented taxonomy.
119
+ throw invalid(
120
+ "resource.archive_open_failed",
121
+ err instanceof Error ? err.message : "could not read archive entries",
122
+ { size: source.size },
123
+ )
124
+ } finally {
125
+ zipfile.close()
126
+ }
127
+ return records
128
+ }
129
+
130
+ /** Read the central directory of the zip at `zipPath`. */
131
+ export async function listZipEntries(
132
+ zipPath: string,
133
+ opts: { readonly dataOffsets?: boolean } = {},
134
+ ): Promise<readonly ZipEntry[]> {
135
+ return listZipEntriesFromSource(await createFileArchiveSource(zipPath), opts)
136
+ }
137
+
138
+ /**
139
+ * Stream one entry's *decompressed* bytes from `source`. STORED entries
140
+ * map to a byte-range stream of the raw data; DEFLATE entries inflate on
141
+ * the fly. Encrypted entries and other compression methods reject with a
142
+ * clear reason (there is no key to decrypt with). Callers are responsible
143
+ * for consuming or destroying the stream (a dangling stream leaks an
144
+ * inflate context).
145
+ */
146
+ export function openZipEntryStream(
147
+ source: ArchiveSource,
148
+ record: ZipEntry,
149
+ ): Readable {
150
+ if (record.encrypted) {
151
+ throw invalid(
152
+ "resource.archive_open_failed",
153
+ `zip entry "${record.name}" is encrypted — password-protected archives are not supported`,
154
+ { name: record.name },
155
+ )
156
+ }
157
+ switch (record.compressionMethod) {
158
+ case 0:
159
+ return streamRange(
160
+ source,
161
+ record.dataOffset,
162
+ record.dataOffset + record.dataSize - 1,
163
+ )
164
+ case 8: {
165
+ const raw = streamRange(
166
+ source,
167
+ record.dataOffset,
168
+ record.dataOffset + record.dataSize - 1,
169
+ )
170
+ const inflater = createInflateRaw()
171
+ raw.on("error", (err) => inflater.destroy(err))
172
+ return raw.pipe(inflater)
173
+ }
174
+ default:
175
+ throw invalid(
176
+ "resource.archive_open_failed",
177
+ `unsupported zip entry compression method ${record.compressionMethod} for "${record.name}"`,
178
+ { method: record.compressionMethod, name: record.name },
179
+ )
180
+ }
181
+ }
182
+
183
+ /** Stream the inclusive byte range `[start, end]` in bounded chunks. */
184
+ export function streamRange(
185
+ source: ArchiveSource,
186
+ start: number,
187
+ end: number,
188
+ ): Readable {
189
+ return Readable.from(
190
+ (async function* rangeChunks() {
191
+ let pos = start
192
+ while (pos <= end) {
193
+ const to = Math.min(end, pos + RANGE_READ_CHUNK_BYTES - 1)
194
+ const chunk = await source.readRange(pos, to)
195
+ if (chunk.length === 0) return
196
+ yield chunk
197
+ pos += chunk.length
198
+ }
199
+ })(),
200
+ )
201
+ }
202
+
203
+ /** Stream `zipPath` bytes in the range `[start, end]` (inclusive). */ export function readZipRange(
204
+ zipPath: string,
205
+ start: number,
206
+ end: number,
207
+ ): ReadStream {
208
+ return createReadStream(zipPath, { start, end })
209
+ }
210
+
211
+ /**
212
+ * Read an inclusive byte range from `path`. Uses a read stream so offsets
213
+ * beyond 2 GiB work on Node versions where `fs.read` position must fit in
214
+ * Int32 (older releases assert instead of throwing).
215
+ */
216
+ export async function readFileRange(
217
+ path: string,
218
+ start: number,
219
+ end: number,
220
+ ): Promise<Buffer> {
221
+ if (
222
+ !Number.isFinite(start) ||
223
+ !Number.isFinite(end) ||
224
+ !Number.isInteger(start) ||
225
+ !Number.isInteger(end) ||
226
+ start < 0 ||
227
+ end < start
228
+ ) {
229
+ throw invalid(
230
+ "resource.file_read_failed",
231
+ `invalid byte range ${start}..${end}`,
232
+ { path, start, end },
233
+ )
234
+ }
235
+ const length = end - start + 1
236
+ if (length <= 0) return Buffer.alloc(0)
237
+ return buffer(readZipRange(path, start, end))
238
+ }
239
+
240
+ function openZipFromSource(source: ArchiveSource): Promise<yauzl.ZipFile> {
241
+ const reader = new RangeReader(source)
242
+ return yauzl
243
+ .fromRandomAccessReaderPromise(reader, source.size, {
244
+ lazyEntries: true,
245
+ decodeStrings: true,
246
+ })
247
+ .catch((err: unknown) => {
248
+ reader.close(() => {})
249
+ throw invalid(
250
+ "resource.archive_open_failed",
251
+ err instanceof Error ? err.message : "could not open archive",
252
+ { size: source.size },
253
+ )
254
+ })
255
+ }
256
+
257
+ /**
258
+ * yauzl's random-access-reader bridge. yauzl 3.x reads through
259
+ * `createReadStream`/`read`, which are built on `_readStreamForRange`;
260
+ * a subclass therefore only supplies range streams (chunked, so entries
261
+ * are never buffered whole) plus `_close` (see yauzl's README).
262
+ */
263
+ class RangeReader extends yauzl.RandomAccessReader {
264
+ // Explicit field + assignment: this package ships source to Node's
265
+ // type-stripping loader (no build step), which rejects parameter
266
+ // properties.
267
+ private readonly source: ArchiveSource
268
+
269
+ constructor(source: ArchiveSource) {
270
+ super()
271
+ this.source = source
272
+ }
273
+
274
+ override _readStreamForRange(start: number, end: number): Readable {
275
+ // yauzl's `end` is exclusive (it asserts exactly `end - start`
276
+ // bytes); `streamRange` takes an inclusive end.
277
+ return streamRange(this.source, start, end - 1)
278
+ }
279
+
280
+ // yauzl's runtime contract calls `_close` even though the shipped
281
+ // types only expose the public `close` — no `override` here.
282
+ _close(callback: (err?: Error | null) => void): void {
283
+ callback(null)
284
+ }
285
+ }
286
+
287
+ async function resolveDataOffsetFromSource(
288
+ source: ArchiveSource,
289
+ localHeaderOffset: number,
290
+ ): Promise<number> {
291
+ if (localHeaderOffset + 30 > source.size) {
292
+ throw invalid(
293
+ "resource.archive_open_failed",
294
+ `truncated local file header at offset ${localHeaderOffset}`,
295
+ { localHeaderOffset },
296
+ )
297
+ }
298
+ const head = await source.readRange(localHeaderOffset, localHeaderOffset + 29)
299
+ if (head.length < 30) {
300
+ throw invalid(
301
+ "resource.archive_open_failed",
302
+ `truncated local file header at offset ${localHeaderOffset}`,
303
+ { localHeaderOffset },
304
+ )
305
+ }
306
+ const sig = head.readUInt32LE(0)
307
+ if (sig !== 0x04034b50) {
308
+ throw invalid(
309
+ "resource.archive_open_failed",
310
+ `bad local file header signature at offset ${localHeaderOffset}`,
311
+ { localHeaderOffset, sig: sig.toString(16) },
312
+ )
313
+ }
314
+ const nameLen = head.readUInt16LE(26)
315
+ const extraLen = head.readUInt16LE(28)
316
+ return localHeaderOffset + 30 + nameLen + extraLen
317
+ }
318
+
319
+ /**
320
+ * yauzl validates entry names while parsing the central directory and
321
+ * rejects traversal/absolute names with a plain `Error`. Reclassify
322
+ * those as the documented `resource.archive_invalid_entry` domain error
323
+ * so every caller observes the same error taxonomy.
324
+ */
325
+ export function normalizeZipError(err: unknown): unknown {
326
+ if (!(err instanceof Error)) return err
327
+ const message = err.message
328
+ if (
329
+ message.startsWith("invalid relative path: ") ||
330
+ message.startsWith("absolute path: ") ||
331
+ message.startsWith("invalid characters in fileName: ")
332
+ ) {
333
+ return invalid("resource.archive_invalid_entry", message, {})
334
+ }
335
+ return err
336
+ }
@@ -0,0 +1,73 @@
1
+ import type { PluginManifest } from "@hoardodile/sdk-types"
2
+ import type {
3
+ PluginCapabilityGate,
4
+ PluginCapabilityKey,
5
+ } from "@hoardodile/sdk-types/plugin-capabilities"
6
+ import { PLUGIN_CAPABILITY_GATES } from "@hoardodile/sdk-types/plugin-capabilities"
7
+ import type { PluginRegistryEntry } from "./api-types.ts"
8
+
9
+ /**
10
+ * Capabilities a plugin manifest can declare — derived from the single
11
+ * {@link PLUGIN_CAPABILITY_GATES} table (which is itself keyed by the
12
+ * manifest's `PluginPermissions` keys), so the guard can never drift.
13
+ */
14
+ export type PluginCapability = PluginCapabilityKey
15
+
16
+ /** Gate metadata a consumer may want (e.g. for tooltips). */
17
+ export type { PluginCapabilityGate }
18
+
19
+ /**
20
+ * Permission checks against a plugin's declared manifest permissions,
21
+ * reading the capability vocabulary from the shared gates table.
22
+ * The server uses these before routing any plugin-scoped work so a
23
+ * manifest that does not declare a capability cannot trigger it.
24
+ */
25
+ export type CapabilityGuard = {
26
+ /** Check whether a manifest grants the given capability. */
27
+ readonly check: (
28
+ manifest: PluginManifest,
29
+ capability: PluginCapability,
30
+ ) => boolean
31
+ /** Assert that a manifest grants the given capability; throw if not. */
32
+ readonly require: (
33
+ manifest: PluginManifest,
34
+ capability: PluginCapability,
35
+ ) => void
36
+ /** Filter entries to only those that grant the given capability. */
37
+ readonly filter: (
38
+ entries: readonly PluginRegistryEntry[],
39
+ capability: PluginCapability,
40
+ ) => readonly PluginRegistryEntry[]
41
+ }
42
+
43
+ /** Create a stateless capability guard over manifest permissions. */
44
+ export function createCapabilityGuard(): CapabilityGuard {
45
+ function check(
46
+ manifest: PluginManifest,
47
+ capability: PluginCapability,
48
+ ): boolean {
49
+ return manifest.permissions[capability] === true
50
+ }
51
+
52
+ function require(
53
+ manifest: PluginManifest,
54
+ capability: PluginCapability,
55
+ ): void {
56
+ if (!check(manifest, capability)) {
57
+ throw new Error(
58
+ `${capability} permission denied for plugin ${manifest.id}`,
59
+ )
60
+ }
61
+ }
62
+
63
+ function filter(
64
+ entries: readonly PluginRegistryEntry[],
65
+ capability: PluginCapability,
66
+ ): readonly PluginRegistryEntry[] {
67
+ return entries.filter((e) => check(e.manifest, capability))
68
+ }
69
+
70
+ return { check, require, filter }
71
+ }
72
+
73
+ export { PLUGIN_CAPABILITY_GATES }
@@ -0,0 +1,62 @@
1
+ import type { Readable } from "node:stream"
2
+
3
+ /**
4
+ * Read-only view over a resource's source data, regardless of storage
5
+ * shape — a zip archive, a raw directory, or an in-memory fixture. The
6
+ * {@link ResourceAPI} builder consumes this interface, so every backend
7
+ * (fixture / directory / zip / the server's artifact view) behaves
8
+ * identically behind a plugin hook.
9
+ *
10
+ * `relPath` values are entry names as they appear in the container (may
11
+ * contain `/`). Containers are immutable for the lifetime of a build:
12
+ * the server's per-version archives never change, and the directory
13
+ * container documents reads as snapshot-only.
14
+ */
15
+ export type ResourceContainer = {
16
+ /** List every entry name in the container. Always flat. */
17
+ readonly listEntries: () => Promise<readonly string[]>
18
+ /** Read `relPath` in full. Throws when the entry does not exist. */
19
+ readonly readEntry: (relPath: string) => Promise<Buffer>
20
+ /**
21
+ * Read the byte range `[start, end)` of `relPath` (`end` exclusive).
22
+ * The range is clamped to the entry size; an out-of-range start
23
+ * resolves to an empty buffer.
24
+ */
25
+ readonly readEntrySlice: (
26
+ relPath: string,
27
+ start: number,
28
+ end: number,
29
+ ) => Promise<Buffer>
30
+ /**
31
+ * Stream entry bytes without buffering the whole entry. `path` is
32
+ * the absolute on-disk file backing a literal entry, when there is
33
+ * one — consumers serve ranges through `createReadStream(path,
34
+ * {start, end})` windows instead of draining + discarding the
35
+ * stream's prefix.
36
+ */
37
+ readonly openEntryStream: (relPath: string) => Promise<{
38
+ readonly stream: Readable
39
+ readonly size: number
40
+ /** Modification time of the underlying file, when known. */
41
+ readonly mtimeMs?: number
42
+ /** Absolute path of the backing file, when literal and seekable. */
43
+ readonly path?: string
44
+ }>
45
+ /**
46
+ * Resolve `relPath` to its byte length. Returns `undefined` when the
47
+ * entry does not exist (the caller decides how to report that).
48
+ */
49
+ readonly resolveByteRange: (
50
+ relPath: string,
51
+ ) => Promise<{ readonly size: number } | undefined>
52
+ /**
53
+ * Optional capability: resolve `relPath` to an absolute on-disk path
54
+ * when its bytes can be read as a seekable file (no extraction, no
55
+ * decompression). Consumers like the thumbnail pipeline hand such
56
+ * paths straight to ffmpeg/libvips instead of piping streams.
57
+ * Returns `undefined` when the container cannot provide one.
58
+ */
59
+ readonly resolveSeekablePath?: (
60
+ relPath: string,
61
+ ) => Promise<string | undefined>
62
+ }
@@ -0,0 +1,74 @@
1
+ import { Readable } from "node:stream"
2
+ import type { ResourceContainer } from "../container.ts"
3
+
4
+ /** Declarative in-memory content for a {@link ResourceContainer}. */
5
+ export type ContainerFixtureConfig = {
6
+ /** Entry name → content. Names may contain `/` for nesting. */
7
+ readonly files?: Readonly<Record<string, string | Uint8Array>>
8
+ }
9
+
10
+ /**
11
+ * An in-memory {@link ResourceContainer} driven by a declarative config.
12
+ * The test double in the container contract suite: every assertion runs
13
+ * against it, the directory container and the zip container, and the
14
+ * three must agree.
15
+ */
16
+ export function createContainerFixture(
17
+ config: ContainerFixtureConfig = {},
18
+ ): ResourceContainer {
19
+ const files = config.files ?? {}
20
+
21
+ function bytesOf(relPath: string): Uint8Array | undefined {
22
+ const content = files[relPath]
23
+ if (content === undefined) return undefined
24
+ return typeof content === "string"
25
+ ? new TextEncoder().encode(content)
26
+ : content
27
+ }
28
+
29
+ return {
30
+ async listEntries(): Promise<readonly string[]> {
31
+ return Object.keys(files)
32
+ },
33
+
34
+ async readEntry(relPath: string): Promise<Buffer> {
35
+ const bytes = bytesOf(relPath)
36
+ if (bytes === undefined) {
37
+ throw new Error(`fixture container has no entry ${relPath}`)
38
+ }
39
+ return Buffer.from(bytes)
40
+ },
41
+
42
+ async readEntrySlice(
43
+ relPath: string,
44
+ start: number,
45
+ end: number,
46
+ ): Promise<Buffer> {
47
+ const bytes = bytesOf(relPath)
48
+ if (bytes === undefined) {
49
+ throw new Error(`fixture container has no entry ${relPath}`)
50
+ }
51
+ const clampedStart = Math.min(Math.max(0, start), bytes.length)
52
+ const clampedEnd = Math.min(Math.max(clampedStart, end), bytes.length)
53
+ return Buffer.from(bytes.subarray(clampedStart, clampedEnd))
54
+ },
55
+
56
+ async openEntryStream(relPath: string): Promise<{
57
+ readonly stream: Readable
58
+ readonly size: number
59
+ }> {
60
+ const bytes = bytesOf(relPath)
61
+ if (bytes === undefined) {
62
+ throw new Error(`fixture container has no entry ${relPath}`)
63
+ }
64
+ return { stream: Readable.from(bytes), size: bytes.length }
65
+ },
66
+
67
+ async resolveByteRange(
68
+ relPath: string,
69
+ ): Promise<{ readonly size: number } | undefined> {
70
+ const bytes = bytesOf(relPath)
71
+ return bytes === undefined ? undefined : { size: bytes.length }
72
+ },
73
+ }
74
+ }
@@ -0,0 +1,10 @@
1
+ export type { ContainerFixtureConfig } from "./container-fixture.ts"
2
+ export { createContainerFixture } from "./container-fixture.ts"
3
+ export type {
4
+ ContainerContractCase,
5
+ ContractBackend,
6
+ } from "./suite.ts"
7
+ export {
8
+ CONTAINER_CONTRACT_CASES,
9
+ containerContractSuite,
10
+ } from "./suite.ts"
@@ -0,0 +1,101 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { dirname, join } from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+ import { afterAll, describe, expect, test } from "vitest"
6
+ import { createPluginResourceAPI } from "../api.ts"
7
+ import { createDirectoryContainer } from "../directory-container.ts"
8
+ import { createPluginSandbox, DEFAULT_SANDBOX_CONFIG } from "../sandbox/host.ts"
9
+ import { createContainerFixture } from "./container-fixture.ts"
10
+ import { type ContractBackend, containerContractSuite } from "./suite.ts"
11
+
12
+ const tempRoots: string[] = []
13
+
14
+ function withTempDir(): string {
15
+ const dir = mkdtempSync(join(tmpdir(), "host-contract-"))
16
+ tempRoots.push(dir)
17
+ return dir
18
+ }
19
+
20
+ const FIXTURE_BACKEND: ContractBackend = {
21
+ name: "fixture",
22
+ build: async (files) =>
23
+ createPluginResourceAPI({ view: createContainerFixture({ files }) }),
24
+ }
25
+
26
+ const DIRECTORY_BACKEND: ContractBackend = {
27
+ name: "directory",
28
+ build: async (files) => {
29
+ const dir = withTempDir()
30
+ for (const [name, content] of Object.entries(files)) {
31
+ const abs = join(dir, name)
32
+ mkdirSync(dirname(abs), { recursive: true })
33
+ writeFileSync(
34
+ abs,
35
+ Buffer.from(
36
+ typeof content === "string"
37
+ ? new TextEncoder().encode(content)
38
+ : content,
39
+ ),
40
+ )
41
+ }
42
+ return createPluginResourceAPI({ view: createDirectoryContainer(dir) })
43
+ },
44
+ }
45
+
46
+ describe("container contract suite", () => {
47
+ containerContractSuite([FIXTURE_BACKEND, DIRECTORY_BACKEND])
48
+
49
+ test("sandboxed hooks see identical results across backends", async () => {
50
+ const files = { "blob.bin": Uint8Array.from([1, 2, 3, 4, 250]) }
51
+ const sandbox = createPluginSandbox({
52
+ ...DEFAULT_SANDBOX_CONFIG,
53
+ watchdogMs: 5_000,
54
+ hardTimeoutMs: 10_000,
55
+ })
56
+ try {
57
+ const mainPath = fileURLToPath(
58
+ new URL("../sandbox/fixtures/echo-plugin.mjs", import.meta.url),
59
+ )
60
+ const plugin = await sandbox.loadPlugin({
61
+ id: "echo-contract",
62
+ mainPath,
63
+ eager: false,
64
+ })
65
+ if (plugin === undefined) {
66
+ throw new Error("echo fixture failed to load")
67
+ }
68
+ const detect = plugin.detect
69
+ const sourceMeta = plugin.sourceMeta
70
+ const listFiles = plugin.listFiles
71
+ if (
72
+ detect === undefined ||
73
+ sourceMeta === undefined ||
74
+ listFiles === undefined
75
+ ) {
76
+ throw new Error("echo fixture missing hooks")
77
+ }
78
+
79
+ const outputs: unknown[] = []
80
+ for (const backend of [FIXTURE_BACKEND, DIRECTORY_BACKEND]) {
81
+ const api = await backend.build(files)
82
+ outputs.push([
83
+ await detect(api),
84
+ await sourceMeta(api),
85
+ await listFiles(api),
86
+ ])
87
+ }
88
+ for (let i = 1; i < outputs.length; i++) {
89
+ expect(outputs[i]).toEqual(outputs[0])
90
+ }
91
+ } finally {
92
+ await sandbox.disposeAll()
93
+ }
94
+ })
95
+ })
96
+
97
+ afterAll(() => {
98
+ for (const root of tempRoots) {
99
+ rmSync(root, { recursive: true, force: true })
100
+ }
101
+ })