@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,205 @@
1
+ import { afterAll, describe, expect, test } from "vitest"
2
+ import type { ResourceAPI } from "../types.ts"
3
+
4
+ /**
5
+ * Shared contract suite for the container-backed {@link ResourceAPI}
6
+ * implementations. Every backend (in-memory fixture, directory, zip
7
+ * archive, and the server's own artifact view) must behave identically:
8
+ * same entries, same bytes, same clamping, same probe semantics.
9
+ *
10
+ * Consumers build each backend from a declarative file map, then run the
11
+ * whole suite with `containerContractSuite([...])` inside a test file.
12
+ */
13
+
14
+ /** A declarative container case: entry name → content. */
15
+ export type ContainerContractCase = {
16
+ readonly name: string
17
+ readonly files: Readonly<Record<string, string | Uint8Array>>
18
+ }
19
+
20
+ export const CONTAINER_CONTRACT_CASES: readonly ContainerContractCase[] = [
21
+ { name: "empty container", files: {} },
22
+ {
23
+ name: "flat files",
24
+ files: { "a.txt": "alpha", "b.bin": "beta", "c.jpg": "gamma" },
25
+ },
26
+ {
27
+ name: "nested entry names",
28
+ files: { "dir/a.txt": "nested a", "dir/sub/b.txt": "nested b" },
29
+ },
30
+ {
31
+ name: "binary content",
32
+ files: { "blob.bin": Uint8Array.from([0, 1, 2, 250, 255, 128]) },
33
+ },
34
+ { name: "empty entry", files: { "empty.txt": "" } },
35
+ {
36
+ name: "unicode entry names",
37
+ files: { "Фото/я.jpg": "unicode" },
38
+ },
39
+ ]
40
+
41
+ export type ContractBackend = {
42
+ readonly name: string
43
+ /**
44
+ * Build a {@link ResourceAPI} from a declarative file map. Called
45
+ * once per case; must produce an independent instance each time.
46
+ */
47
+ readonly build: (
48
+ files: Readonly<Record<string, string | Uint8Array>>,
49
+ ) => Promise<ResourceAPI>
50
+ /** Optional teardown for resources created by `build` calls. */
51
+ readonly cleanup?: () => Promise<void> | void
52
+ }
53
+
54
+ function toBytes(content: string | Uint8Array): Uint8Array {
55
+ return typeof content === "string"
56
+ ? new TextEncoder().encode(content)
57
+ : content
58
+ }
59
+
60
+ async function collectManifest(api: ResourceAPI): Promise<unknown> {
61
+ const files = [...(await api.listFileNames())].sort()
62
+ const manifest: Record<string, unknown> = {}
63
+ manifest.files = files
64
+ for (const name of files) {
65
+ manifest[`stat:${name}`] = await api.statFile(name)
66
+ manifest[`read:${name}`] = Array.from(await api.readFile(name))
67
+ manifest[`range:${name}`] = Array.from(
68
+ await api.readFile(name, { start: 1, end: 3 }),
69
+ )
70
+ // Identification must agree across backends too — the sniffer
71
+ // reads through the same container abstraction.
72
+ manifest[`type:${name}`] = await api.sniff(name)
73
+ manifest[`probe:${name}`] = await api.probe(name)
74
+ }
75
+ // Batch stat: positions preserved, missing entries undefined.
76
+ const batched = await api.statFiles(files)
77
+ manifest.statAll = files.map((name, i) => ({
78
+ name,
79
+ stat: batched[i],
80
+ }))
81
+ manifest.missingStat = await api.statFile("missing-entry.bin")
82
+ manifest.missingStatAll = await api.statFiles(["missing-entry.bin"])
83
+ return manifest
84
+ }
85
+
86
+ /**
87
+ * Run the full container contract suite against the given backends.
88
+ * Must be called from within a vitest test file.
89
+ */
90
+ export function containerContractSuite(
91
+ backends: readonly ContractBackend[],
92
+ ): void {
93
+ afterAll(async () => {
94
+ await Promise.all(backends.map((b) => b.cleanup?.()))
95
+ })
96
+
97
+ describe("container contract", () => {
98
+ for (const backend of backends) {
99
+ describe(backend.name, () => {
100
+ for (const c of CONTAINER_CONTRACT_CASES) {
101
+ test(`case "${c.name}": core semantics`, async () => {
102
+ const api = await backend.build(c.files)
103
+ const names = Object.keys(c.files)
104
+
105
+ expect([...(await api.listFileNames())].sort()).toEqual(
106
+ [...names].sort(),
107
+ )
108
+
109
+ for (const name of names) {
110
+ const content = c.files[name]
111
+ if (content === undefined) continue
112
+ const expected = toBytes(content)
113
+ expect(Array.from(await api.readFile(name))).toEqual([
114
+ ...expected,
115
+ ])
116
+
117
+ const mid = await api.readFile(name, {
118
+ start: 1,
119
+ end: Math.min(3, expected.length),
120
+ })
121
+ expect(Array.from(mid)).toEqual([
122
+ ...expected.slice(1, Math.min(3, expected.length)),
123
+ ])
124
+
125
+ const past = await api.readFile(name, {
126
+ start: expected.length + 5,
127
+ end: expected.length + 9,
128
+ })
129
+ expect(past.byteLength).toBe(0)
130
+
131
+ expect(await api.statFile(name)).toEqual({
132
+ sizeBytes: expected.length,
133
+ })
134
+ }
135
+
136
+ // Missing entries: readFile rejects, statFile is undefined.
137
+ await expect(api.readFile("missing-entry.bin")).rejects.toThrow()
138
+ await expect(
139
+ api.statFile("missing-entry.bin"),
140
+ ).resolves.toBeUndefined()
141
+
142
+ // Batch stat matches the single-stats, in order.
143
+ const singleStats = names.map((n) =>
144
+ c.files[n] !== undefined
145
+ ? { sizeBytes: toBytes(c.files[n]!).length }
146
+ : undefined,
147
+ )
148
+ const batchStats = await api.statFiles(names)
149
+ expect(batchStats.length).toBe(names.length)
150
+ for (let i = 0; i < names.length; i++) {
151
+ expect(batchStats[i]).toEqual(singleStats[i])
152
+ }
153
+ await expect(api.statFiles(["missing-entry.bin"])).resolves.toEqual(
154
+ [undefined],
155
+ )
156
+
157
+ // Contract backends wire no probe implementations, so
158
+ // identified media reports the dedicated "no backend"
159
+ // reason instead of looking like a decode failure,
160
+ // while non-media entries still identify normally.
161
+ for (const name of names) {
162
+ const type = await api.sniff(name)
163
+ const probed = await api.probe(name)
164
+ if (type !== undefined && type.kind !== "other") {
165
+ expect(probed).toEqual({
166
+ kind: "unknown",
167
+ reason: "unavailable",
168
+ })
169
+ } else if (type !== undefined) {
170
+ expect(probed).toEqual({ kind: "other", mime: type.mime })
171
+ } else {
172
+ expect(probed).toEqual({
173
+ kind: "unknown",
174
+ reason: "unsupported",
175
+ })
176
+ }
177
+ }
178
+
179
+ // Neither call rejects on a missing entry.
180
+ await expect(
181
+ api.sniff("missing-entry.bin"),
182
+ ).resolves.toBeUndefined()
183
+ await expect(api.probe("missing-entry.bin")).resolves.toEqual({
184
+ kind: "unknown",
185
+ reason: "unsupported",
186
+ })
187
+ })
188
+ }
189
+ })
190
+ }
191
+
192
+ for (const c of CONTAINER_CONTRACT_CASES) {
193
+ test(`case "${c.name}": identical results across backends`, async () => {
194
+ const results = []
195
+ for (const backend of backends) {
196
+ const api = await backend.build(c.files)
197
+ results.push(await collectManifest(api))
198
+ }
199
+ for (let i = 1; i < results.length; i++) {
200
+ expect(results[i]).toEqual(results[0])
201
+ }
202
+ })
203
+ }
204
+ })
205
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { assertPluginShape, definePlugin } from "./define-plugin.ts"
3
+
4
+ /** Cast a deliberately-broken definition past the type level. */
5
+ const broken = (value: unknown) => value as Parameters<typeof definePlugin>[0]
6
+
7
+ describe("definePlugin", () => {
8
+ it("freezes a valid definition", () => {
9
+ const plugin = definePlugin({ detect: async () => ({ ok: true }) })
10
+ expect(Object.isFrozen(plugin)).toBe(true)
11
+ })
12
+
13
+ it("rejects unknown hook keys with a friendly error", () => {
14
+ expect(() =>
15
+ definePlugin(
16
+ broken({
17
+ detect: async () => ({ ok: true }),
18
+ sourceMetaa: async () => ({}),
19
+ }),
20
+ ),
21
+ ).toThrow(/unknown hook\(s\) "sourceMetaa"/)
22
+ })
23
+
24
+ it("rejects a missing detect", () => {
25
+ expect(() =>
26
+ definePlugin(broken({ sourceMeta: async () => ({}) })),
27
+ ).toThrow(/missing detect\(\)/)
28
+ })
29
+
30
+ it("rejects synchronous hooks", () => {
31
+ expect(() =>
32
+ definePlugin(broken({ detect: () => ({ ok: true }) })),
33
+ ).toThrow(/"detect" must be an async function/)
34
+ })
35
+
36
+ it("accepts every optional hook when async", () => {
37
+ expect(() =>
38
+ definePlugin({
39
+ detect: async () => ({ ok: true }),
40
+ sourceMeta: async () => ({}),
41
+ searchMeta: async () => ({}),
42
+ coverLocal: async () => undefined,
43
+ listFiles: async () => [],
44
+ }),
45
+ ).not.toThrow()
46
+ })
47
+
48
+ it("assertPluginShape narrows valid values", () => {
49
+ const value: unknown = { detect: async () => ({ ok: true }) }
50
+ assertPluginShape(value)
51
+ expect(typeof value.detect).toBe("function")
52
+ })
53
+ })
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The plugin definition factories now live in `@hoardodile/sdk-types` —
3
+ * the shared contract (see `plugin-definition.ts` there). This module
4
+ * keeps the module path so internal imports stay untouched.
5
+ */
6
+ export {
7
+ assertPluginShape,
8
+ createFailingPlugin,
9
+ definePlugin,
10
+ isDetected,
11
+ isMissed,
12
+ } from "@hoardodile/sdk-types"
@@ -0,0 +1,98 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { afterEach, beforeEach, describe, expect, test } from "vitest"
5
+ import { runPluginHook } from "./dev-runner.ts"
6
+
7
+ const FIXTURE_PLUGIN = `
8
+ export default {
9
+ async detect(api) {
10
+ const files = await api.listFileNames()
11
+ return files.some((f) => f.endsWith(".hdtpl"))
12
+ ? { ok: true }
13
+ : { ok: false, reasons: ["no .hdtpl file"] }
14
+ },
15
+ }
16
+ `
17
+
18
+ describe("runPluginHook", () => {
19
+ let rootDir: string
20
+ let dir: string
21
+ let mainPath: string
22
+
23
+ beforeEach(() => {
24
+ rootDir = mkdtempSync(join(tmpdir(), "dev-runner-test-"))
25
+ dir = join(rootDir, "resource")
26
+ mkdirSync(dir)
27
+ writeFileSync(join(dir, "chapter.hdtpl"), "tpl")
28
+ mainPath = join(rootDir, "main.js")
29
+ writeFileSync(mainPath, FIXTURE_PLUGIN)
30
+ })
31
+
32
+ afterEach(() => {
33
+ rmSync(rootDir, { recursive: true, force: true })
34
+ })
35
+
36
+ test("runs the hook with a directory-backed ResourceAPI", async () => {
37
+ const { result, durationMs } = await runPluginHook({
38
+ mainPath,
39
+ hook: "detect",
40
+ dir,
41
+ })
42
+ expect(result).toEqual({ ok: true })
43
+ expect(durationMs).toBeGreaterThanOrEqual(0)
44
+ })
45
+
46
+ test("throws a clear error when the hook is missing", async () => {
47
+ await expect(
48
+ runPluginHook({ mainPath, hook: "sourceMeta", dir }),
49
+ ).rejects.toThrow(/no "sourceMeta" hook/)
50
+ })
51
+
52
+ test("throws when the module has no default-export definition", async () => {
53
+ writeFileSync(mainPath, "export const x = 1\n")
54
+ await expect(
55
+ runPluginHook({ mainPath, hook: "detect", dir }),
56
+ ).rejects.toThrow(/default-export a plugin definition/)
57
+ })
58
+
59
+ test("runs detect first so later hooks see the payload via api.context", async () => {
60
+ writeFileSync(
61
+ mainPath,
62
+ `export default {
63
+ async detect() {
64
+ return { ok: true, shape: { hasChapter: true } }
65
+ },
66
+ async sourceMeta(api) {
67
+ return { context: api.context.detect }
68
+ },
69
+ }`,
70
+ )
71
+ const { result } = await runPluginHook({
72
+ mainPath,
73
+ hook: "sourceMeta",
74
+ dir,
75
+ })
76
+ expect(result).toEqual({ context: { shape: { hasChapter: true } } })
77
+ })
78
+
79
+ test("a failed detection leaves the context absent", async () => {
80
+ writeFileSync(
81
+ mainPath,
82
+ `export default {
83
+ async detect() {
84
+ return { ok: false, reasons: ["no match"] }
85
+ },
86
+ async sourceMeta(api) {
87
+ return { context: api.context.detect }
88
+ },
89
+ }`,
90
+ )
91
+ const { result } = await runPluginHook({
92
+ mainPath,
93
+ hook: "sourceMeta",
94
+ dir,
95
+ })
96
+ expect(result).toEqual({ context: undefined })
97
+ })
98
+ })
@@ -0,0 +1,73 @@
1
+ import { pathToFileURL } from "node:url"
2
+ import { createDirectoryResourceAPI } from "./directory-api.ts"
3
+ import type { HookName } from "./sandbox/protocol.ts"
4
+
5
+ /**
6
+ * Plugin hooks the dev runner can invoke against a directory — the
7
+ * contract-order list shared with the sandbox host and the CLI.
8
+ */
9
+ export type PluginHookName = HookName
10
+
11
+ function isRecord(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === "object" && value !== null
13
+ }
14
+
15
+ /**
16
+ * Load a built plugin `main.js` and invoke one hook with a
17
+ * directory-backed {@link ResourceAPI}. Development aid only — the plugin
18
+ * runs unsandboxed in the current process, so only run code you trust.
19
+ * Consumed from plugin tests as a devDependency; never bundled into a
20
+ * shipped plugin.
21
+ *
22
+ * Before the target hook runs, `detect` is invoked once (when the
23
+ * target is not detect itself) so hooks see the session context a real
24
+ * sandbox session would have — `api.context.detect` carries the
25
+ * detection payload. A throwing or missed detection leaves the context
26
+ * absent, exactly like a fresh worker in production.
27
+ */
28
+ export async function runPluginHook(opts: {
29
+ readonly mainPath: string
30
+ readonly hook: PluginHookName
31
+ readonly dir: string
32
+ /** Scratch dir for `extractArchive` materialization. */
33
+ readonly extractCacheDir?: string
34
+ }): Promise<{ readonly result: unknown; readonly durationMs: number }> {
35
+ const mod: unknown = await import(pathToFileURL(opts.mainPath).href)
36
+ if (!isRecord(mod) || !isRecord(mod.default)) {
37
+ throw new Error(
38
+ `Plugin at ${opts.mainPath} must default-export a plugin definition`,
39
+ )
40
+ }
41
+ const hook = mod.default[opts.hook]
42
+ if (typeof hook !== "function") {
43
+ throw new Error(
44
+ `Plugin at ${opts.mainPath} has no "${opts.hook}" hook (expected a function on the default export)`,
45
+ )
46
+ }
47
+
48
+ let detectContext: unknown
49
+ if (opts.hook !== "detect" && typeof mod.default.detect === "function") {
50
+ try {
51
+ const detected = await mod.default.detect(
52
+ createDirectoryResourceAPI(opts.dir, {
53
+ extractCacheDir: opts.extractCacheDir,
54
+ }),
55
+ )
56
+ if (isRecord(detected) && detected.ok === true) {
57
+ const { ok: _ok, ...payload } = detected
58
+ if (Object.keys(payload).length > 0) detectContext = payload
59
+ }
60
+ } catch {
61
+ // A throwing detector leaves the context absent — same as a
62
+ // fresh worker in production.
63
+ }
64
+ }
65
+
66
+ const api = createDirectoryResourceAPI(opts.dir, {
67
+ extractCacheDir: opts.extractCacheDir,
68
+ detectContext,
69
+ })
70
+ const started = performance.now()
71
+ const result: unknown = await hook(api)
72
+ return { result, durationMs: performance.now() - started }
73
+ }
@@ -0,0 +1,229 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { afterEach, beforeEach, describe, expect, test } from "vitest"
5
+ import {
6
+ createDirectoryResourceAPI,
7
+ resolveSafeImportPath,
8
+ } from "./directory-api.ts"
9
+
10
+ describe("createDirectoryResourceAPI", () => {
11
+ let rootDir: string
12
+
13
+ beforeEach(() => {
14
+ rootDir = mkdtempSync(join(tmpdir(), "directory-api-test-"))
15
+ mkdirSync(join(rootDir, "sub"))
16
+ writeFileSync(join(rootDir, "inside.txt"), "inside")
17
+ writeFileSync(join(rootDir, "sub", "nested.txt"), "nested")
18
+ })
19
+
20
+ afterEach(() => {
21
+ rmSync(rootDir, { recursive: true, force: true })
22
+ })
23
+
24
+ test("readFile allows paths inside the directory", async () => {
25
+ const api = createDirectoryResourceAPI(rootDir)
26
+ const data = await api.readFile("inside.txt")
27
+ expect(new TextDecoder().decode(data)).toBe("inside")
28
+ })
29
+
30
+ test("readFile rejects parent directory traversal", async () => {
31
+ const api = createDirectoryResourceAPI(rootDir)
32
+ await expect(api.readFile("../outside.txt")).rejects.toThrow(
33
+ "escapes import directory",
34
+ )
35
+ })
36
+
37
+ test("readFile rejects nested traversal", async () => {
38
+ const api = createDirectoryResourceAPI(rootDir)
39
+ await expect(api.readFile("sub/../../outside.txt")).rejects.toThrow(
40
+ "escapes import directory",
41
+ )
42
+ })
43
+
44
+ test("readFile rejects absolute paths", async () => {
45
+ const api = createDirectoryResourceAPI(rootDir)
46
+ await expect(api.readFile("/etc/passwd")).rejects.toThrow(
47
+ "absolute paths are not allowed",
48
+ )
49
+ })
50
+
51
+ test("readFile rejects empty paths", async () => {
52
+ const api = createDirectoryResourceAPI(rootDir)
53
+ await expect(api.readFile("")).rejects.toThrow("path is empty")
54
+ })
55
+
56
+ test("readFile rejects null bytes", async () => {
57
+ const api = createDirectoryResourceAPI(rootDir)
58
+ await expect(api.readFile("inside.txt\0extra")).rejects.toThrow("null byte")
59
+ })
60
+
61
+ test("statFile validates paths and reports real sizes", async () => {
62
+ const api = createDirectoryResourceAPI(rootDir)
63
+ await expect(api.statFile("../outside.txt")).rejects.toThrow(
64
+ "escapes import directory",
65
+ )
66
+ expect(await api.statFile("inside.txt")).toEqual({ sizeBytes: 6 })
67
+ })
68
+
69
+ test("sniffing works without probe backends", async () => {
70
+ const api = createDirectoryResourceAPI(rootDir)
71
+ // Identification only reads bytes, so the directory API answers it
72
+ // in full — decoding is what has no backend here.
73
+ expect(await api.sniff("inside.txt")).toEqual({
74
+ mime: "text/plain",
75
+ ext: ".txt",
76
+ kind: "other",
77
+ source: "extension",
78
+ })
79
+ expect(await api.probe("inside.txt")).toEqual({
80
+ kind: "other",
81
+ mime: "text/plain",
82
+ })
83
+ })
84
+
85
+ test("listFileNames stays within the directory", async () => {
86
+ const api = createDirectoryResourceAPI(rootDir)
87
+ const files = await api.listFileNames()
88
+ expect([...files].sort()).toEqual(["inside.txt", "sub/nested.txt"])
89
+ })
90
+ })
91
+
92
+ describe("createDirectoryResourceAPI listFileNames", () => {
93
+ let rootDir: string
94
+
95
+ beforeEach(() => {
96
+ rootDir = mkdtempSync(join(tmpdir(), "directory-api-list-"))
97
+ writeFileSync(join(rootDir, "page10.txt"), "")
98
+ writeFileSync(join(rootDir, "page2.txt"), "")
99
+ writeFileSync(join(rootDir, "page1.txt"), "")
100
+ writeFileSync(join(rootDir, ".hidden"), "")
101
+ writeFileSync(join(rootDir, "cover.uploading-abc123"), "")
102
+ mkdirSync(join(rootDir, ".hiddendir"))
103
+ writeFileSync(join(rootDir, ".hiddendir", "secret.txt"), "")
104
+ })
105
+
106
+ afterEach(() => {
107
+ rmSync(rootDir, { recursive: true, force: true })
108
+ })
109
+
110
+ test("skips dotfiles and .uploading- entries", async () => {
111
+ const api = createDirectoryResourceAPI(rootDir)
112
+ const files = await api.listFileNames()
113
+ expect(files.every((f) => !f.includes(".hidden"))).toBe(true)
114
+ expect(files.some((f) => f.includes(".uploading-"))).toBe(false)
115
+ })
116
+
117
+ test("sorts with localeCompare numeric ordering", async () => {
118
+ const api = createDirectoryResourceAPI(rootDir)
119
+ expect(await api.listFileNames()).toEqual([
120
+ "page1.txt",
121
+ "page2.txt",
122
+ "page10.txt",
123
+ ])
124
+ })
125
+ })
126
+
127
+ describe("createDirectoryResourceAPI order manifest", () => {
128
+ let rootDir: string
129
+
130
+ beforeEach(() => {
131
+ rootDir = mkdtempSync(join(tmpdir(), "directory-api-order-"))
132
+ writeFileSync(join(rootDir, "b.txt"), "")
133
+ writeFileSync(join(rootDir, "a.txt"), "")
134
+ writeFileSync(join(rootDir, "c.txt"), "")
135
+ })
136
+
137
+ afterEach(() => {
138
+ rmSync(rootDir, { recursive: true, force: true })
139
+ })
140
+
141
+ test("honors a valid .order manifest over the natural sort", async () => {
142
+ writeFileSync(
143
+ join(rootDir, ".order"),
144
+ JSON.stringify(["c.txt", "a.txt", "b.txt"]),
145
+ )
146
+ const api = createDirectoryResourceAPI(rootDir)
147
+ expect(await api.listFileNames()).toEqual(["c.txt", "a.txt", "b.txt"])
148
+ })
149
+
150
+ test("appends entries the manifest does not mention, naturally sorted", async () => {
151
+ writeFileSync(join(rootDir, ".order"), JSON.stringify(["c.txt"]))
152
+ const api = createDirectoryResourceAPI(rootDir)
153
+ expect(await api.listFileNames()).toEqual(["c.txt", "a.txt", "b.txt"])
154
+ })
155
+
156
+ test("ignores a manifest whose entries do not resolve", async () => {
157
+ writeFileSync(
158
+ join(rootDir, ".order"),
159
+ JSON.stringify(["gone.txt", "b.txt"]),
160
+ )
161
+ const api = createDirectoryResourceAPI(rootDir)
162
+ expect(await api.listFileNames()).toEqual(["a.txt", "b.txt", "c.txt"])
163
+ })
164
+
165
+ test("ignores a malformed stray .order in an arbitrary import dir", async () => {
166
+ writeFileSync(join(rootDir, ".order"), "not json")
167
+ const api = createDirectoryResourceAPI(rootDir)
168
+ expect(await api.listFileNames()).toEqual(["a.txt", "b.txt", "c.txt"])
169
+ })
170
+ })
171
+
172
+ describe("createDirectoryResourceAPI ranged reads", () => {
173
+ let rootDir: string
174
+
175
+ beforeEach(() => {
176
+ rootDir = mkdtempSync(join(tmpdir(), "directory-api-range-"))
177
+ writeFileSync(join(rootDir, "data.bin"), Buffer.from([1, 2, 3, 4, 250]))
178
+ })
179
+
180
+ afterEach(() => {
181
+ rmSync(rootDir, { recursive: true, force: true })
182
+ })
183
+
184
+ test("range returns the requested slice", async () => {
185
+ const api = createDirectoryResourceAPI(rootDir)
186
+ expect([...(await api.readFile("data.bin", { start: 1, end: 4 }))]).toEqual(
187
+ [2, 3, 4],
188
+ )
189
+ })
190
+
191
+ test("end defaults to file size and clamps", async () => {
192
+ const api = createDirectoryResourceAPI(rootDir)
193
+ expect([...(await api.readFile("data.bin", { start: 3 }))]).toEqual([
194
+ 4, 250,
195
+ ])
196
+ expect([
197
+ ...(await api.readFile("data.bin", { start: 0, end: 10_000 })),
198
+ ]).toEqual([1, 2, 3, 4, 250])
199
+ })
200
+
201
+ test("start past the end returns empty", async () => {
202
+ const api = createDirectoryResourceAPI(rootDir)
203
+ expect(
204
+ (await api.readFile("data.bin", { start: 100, end: 200 })).byteLength,
205
+ ).toBe(0)
206
+ })
207
+
208
+ test("full reads above the byte cap are rejected with guidance", async () => {
209
+ const api = createDirectoryResourceAPI(rootDir, { maxReadFileBytes: 4 })
210
+ await expect(api.readFile("data.bin")).rejects.toThrow(/byte range/)
211
+ })
212
+
213
+ test("ranged reads above the byte cap are rejected too", async () => {
214
+ const api = createDirectoryResourceAPI(rootDir, { maxReadFileBytes: 3 })
215
+ await expect(
216
+ api.readFile("data.bin", { start: 0, end: 5 }),
217
+ ).rejects.toThrow(/byte range/)
218
+ await expect(
219
+ api.readFile("data.bin", { start: 0, end: 3 }),
220
+ ).resolves.toHaveLength(3)
221
+ })
222
+ })
223
+
224
+ describe("resolveSafeImportPath", () => {
225
+ test("resolves a valid relative path against the directory", () => {
226
+ const resolved = resolveSafeImportPath("root", "a/b.txt")
227
+ expect(resolved.endsWith(join("root", "a", "b.txt"))).toBe(true)
228
+ })
229
+ })