@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,35 @@
|
|
|
1
|
+
import type { Readable } from "node:stream"
|
|
2
|
+
import { ffmpegThumbSource, type ThumbInput } from "./thumb-input.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Run a media pipeline over an entry, materializing it to a seekable
|
|
6
|
+
* path first when the container format needs seeking — ISO-BMFF
|
|
7
|
+
* (.mp4/.mov/.m4v/.m4a) keeps its moov index at the end of the file,
|
|
8
|
+
* unreachable from a forward-only pipe — or when the stream attempt
|
|
9
|
+
* fails for another reason.
|
|
10
|
+
*
|
|
11
|
+
* One implementation shared by the thumbnail pipeline and the cover-meta
|
|
12
|
+
* probes, so the "streamable vs materialize" split can never diverge
|
|
13
|
+
* between the two paths again.
|
|
14
|
+
*/
|
|
15
|
+
export async function withMediaSource<T>(opts: {
|
|
16
|
+
readonly relPath: string
|
|
17
|
+
readonly ext: string
|
|
18
|
+
readonly input: ThumbInput
|
|
19
|
+
/** Extensions whose container is seekable from a forward-only pipe. */
|
|
20
|
+
readonly streamableExts: ReadonlySet<string>
|
|
21
|
+
/** Materialize `relPath` to a seekable on-disk path. */
|
|
22
|
+
readonly materialize: (relPath: string) => Promise<string>
|
|
23
|
+
readonly run: (source: string | Readable) => Promise<T>
|
|
24
|
+
}): Promise<T> {
|
|
25
|
+
const { ext, input, streamableExts, materialize, relPath, run } = opts
|
|
26
|
+
if (input.kind === "stream" && !streamableExts.has(ext)) {
|
|
27
|
+
return run(await materialize(relPath))
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return await run(await ffmpegThumbSource(input))
|
|
31
|
+
} catch (streamErr) {
|
|
32
|
+
if (input.kind !== "stream") throw streamErr
|
|
33
|
+
return run(await materialize(relPath))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { buffer } from "node:stream/consumers"
|
|
5
|
+
import sharp from "sharp"
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest"
|
|
7
|
+
import yazl from "yazl"
|
|
8
|
+
import { createDirectoryContainer } from "../directory-container.ts"
|
|
9
|
+
import { createNestedAwareContainer } from "../nested-view.ts"
|
|
10
|
+
import { THUMB_BUFFER_MAX_BYTES } from "../probe/index.ts"
|
|
11
|
+
import { withThumbInput } from "./thumb-input.ts"
|
|
12
|
+
|
|
13
|
+
describe("withThumbInput", () => {
|
|
14
|
+
let root: string
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
root = mkdtempSync(join(tmpdir(), "thumb-input-"))
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(root, { recursive: true, force: true })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("small image entries are read into memory without extraction", async () => {
|
|
25
|
+
const png = await sharp({
|
|
26
|
+
create: {
|
|
27
|
+
width: 24,
|
|
28
|
+
height: 24,
|
|
29
|
+
channels: 3,
|
|
30
|
+
background: { r: 4, g: 5, b: 6 },
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
.png()
|
|
34
|
+
.toBuffer()
|
|
35
|
+
writeFileSync(join(root, "a.png"), png)
|
|
36
|
+
|
|
37
|
+
const container = createDirectoryContainer(root)
|
|
38
|
+
|
|
39
|
+
let sawBuffer = false
|
|
40
|
+
await withThumbInput(container, "a.png", "image", async (input) => {
|
|
41
|
+
expect(input.kind).toBe("buffer")
|
|
42
|
+
if (input.kind === "buffer") {
|
|
43
|
+
sawBuffer = true
|
|
44
|
+
expect(input.buffer.equals(png)).toBe(true)
|
|
45
|
+
}
|
|
46
|
+
return "ok"
|
|
47
|
+
})
|
|
48
|
+
expect(sawBuffer).toBe(true)
|
|
49
|
+
expect(png.length).toBeLessThan(THUMB_BUFFER_MAX_BYTES)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("literal video entries resolve to the seekable file path", async () => {
|
|
53
|
+
const mp4Bytes = Buffer.from("fake-mp4-bytes")
|
|
54
|
+
writeFileSync(join(root, "clip.mp4"), mp4Bytes)
|
|
55
|
+
|
|
56
|
+
const container = createDirectoryContainer(root)
|
|
57
|
+
|
|
58
|
+
await withThumbInput(container, "clip.mp4", "video", async (input) => {
|
|
59
|
+
expect(input.kind).toBe("path")
|
|
60
|
+
if (input.kind === "path") {
|
|
61
|
+
expect(input.path).toBe(join(root, "clip.mp4"))
|
|
62
|
+
}
|
|
63
|
+
return "ok"
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("large literal images resolve to the seekable file path", async () => {
|
|
68
|
+
const big = Buffer.alloc(THUMB_BUFFER_MAX_BYTES + 1, 0xa5)
|
|
69
|
+
writeFileSync(join(root, "big.png"), big)
|
|
70
|
+
|
|
71
|
+
const container = createDirectoryContainer(root)
|
|
72
|
+
|
|
73
|
+
await withThumbInput(container, "big.png", "image", async (input) => {
|
|
74
|
+
expect(input.kind).toBe("path")
|
|
75
|
+
if (input.kind === "path") {
|
|
76
|
+
expect(input.path).toBe(join(root, "big.png"))
|
|
77
|
+
}
|
|
78
|
+
return "ok"
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test("virtual archive entries keep streaming (no seekable path)", async () => {
|
|
83
|
+
const zip = new yazl.ZipFile()
|
|
84
|
+
zip.addBuffer(Buffer.from("fake-mp4-bytes"), "clip.mp4")
|
|
85
|
+
zip.end()
|
|
86
|
+
const chunks: Buffer[] = []
|
|
87
|
+
for await (const chunk of zip.outputStream) {
|
|
88
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
|
|
89
|
+
}
|
|
90
|
+
writeFileSync(join(root, "book.cbz"), Buffer.concat(chunks))
|
|
91
|
+
|
|
92
|
+
const container = createNestedAwareContainer(createDirectoryContainer(root))
|
|
93
|
+
|
|
94
|
+
await withThumbInput(
|
|
95
|
+
container,
|
|
96
|
+
"book.cbz!clip.mp4",
|
|
97
|
+
"video",
|
|
98
|
+
async (input) => {
|
|
99
|
+
expect(input.kind).toBe("stream")
|
|
100
|
+
if (input.kind === "stream") {
|
|
101
|
+
expect(input.size).toBe(Buffer.byteLength("fake-mp4-bytes"))
|
|
102
|
+
const read = await buffer(await input.openStream())
|
|
103
|
+
expect(read.toString()).toBe("fake-mp4-bytes")
|
|
104
|
+
}
|
|
105
|
+
return "ok"
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { extname } from "node:path"
|
|
2
|
+
import type { Readable } from "node:stream"
|
|
3
|
+
import type { FileType } from "@hoardodile/sdk-types"
|
|
4
|
+
import type { MediaKind } from "@hoardodile/sdk-types/media-exts"
|
|
5
|
+
import type { ResourceContainer } from "../container.ts"
|
|
6
|
+
import { notFound } from "../errors.ts"
|
|
7
|
+
import {
|
|
8
|
+
SNIFF_HEADER_BYTES,
|
|
9
|
+
sniffBytes,
|
|
10
|
+
THUMB_BUFFER_MAX_BYTES,
|
|
11
|
+
} from "../probe/index.ts"
|
|
12
|
+
import type { PluginProbeCache } from "../probe-cache.ts"
|
|
13
|
+
import type { ImageThumbInput } from "../render/index.ts"
|
|
14
|
+
|
|
15
|
+
export type ThumbInput =
|
|
16
|
+
| { readonly kind: "path"; readonly path: string }
|
|
17
|
+
| { readonly kind: "buffer"; readonly buffer: Buffer }
|
|
18
|
+
| {
|
|
19
|
+
readonly kind: "stream"
|
|
20
|
+
readonly openStream: () => Promise<Readable>
|
|
21
|
+
readonly size: number
|
|
22
|
+
/**
|
|
23
|
+
* Byte-range read into the entry (zip slice). Lets the render
|
|
24
|
+
* pipeline's metadata step read a small header window instead
|
|
25
|
+
* of streaming the whole entry — libvips full-reads
|
|
26
|
+
* non-seekable input for metadata.
|
|
27
|
+
*/
|
|
28
|
+
readonly readRange?: (start: number, end: number) => Promise<Buffer>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function imageThumbSource(input: ThumbInput): ImageThumbInput {
|
|
32
|
+
if (input.kind === "path") return input.path
|
|
33
|
+
if (input.kind === "buffer") return input.buffer
|
|
34
|
+
if (input.kind === "stream") {
|
|
35
|
+
return input.readRange === undefined
|
|
36
|
+
? { openStream: input.openStream }
|
|
37
|
+
: { openStream: input.openStream, readRange: input.readRange }
|
|
38
|
+
}
|
|
39
|
+
throw new Error("unsupported image thumb input")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve an ffmpeg-bound entry (video, or audio whose embedded artwork
|
|
44
|
+
* ffmpeg demuxes as a still video stream) into a path or stream. ffmpeg
|
|
45
|
+
* reads both from `pipe:0`, so the two share one shape.
|
|
46
|
+
*/
|
|
47
|
+
export async function ffmpegThumbSource(
|
|
48
|
+
input: ThumbInput,
|
|
49
|
+
): Promise<string | Readable> {
|
|
50
|
+
if (input.kind === "path") return input.path
|
|
51
|
+
if (input.kind === "stream") return input.openStream()
|
|
52
|
+
throw new Error("unsupported ffmpeg thumb input")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function streamThumbInput(
|
|
56
|
+
container: ResourceContainer,
|
|
57
|
+
relPath: string,
|
|
58
|
+
size: number,
|
|
59
|
+
): ThumbInput {
|
|
60
|
+
return {
|
|
61
|
+
kind: "stream",
|
|
62
|
+
openStream: () =>
|
|
63
|
+
container.openEntryStream(relPath).then((entry) => entry.stream),
|
|
64
|
+
readRange: (start, end) => container.readEntrySlice(relPath, start, end),
|
|
65
|
+
size,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Prefer a seekable on-disk path over a stream when the container can
|
|
71
|
+
* provide one (bare-file backends): libvips/ffmpeg open the file
|
|
72
|
+
* directly — mmap + shrink-on-load for images, no pipe for video —
|
|
73
|
+
* with none of the pipe-copy overhead. Containers without the
|
|
74
|
+
* capability (in-memory fixtures, virtual `outer!inner` entries) fall
|
|
75
|
+
* back to streaming.
|
|
76
|
+
*/
|
|
77
|
+
async function pathOrStreamThumbInput(
|
|
78
|
+
container: ResourceContainer,
|
|
79
|
+
relPath: string,
|
|
80
|
+
size: number,
|
|
81
|
+
): Promise<ThumbInput> {
|
|
82
|
+
const path = await container.resolveSeekablePath?.(relPath)
|
|
83
|
+
if (path !== undefined) return { kind: "path", path }
|
|
84
|
+
return streamThumbInput(container, relPath, size)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Identify a container entry from its bytes, falling back to its name.
|
|
89
|
+
* The thumbnail pipeline and the plugins that pick cover files must
|
|
90
|
+
* agree on what a file is, so both go through the same sniffer — an
|
|
91
|
+
* entry the plugin selected as an image never lands in the ffmpeg branch
|
|
92
|
+
* because of a misleading extension.
|
|
93
|
+
*/
|
|
94
|
+
async function sniffEntry(
|
|
95
|
+
container: ResourceContainer,
|
|
96
|
+
relPath: string,
|
|
97
|
+
opts: WithThumbInputOptions | undefined,
|
|
98
|
+
): Promise<FileType | undefined> {
|
|
99
|
+
const readHead = async (): Promise<Buffer | undefined> =>
|
|
100
|
+
container
|
|
101
|
+
.readEntrySlice(relPath, 0, SNIFF_HEADER_BYTES)
|
|
102
|
+
.catch(() => undefined)
|
|
103
|
+
const head =
|
|
104
|
+
opts?.probeCache !== undefined && opts.cacheScope !== undefined
|
|
105
|
+
? await opts.probeCache.getOrCompute(
|
|
106
|
+
`${opts.cacheScope}:sniff:${relPath}`,
|
|
107
|
+
readHead,
|
|
108
|
+
)
|
|
109
|
+
: await readHead()
|
|
110
|
+
if (head === undefined) return undefined
|
|
111
|
+
return sniffBytes(head, relPath)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type WithThumbInputOptions = {
|
|
115
|
+
/**
|
|
116
|
+
* Identifier used in the missing-entry error message (e.g. the
|
|
117
|
+
* resource id).
|
|
118
|
+
*/
|
|
119
|
+
readonly label?: string
|
|
120
|
+
/**
|
|
121
|
+
* Shared probe cache, keyed by `cacheScope` — pass the same instance
|
|
122
|
+
* and scope the plugin API uses so sniff results are reused across
|
|
123
|
+
* hook runs and thumb jobs for one resource version.
|
|
124
|
+
*/
|
|
125
|
+
readonly probeCache?: PluginProbeCache
|
|
126
|
+
readonly cacheScope?: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve a container entry into the cheapest readable form for thumb
|
|
131
|
+
* synthesis: small images are read into memory; larger images, videos
|
|
132
|
+
* and audio stream directly from the source file without extracted-cache
|
|
133
|
+
* writes.
|
|
134
|
+
*
|
|
135
|
+
* `expected` guards the caller's assumption — `"any"` accepts whatever
|
|
136
|
+
* the entry turns out to be. The callback receives the **sniffed**
|
|
137
|
+
* extension, so sharp and ffmpeg get the container hint the content
|
|
138
|
+
* actually warrants.
|
|
139
|
+
*/
|
|
140
|
+
export async function withThumbInput<T>(
|
|
141
|
+
container: ResourceContainer,
|
|
142
|
+
relPath: string,
|
|
143
|
+
expected: MediaKind | "any",
|
|
144
|
+
fn: (input: ThumbInput, ext: string, kind: MediaKind) => Promise<T>,
|
|
145
|
+
opts?: WithThumbInputOptions,
|
|
146
|
+
): Promise<T> {
|
|
147
|
+
const type = await sniffEntry(container, relPath, opts)
|
|
148
|
+
const kind = type?.kind ?? "other"
|
|
149
|
+
// An unidentified entry keeps the caller's expectation so the ffmpeg
|
|
150
|
+
// path still gets its chance, exactly as the extension fallback did.
|
|
151
|
+
const ext = type?.ext ?? extname(relPath).toLowerCase()
|
|
152
|
+
const effective: MediaKind =
|
|
153
|
+
type === undefined && expected !== "any" ? expected : kind
|
|
154
|
+
if (expected !== "any" && effective !== expected) {
|
|
155
|
+
throw new Error(`unsupported thumb media kind ${effective} for ${relPath}`)
|
|
156
|
+
}
|
|
157
|
+
if (effective === "image") {
|
|
158
|
+
const range = await requireEntryRange(container, relPath, opts?.label)
|
|
159
|
+
if (range.size <= THUMB_BUFFER_MAX_BYTES) {
|
|
160
|
+
const buffer = await container.readEntry(relPath)
|
|
161
|
+
return fn({ kind: "buffer", buffer }, ext, effective)
|
|
162
|
+
}
|
|
163
|
+
return fn(
|
|
164
|
+
await pathOrStreamThumbInput(container, relPath, range.size),
|
|
165
|
+
ext,
|
|
166
|
+
effective,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
if (effective === "video" || effective === "audio") {
|
|
170
|
+
const range = await requireEntryRange(container, relPath, opts?.label)
|
|
171
|
+
return fn(
|
|
172
|
+
await pathOrStreamThumbInput(container, relPath, range.size),
|
|
173
|
+
ext,
|
|
174
|
+
effective,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`unsupported thumb media kind ${effective} for ${relPath}`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function requireEntryRange(
|
|
181
|
+
container: ResourceContainer,
|
|
182
|
+
relPath: string,
|
|
183
|
+
label: string | undefined,
|
|
184
|
+
): Promise<{ readonly size: number }> {
|
|
185
|
+
const range = await container.resolveByteRange(relPath)
|
|
186
|
+
if (range === undefined) {
|
|
187
|
+
throw notFound(
|
|
188
|
+
"resource.file_not_found",
|
|
189
|
+
`resource ${label ?? "?"} has no entry ${relPath}`,
|
|
190
|
+
{ resId: label, relPath },
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
return range
|
|
194
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
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 { resolveSevenZipPath } from "./archive/7z.ts"
|
|
17
|
+
import { createArchiveExtractor } from "./archive/extract-archive.ts"
|
|
18
|
+
import type { ResourceContainer } from "./container.ts"
|
|
19
|
+
import { createNestedAwareContainer } from "./nested-view.ts"
|
|
20
|
+
|
|
21
|
+
const sevenZipAvailable = resolveSevenZipPath() !== undefined
|
|
22
|
+
|
|
23
|
+
function memoryContainer(
|
|
24
|
+
files: Readonly<Record<string, Buffer>>,
|
|
25
|
+
): ResourceContainer {
|
|
26
|
+
return {
|
|
27
|
+
listEntries: async () => Object.keys(files).sort(),
|
|
28
|
+
readEntry: async (rel) => {
|
|
29
|
+
const buf = files[rel]
|
|
30
|
+
if (buf === undefined) throw new Error(`no entry ${rel}`)
|
|
31
|
+
return buf
|
|
32
|
+
},
|
|
33
|
+
readEntrySlice: async (rel, start, end) => {
|
|
34
|
+
const buf = files[rel]
|
|
35
|
+
if (buf === undefined) throw new Error(`no entry ${rel}`)
|
|
36
|
+
return buf.subarray(start, Math.min(end, buf.length))
|
|
37
|
+
},
|
|
38
|
+
openEntryStream: async (rel) => {
|
|
39
|
+
const buf = files[rel]
|
|
40
|
+
if (buf === undefined) throw new Error(`no entry ${rel}`)
|
|
41
|
+
return { stream: Readable.from([buf]), size: buf.length }
|
|
42
|
+
},
|
|
43
|
+
resolveByteRange: async (rel) => {
|
|
44
|
+
const buf = files[rel]
|
|
45
|
+
return buf === undefined ? undefined : { size: buf.length }
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DEFLATED = makeZip([
|
|
51
|
+
{
|
|
52
|
+
name: "Ch1/001.jpg",
|
|
53
|
+
data: Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
|
|
54
|
+
method: 8,
|
|
55
|
+
},
|
|
56
|
+
{ name: "Ch1/002.jpg", data: Uint8Array.from([11, 12, 13]) },
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
describe("createNestedAwareContainer", () => {
|
|
60
|
+
const container = createNestedAwareContainer(
|
|
61
|
+
memoryContainer({
|
|
62
|
+
"book.cbz": DEFLATED,
|
|
63
|
+
"plain.txt": Buffer.from("hello world"),
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
it("passes literal entries through unchanged", async () => {
|
|
68
|
+
expect(await container.resolveByteRange("plain.txt")).toEqual({ size: 11 })
|
|
69
|
+
expect(await container.readEntrySlice("plain.txt", 0, 5)).toEqual(
|
|
70
|
+
Buffer.from("hello"),
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("resolves virtual path sizes", async () => {
|
|
75
|
+
expect(await container.resolveByteRange("book.cbz!Ch1/002.jpg")).toEqual({
|
|
76
|
+
size: 3,
|
|
77
|
+
})
|
|
78
|
+
expect(
|
|
79
|
+
await container.resolveByteRange("book.cbz!missing.jpg"),
|
|
80
|
+
).toBeUndefined()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("streams virtual entries with their decompressed size", async () => {
|
|
84
|
+
const { stream, size } = await container.openEntryStream(
|
|
85
|
+
"book.cbz!Ch1/001.jpg",
|
|
86
|
+
)
|
|
87
|
+
const chunks: Buffer[] = []
|
|
88
|
+
for await (const chunk of stream) chunks.push(Buffer.from(chunk))
|
|
89
|
+
expect(size).toBe(10)
|
|
90
|
+
expect(Buffer.concat(chunks)).toEqual(
|
|
91
|
+
Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("slices virtual entries without inflating the whole entry", async () => {
|
|
96
|
+
const head = await container.readEntrySlice("book.cbz!Ch1/001.jpg", 0, 4)
|
|
97
|
+
expect(head).toEqual(Buffer.from([1, 2, 3, 4]))
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("clamps out-of-range slices", async () => {
|
|
101
|
+
const tail = await container.readEntrySlice("book.cbz!Ch1/001.jpg", 8, 100)
|
|
102
|
+
expect(tail).toEqual(Buffer.from([9, 10]))
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("keeps listEntries flat", async () => {
|
|
106
|
+
expect(await container.listEntries()).toEqual(["book.cbz", "plain.txt"])
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// ── Materialized addressing (extracted non-zip containers) ──────────────────
|
|
111
|
+
|
|
112
|
+
function makeSevenZipArchive(
|
|
113
|
+
dir: string,
|
|
114
|
+
files: Readonly<Record<string, string>>,
|
|
115
|
+
): Buffer {
|
|
116
|
+
const payload = join(dir, "payload")
|
|
117
|
+
mkdirSync(payload, { recursive: true })
|
|
118
|
+
for (const [name, content] of Object.entries(files)) {
|
|
119
|
+
const filePath = join(payload, name)
|
|
120
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
121
|
+
writeFileSync(filePath, content)
|
|
122
|
+
}
|
|
123
|
+
const archivePath = join(dir, "book.cb7")
|
|
124
|
+
execFileSync(resolveSevenZipPath()!, ["a", "-t7z", archivePath, "."], {
|
|
125
|
+
cwd: payload,
|
|
126
|
+
stdio: "ignore",
|
|
127
|
+
})
|
|
128
|
+
return readFileSync(archivePath)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe.skipIf(!sevenZipAvailable)(
|
|
132
|
+
"createNestedAwareContainer materialized addressing",
|
|
133
|
+
() => {
|
|
134
|
+
let root: string
|
|
135
|
+
let cacheDir: string
|
|
136
|
+
|
|
137
|
+
afterEach(() => {
|
|
138
|
+
rmSync(root, { recursive: true, force: true })
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
function extractorFor(base: ResourceContainer) {
|
|
142
|
+
return createArchiveExtractor({
|
|
143
|
+
outer: {
|
|
144
|
+
sizeOf: (rel) => base.resolveByteRange(rel).then((r) => r?.size),
|
|
145
|
+
readSlice: (rel, start, end) => base.readEntrySlice(rel, start, end),
|
|
146
|
+
},
|
|
147
|
+
cacheDir,
|
|
148
|
+
maxBytes: 1024 * 1024,
|
|
149
|
+
maxEntries: 100,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
it("serves extracted entries from disk with seekable paths", async () => {
|
|
154
|
+
root = mkdtempSync(join(tmpdir(), "nested-view-"))
|
|
155
|
+
cacheDir = join(root, "cache")
|
|
156
|
+
const archive = makeSevenZipArchive(root, {
|
|
157
|
+
"Ch1/001.jpg": "first",
|
|
158
|
+
"Ch1/002.jpg": "second",
|
|
159
|
+
})
|
|
160
|
+
const base = memoryContainer({ "book.cb7": archive })
|
|
161
|
+
await extractorFor(base).extract("book.cb7")
|
|
162
|
+
|
|
163
|
+
const view = createNestedAwareContainer(
|
|
164
|
+
base,
|
|
165
|
+
undefined,
|
|
166
|
+
undefined,
|
|
167
|
+
cacheDir,
|
|
168
|
+
)
|
|
169
|
+
expect(await view.resolveByteRange("book.cb7!Ch1/001.jpg")).toEqual({
|
|
170
|
+
size: 5,
|
|
171
|
+
})
|
|
172
|
+
const { stream, size, path } = await view.openEntryStream(
|
|
173
|
+
"book.cb7!Ch1/002.jpg",
|
|
174
|
+
)
|
|
175
|
+
const chunks: Buffer[] = []
|
|
176
|
+
for await (const chunk of stream) chunks.push(Buffer.from(chunk))
|
|
177
|
+
expect(size).toBe(6)
|
|
178
|
+
expect(Buffer.concat(chunks).toString("utf8")).toBe("second")
|
|
179
|
+
expect(path).toBe(join(cacheDir, "book.cb7", "Ch1", "002.jpg"))
|
|
180
|
+
expect(await view.readEntrySlice("book.cb7!Ch1/001.jpg", 0, 3)).toEqual(
|
|
181
|
+
Buffer.from("fir"),
|
|
182
|
+
)
|
|
183
|
+
expect(await view.resolveSeekablePath?.("book.cb7!Ch1/001.jpg")).toBe(
|
|
184
|
+
join(cacheDir, "book.cb7", "Ch1", "001.jpg"),
|
|
185
|
+
)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("does not address non-extracted containers", async () => {
|
|
189
|
+
root = mkdtempSync(join(tmpdir(), "nested-view-"))
|
|
190
|
+
cacheDir = join(root, "cache")
|
|
191
|
+
const archive = makeSevenZipArchive(root, { "a.txt": "x" })
|
|
192
|
+
const base = memoryContainer({ "book.cb7": archive })
|
|
193
|
+
const view = createNestedAwareContainer(
|
|
194
|
+
base,
|
|
195
|
+
undefined,
|
|
196
|
+
undefined,
|
|
197
|
+
cacheDir,
|
|
198
|
+
)
|
|
199
|
+
expect(await view.resolveByteRange("book.cb7!a.txt")).toBeUndefined()
|
|
200
|
+
expect(await view.resolveSeekablePath?.("book.cb7!a.txt")).toBeUndefined()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it("ignores paths outside the manifest whitelist", async () => {
|
|
204
|
+
root = mkdtempSync(join(tmpdir(), "nested-view-"))
|
|
205
|
+
cacheDir = join(root, "cache")
|
|
206
|
+
const archive = makeSevenZipArchive(root, { "a.txt": "x" })
|
|
207
|
+
const base = memoryContainer({ "book.cb7": archive })
|
|
208
|
+
await extractorFor(base).extract("book.cb7")
|
|
209
|
+
const view = createNestedAwareContainer(
|
|
210
|
+
base,
|
|
211
|
+
undefined,
|
|
212
|
+
undefined,
|
|
213
|
+
cacheDir,
|
|
214
|
+
)
|
|
215
|
+
expect(
|
|
216
|
+
await view.resolveByteRange("book.cb7!missing.txt"),
|
|
217
|
+
).toBeUndefined()
|
|
218
|
+
expect(
|
|
219
|
+
await view.resolveByteRange("book.cb7!../escape.txt"),
|
|
220
|
+
).toBeUndefined()
|
|
221
|
+
})
|
|
222
|
+
},
|
|
223
|
+
)
|