@hoardodile/host 0.1.6 → 0.1.8
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/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/archive-api.test.ts +156 -0
- package/src/nested-view.ts +15 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hoardodile/host",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "hoardodile plugin runtime host: sandbox, hook strategy, ResourceAPI, containers and the test runner.",
|
|
6
6
|
"keywords": [
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"file-type": "^22.0.2",
|
|
68
68
|
"yauzl": "^3.4.0",
|
|
69
69
|
"yazl": "^3.3.1",
|
|
70
|
-
"@hoardodile/sdk-types": "0.1.
|
|
70
|
+
"@hoardodile/sdk-types": "0.1.8"
|
|
71
71
|
},
|
|
72
72
|
"optionalDependencies": {
|
|
73
73
|
"@derhuerst/ffprobe-static": "^5.3.0",
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from "node:child_process"
|
|
4
|
+
import {
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs"
|
|
11
|
+
import { tmpdir } from "node:os"
|
|
12
|
+
import { dirname, join } from "node:path"
|
|
13
|
+
import { Readable } from "node:stream"
|
|
14
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
15
|
+
import { makeZip } from "./__testutils__/zip-fixtures.ts"
|
|
16
|
+
import { createPluginResourceAPI } from "./api.ts"
|
|
17
|
+
import { resolveSevenZipPath } from "./archive/7z.ts"
|
|
18
|
+
import type { ResourceContainer } from "./container.ts"
|
|
19
|
+
|
|
20
|
+
const sevenZipAvailable = resolveSevenZipPath() !== undefined
|
|
21
|
+
|
|
22
|
+
function memoryContainer(
|
|
23
|
+
files: Readonly<Record<string, Buffer>>,
|
|
24
|
+
): ResourceContainer {
|
|
25
|
+
return {
|
|
26
|
+
listEntries: async () => Object.keys(files).sort(),
|
|
27
|
+
readEntry: async (rel) => {
|
|
28
|
+
const buf = files[rel]
|
|
29
|
+
if (buf === undefined) throw new Error(`no entry ${rel}`)
|
|
30
|
+
return buf
|
|
31
|
+
},
|
|
32
|
+
readEntrySlice: async (rel, start, end) => {
|
|
33
|
+
const buf = files[rel]
|
|
34
|
+
if (buf === undefined) throw new Error(`no entry ${rel}`)
|
|
35
|
+
return buf.subarray(start, Math.min(end, buf.length))
|
|
36
|
+
},
|
|
37
|
+
openEntryStream: async (rel) => {
|
|
38
|
+
const buf = files[rel]
|
|
39
|
+
if (buf === undefined) throw new Error(`no entry ${rel}`)
|
|
40
|
+
return { stream: Readable.from([buf]), size: buf.length }
|
|
41
|
+
},
|
|
42
|
+
resolveByteRange: async (rel) => {
|
|
43
|
+
const buf = files[rel]
|
|
44
|
+
return buf === undefined ? undefined : { size: buf.length }
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeSevenZip(
|
|
50
|
+
dir: string,
|
|
51
|
+
files: Readonly<Record<string, string>>,
|
|
52
|
+
): Buffer {
|
|
53
|
+
const payload = join(dir, "payload")
|
|
54
|
+
mkdirSync(payload, { recursive: true })
|
|
55
|
+
for (const [name, content] of Object.entries(files)) {
|
|
56
|
+
const filePath = join(payload, name)
|
|
57
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
58
|
+
writeFileSync(filePath, content)
|
|
59
|
+
}
|
|
60
|
+
const archivePath = join(dir, "book.cb7")
|
|
61
|
+
execFileSync(resolveSevenZipPath()!, ["a", "-t7z", archivePath, "."], {
|
|
62
|
+
cwd: payload,
|
|
63
|
+
stdio: "ignore",
|
|
64
|
+
})
|
|
65
|
+
return readFileSync(archivePath)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The plugin-facing container contract under the unified addressing model:
|
|
70
|
+
* an entry is addressed as `outer!inner` and read straight from the
|
|
71
|
+
* central directory (zip) or from the extraction cache (non-zip) after
|
|
72
|
+
* `extractArchive`. Both formats answer the same plugin API.
|
|
73
|
+
*/
|
|
74
|
+
describe("plugin container addressing (outer!inner)", () => {
|
|
75
|
+
let cacheDir: string
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
rmSync(cacheDir, { recursive: true, force: true })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
function apiOf(containerName: string, archiveBytes: Buffer) {
|
|
82
|
+
cacheDir = mkdtempSync(join(tmpdir(), "archive-api-"))
|
|
83
|
+
return createPluginResourceAPI({
|
|
84
|
+
view: memoryContainer({ [containerName]: archiveBytes }),
|
|
85
|
+
extractCacheDir: cacheDir,
|
|
86
|
+
cacheScope: "test",
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
it("reads and lists a zip entry from the central directory", async () => {
|
|
91
|
+
const zip = makeZip([
|
|
92
|
+
{
|
|
93
|
+
name: "Ch1/001.jpg",
|
|
94
|
+
data: Uint8Array.from([1, 2, 3, 4, 5]),
|
|
95
|
+
method: 8,
|
|
96
|
+
},
|
|
97
|
+
{ name: "Ch1/002.jpg", data: Uint8Array.from([11, 12, 13]) },
|
|
98
|
+
])
|
|
99
|
+
const api = apiOf("book.cbz", zip)
|
|
100
|
+
|
|
101
|
+
// The cheap listing never materializes.
|
|
102
|
+
expect(
|
|
103
|
+
(await api.listContainer("book.cbz")).entries.map((e) => e.path),
|
|
104
|
+
).toEqual(["Ch1/001.jpg", "Ch1/002.jpg"])
|
|
105
|
+
// The virtual entry reads straight from the central directory.
|
|
106
|
+
expect([...(await api.readFile("book.cbz!Ch1/001.jpg"))]).toEqual([
|
|
107
|
+
1, 2, 3, 4, 5,
|
|
108
|
+
])
|
|
109
|
+
expect(await api.statFile("book.cbz!Ch1/002.jpg")).toEqual({
|
|
110
|
+
sizeBytes: 3,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// A missing inner entry surfaces as a stat miss / read error.
|
|
114
|
+
expect(await api.statFile("book.cbz!nope.jpg")).toBeUndefined()
|
|
115
|
+
await expect(api.readFile("book.cbz!nope.jpg")).rejects.toThrow()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it.skipIf(!sevenZipAvailable)(
|
|
119
|
+
"extracts and reads a non-zip entry from the extraction cache",
|
|
120
|
+
async () => {
|
|
121
|
+
const archive = makeSevenZip(
|
|
122
|
+
mkdtempSync(join(tmpdir(), "archive-api-")),
|
|
123
|
+
{
|
|
124
|
+
"Ch1/001.jpg": "first",
|
|
125
|
+
"Ch1/002.jpg": "second",
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
const api = apiOf("book.cb7", archive)
|
|
129
|
+
|
|
130
|
+
// The cheap listing never materializes.
|
|
131
|
+
expect(
|
|
132
|
+
(await api.listContainer("book.cb7")).entries.map((e) => e.path),
|
|
133
|
+
).toEqual(["Ch1/001.jpg", "Ch1/002.jpg"])
|
|
134
|
+
|
|
135
|
+
// Before materialization a non-zip inner is not readable — and
|
|
136
|
+
// this absent read must NOT poison the view for a later
|
|
137
|
+
// extract+read (the manifest memo only keeps positive results).
|
|
138
|
+
await expect(api.readFile("book.cb7!Ch1/001.jpg")).rejects.toThrow()
|
|
139
|
+
|
|
140
|
+
const extraction = await api.extractArchive("book.cb7")
|
|
141
|
+
expect(extraction.entries.map((e) => e.path)).toEqual([
|
|
142
|
+
"Ch1/001.jpg",
|
|
143
|
+
"Ch1/002.jpg",
|
|
144
|
+
])
|
|
145
|
+
|
|
146
|
+
// After materialization the SAME outer!inner form reads from disk,
|
|
147
|
+
// even though the same view instance saw it absent a moment ago.
|
|
148
|
+
expect(
|
|
149
|
+
Buffer.from(await api.readFile("book.cb7!Ch1/001.jpg")).toString(),
|
|
150
|
+
).toBe("first")
|
|
151
|
+
expect(await api.statFile("book.cb7!Ch1/002.jpg")).toEqual({
|
|
152
|
+
sizeBytes: 6,
|
|
153
|
+
})
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
})
|
package/src/nested-view.ts
CHANGED
|
@@ -53,25 +53,36 @@ export function createNestedAwareContainer(
|
|
|
53
53
|
{ cdCache: nestedCdCache, scope },
|
|
54
54
|
)
|
|
55
55
|
|
|
56
|
-
// Manifest reads are memoized per (cacheDir, outer)
|
|
57
|
-
//
|
|
56
|
+
// Manifest reads are memoized per (cacheDir, outer) to avoid re-parsing
|
|
57
|
+
// the JSON on every virtual read. A **present** manifest is immutable
|
|
58
|
+
// per version, so a parsed result never needs invalidation. An
|
|
59
|
+
// **absent** manifest, however, is only "not materialized yet": a
|
|
60
|
+
// plugin may probe a non-zip `outer!inner` before calling
|
|
61
|
+
// `extractArchive`, then materialize it. Pinning the "absent" state
|
|
62
|
+
// would make the same view stale forever, so the cache only keeps a
|
|
63
|
+
// positive result and re-checks after an absent read.
|
|
58
64
|
const manifestMemo = new Map<
|
|
59
65
|
string,
|
|
60
66
|
Promise<readonly ExtractedEntry[] | undefined>
|
|
61
67
|
>()
|
|
62
68
|
|
|
63
|
-
function readManifest(
|
|
69
|
+
async function readManifest(
|
|
64
70
|
outer: string,
|
|
65
71
|
): Promise<readonly ExtractedEntry[] | undefined> {
|
|
66
72
|
const key = `${extractCacheDir}:${outer}`
|
|
67
73
|
const pending = manifestMemo.get(key)
|
|
68
74
|
if (pending !== undefined) return pending
|
|
75
|
+
// Share the in-flight read so concurrent callers do not re-read the
|
|
76
|
+
// same archive's manifest, then drop the key when the answer is
|
|
77
|
+
// "absent" so a later materialization is picked up.
|
|
69
78
|
const work = readExistingManifest(
|
|
70
79
|
join(extractCacheDir!, outer, "index.json"),
|
|
71
80
|
outer,
|
|
72
81
|
).catch(() => undefined)
|
|
73
82
|
manifestMemo.set(key, work)
|
|
74
|
-
|
|
83
|
+
const entries = await work
|
|
84
|
+
if (entries === undefined) manifestMemo.delete(key)
|
|
85
|
+
return entries
|
|
75
86
|
}
|
|
76
87
|
|
|
77
88
|
type MaterializedEntry = {
|