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