@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,55 @@
1
+ /**
2
+ * Process-wide cache of nested container listings (central directories
3
+ * of zip/tar entries addressed via virtual paths). Resolving
4
+ * `outer!inner` re-parses the outer archive's CD on every hook call;
5
+ * this cache keeps the parsed listing resident so a multi-hook pass
6
+ * (sourceMeta → cover → hashes → listFiles) parses each nested archive
7
+ * once.
8
+ *
9
+ * Keyed by the outer entry name only — callers scope the cache instance
10
+ * to a fixed (resId, fileVersion), and archives are immutable per
11
+ * version, so a key never outlives its content.
12
+ */
13
+ export type NestedCdCache = {
14
+ readonly get: (key: string) => Promise<unknown> | undefined
15
+ readonly set: (key: string, value: Promise<unknown>) => void
16
+ readonly clear: () => void
17
+ }
18
+
19
+ export type NestedCdCacheOptions = {
20
+ /** Max number of nested archives kept resident. */
21
+ readonly maxEntries: number
22
+ }
23
+
24
+ const DEFAULT_MAX_ENTRIES = 512
25
+
26
+ /** Create an LRU-bounded nested listing cache. */
27
+ export function createNestedCdCache(
28
+ opts?: Partial<NestedCdCacheOptions>,
29
+ ): NestedCdCache {
30
+ const maxEntries = opts?.maxEntries ?? DEFAULT_MAX_ENTRIES
31
+ const lru = new Map<string, Promise<unknown>>()
32
+
33
+ function get(key: string): Promise<unknown> | undefined {
34
+ const value = lru.get(key)
35
+ if (value === undefined) return undefined
36
+ lru.delete(key)
37
+ lru.set(key, value)
38
+ return value
39
+ }
40
+
41
+ function set(key: string, value: Promise<unknown>): void {
42
+ lru.set(key, value)
43
+ while (lru.size > maxEntries) {
44
+ const oldest = lru.keys().next().value
45
+ if (oldest === undefined) break
46
+ lru.delete(oldest)
47
+ }
48
+ }
49
+
50
+ function clear(): void {
51
+ lru.clear()
52
+ }
53
+
54
+ return { get, set, clear }
55
+ }
@@ -0,0 +1,194 @@
1
+ // @vitest-environment node
2
+
3
+ import { describe, expect, it } from "vitest"
4
+ import { makeTar, makeZip } from "../__testutils__/zip-fixtures.ts"
5
+ import { sniffContainerFormat } from "./format.ts"
6
+ import {
7
+ createNestedResolver,
8
+ type OuterEntrySource,
9
+ splitVirtualPath,
10
+ } from "./nested-entry.ts"
11
+
12
+ // ── Fixture helpers ──────────────────────────────────────────────────────────
13
+
14
+ function outerFrom(files: Readonly<Record<string, Buffer>>): OuterEntrySource {
15
+ return {
16
+ sizeOf: async (rel) => {
17
+ const buf = files[rel]
18
+ return buf === undefined ? undefined : buf.length
19
+ },
20
+ readSlice: async (rel, start, end) => {
21
+ const buf = files[rel]
22
+ if (buf === undefined) throw new Error(`missing fixture entry ${rel}`)
23
+ return buf.subarray(start, Math.min(end, buf.length))
24
+ },
25
+ }
26
+ }
27
+
28
+ // ── splitVirtualPath ─────────────────────────────────────────────────────────
29
+
30
+ describe("splitVirtualPath", () => {
31
+ it("splits at the first separator", () => {
32
+ expect(splitVirtualPath("a.cbz!Ch1/001.jpg")).toEqual({
33
+ outer: "a.cbz",
34
+ inner: "Ch1/001.jpg",
35
+ })
36
+ })
37
+
38
+ it("handles separators inside the inner path", () => {
39
+ expect(splitVirtualPath("a.cbz!b!c.png")).toEqual({
40
+ outer: "a.cbz",
41
+ inner: "b!c.png",
42
+ })
43
+ })
44
+
45
+ it("returns undefined without a separator or with an empty inner", () => {
46
+ expect(splitVirtualPath("plain.jpg")).toBeUndefined()
47
+ expect(splitVirtualPath("a.cbz!")).toBeUndefined()
48
+ expect(splitVirtualPath("!a")).toBeUndefined()
49
+ })
50
+ })
51
+
52
+ // ── sniffContainerFormat ─────────────────────────────────────────────────────
53
+
54
+ describe("sniffContainerFormat", () => {
55
+ it("detects zip and tar magic", () => {
56
+ expect(
57
+ sniffContainerFormat(Uint8Array.from([0x50, 0x4b, 0x03, 0x04])),
58
+ ).toBe("zip")
59
+ const tarHead = new Uint8Array(300)
60
+ tarHead.set(Buffer.from("ustar"), 257)
61
+ expect(sniffContainerFormat(tarHead)).toBe("tar")
62
+ })
63
+
64
+ it("detects gzip magic (extractable, not a nested container)", () => {
65
+ expect(
66
+ sniffContainerFormat(Uint8Array.from([0x1f, 0x8b, 0x08, 0x00])),
67
+ ).toBe("gzip")
68
+ })
69
+
70
+ it("returns undefined for other content", () => {
71
+ expect(sniffContainerFormat(new Uint8Array([1, 2, 3, 4]))).toBeUndefined()
72
+ expect(sniffContainerFormat(new Uint8Array(0))).toBeUndefined()
73
+ })
74
+ })
75
+
76
+ // ── createNestedResolver ─────────────────────────────────────────────────────
77
+
78
+ describe("createNestedResolver", () => {
79
+ const cbz = makeZip([
80
+ { name: "Ch1/001.jpg", data: Uint8Array.from([1, 2, 3]) },
81
+ { name: "Ch1/002.jpg", data: Uint8Array.from([4, 5]) },
82
+ { name: "Ch2/001.jpg", data: Uint8Array.from([6]) },
83
+ ])
84
+ const deflated = makeZip([
85
+ {
86
+ name: "page.png",
87
+ data: Uint8Array.from([9, 9, 9, 9, 9, 9, 9, 9, 9, 9]),
88
+ method: 8,
89
+ },
90
+ ])
91
+ const tar = makeTar([
92
+ { name: "Ch1/001.jpg", data: Uint8Array.from([7, 7]) },
93
+ { name: "Ch1/002.jpg", data: Uint8Array.from([8, 8, 8]) },
94
+ ])
95
+ const outer = outerFrom({
96
+ "book.cbz": cbz,
97
+ "flat.png": Buffer.from([1, 2]),
98
+ "tome.tar": tar,
99
+ "weird!name.txt": Buffer.from("literal"),
100
+ })
101
+
102
+ it("resolves zip entries with sizes", async () => {
103
+ const resolver = createNestedResolver(outer)
104
+ const resolved = await resolver.resolve("book.cbz!Ch1/001.jpg")
105
+ expect(resolved.kind).toBe("container")
106
+ if (resolved.kind !== "container") return
107
+ expect(resolved.outer).toBe("book.cbz")
108
+ expect(resolved.entry).toEqual({ name: "Ch1/001.jpg", sizeBytes: 3 })
109
+ })
110
+
111
+ it("streams STORED entry bytes", async () => {
112
+ const resolver = createNestedResolver(outer)
113
+ const resolved = await resolver.resolve("book.cbz!Ch1/002.jpg")
114
+ if (resolved.kind !== "container") throw new Error("expected container")
115
+ const chunks: Buffer[] = []
116
+ for await (const chunk of resolved.openStream()) {
117
+ chunks.push(Buffer.from(chunk))
118
+ }
119
+ expect(Buffer.concat(chunks)).toEqual(Buffer.from([4, 5]))
120
+ })
121
+
122
+ it("streams DEFLATE entry bytes", async () => {
123
+ const resolver = createNestedResolver(outerFrom({ "d.zip": deflated }))
124
+ const resolved = await resolver.resolve("d.zip!page.png")
125
+ if (resolved.kind !== "container") throw new Error("expected container")
126
+ const chunks: Buffer[] = []
127
+ for await (const chunk of resolved.openStream()) {
128
+ chunks.push(Buffer.from(chunk))
129
+ }
130
+ expect(Buffer.concat(chunks)).toEqual(
131
+ Buffer.from([9, 9, 9, 9, 9, 9, 9, 9, 9, 9]),
132
+ )
133
+ expect(resolved.entry.sizeBytes).toBe(10)
134
+ })
135
+
136
+ it("does not address tar archives as nested containers", async () => {
137
+ const resolver = createNestedResolver(outer)
138
+ const resolved = await resolver.resolve("tome.tar!Ch1/001.jpg")
139
+ expect(resolved.kind).toBe("literal")
140
+ expect(await resolver.list("tome.tar")).toBeUndefined()
141
+ })
142
+
143
+ it("falls back to literal when the inner entry is missing", async () => {
144
+ const resolver = createNestedResolver(outer)
145
+ const resolved = await resolver.resolve("book.cbz!missing.jpg")
146
+ expect(resolved.kind).toBe("literal")
147
+ })
148
+
149
+ it("falls back to literal when the outer is not a container", async () => {
150
+ const resolver = createNestedResolver(outer)
151
+ const resolved = await resolver.resolve("flat.png!x")
152
+ expect(resolved.kind).toBe("literal")
153
+ })
154
+
155
+ it("keeps literal files whose names contain the separator", async () => {
156
+ const resolver = createNestedResolver(outer)
157
+ const resolved = await resolver.resolve("weird!name.txt")
158
+ expect(resolved.kind).toBe("literal")
159
+ })
160
+
161
+ it("lists container entries memoized per outer", async () => {
162
+ const resolver = createNestedResolver(outer)
163
+ const first = await resolver.list("book.cbz")
164
+ const second = await resolver.list("book.cbz")
165
+ expect(first?.map((e) => e.name)).toEqual([
166
+ "Ch1/001.jpg",
167
+ "Ch1/002.jpg",
168
+ "Ch2/001.jpg",
169
+ ])
170
+ expect(second).toBe(first)
171
+ expect(await resolver.list("flat.png")).toBeUndefined()
172
+ })
173
+
174
+ it("rejects encrypted entries with a clear error", async () => {
175
+ // DEFLATE, not STORED: for stored files yauzl rejects the
176
+ // inconsistent sizes at central-directory parse time, so the
177
+ // per-entry encrypted rejection could never fire.
178
+ const encrypted = makeZip([
179
+ {
180
+ name: "page.png",
181
+ data: Uint8Array.from([1, 2, 3]),
182
+ method: 8,
183
+ encrypted: true,
184
+ },
185
+ ])
186
+ const resolver = createNestedResolver(
187
+ outerFrom({ "locked.zip": encrypted }),
188
+ )
189
+ const resolved = await resolver.resolve("locked.zip!page.png")
190
+ expect(resolved.kind).toBe("container")
191
+ if (resolved.kind !== "container") return
192
+ expect(() => resolved.openStream()).toThrow(/encrypted/)
193
+ })
194
+ })
@@ -0,0 +1,254 @@
1
+ import type { Readable } from "node:stream"
2
+ import { invalid } from "../errors.ts"
3
+ import { SNIFF_WINDOW_BYTES, sniffContainerFormat } from "./format.ts"
4
+ import type { NestedCdCache } from "./nested-cd-cache.ts"
5
+ import {
6
+ type ArchiveSource,
7
+ listZipEntriesFromSource,
8
+ openZipEntryStream,
9
+ } from "./zip-entries.ts"
10
+
11
+ /**
12
+ * Container-entry addressing: plugin hooks (and the server view) may
13
+ * reference a file *inside* a container entry with the virtual path
14
+ * `outer!inner` — e.g. `manga.cbz!Chapter 1/001.jpg`. This module owns
15
+ * the resolution rules and the zip listing (format identification
16
+ * lives in `format.ts`); nested containers reuse `zip-entries.ts` over
17
+ * a range source. Zip is the only addressable nested format —
18
+ * tar/7z/rar/xz/gzip are whole-archive formats (extractable via 7-Zip,
19
+ * not addressable by single entry).
20
+ *
21
+ * Ambiguity rule: `outer` is treated as a container only when its magic
22
+ * bytes say so (zip) AND the archive actually contains `inner`;
23
+ * otherwise the whole path is the literal container entry. A file whose
24
+ * name contains `!` therefore stays addressable.
25
+ */
26
+
27
+ /** The virtual-path separator between an outer entry and an inner one. */
28
+ export const VIRTUAL_PATH_SEPARATOR = "!"
29
+
30
+ /**
31
+ * Read interface an outer container must satisfy. Both the plugin-side
32
+ * `ResourceContainer` and the server's artifact view provide these — the
33
+ * virtual resolver is deliberately minimal so it composes with either.
34
+ */
35
+ export type OuterEntrySource = {
36
+ /** Byte length of a literal entry; `undefined` when absent. */
37
+ readonly sizeOf: (rel: string) => Promise<number | undefined>
38
+ /** Read `[start, end)` of a literal entry (`end` exclusive). */
39
+ readonly readSlice: (
40
+ rel: string,
41
+ start: number,
42
+ end: number,
43
+ ) => Promise<Buffer>
44
+ }
45
+
46
+ /** A unified, format-agnostic inner entry. */
47
+ export type ArchiveEntry = {
48
+ readonly name: string
49
+ readonly sizeBytes: number
50
+ }
51
+
52
+ /** An inner entry plus its stream opener (format handles attached). */
53
+ export type OpenableArchiveEntry = ArchiveEntry & {
54
+ readonly openStream: () => Readable
55
+ }
56
+
57
+ export type PathResolution =
58
+ | { readonly kind: "literal" }
59
+ | {
60
+ readonly kind: "container"
61
+ readonly outer: string
62
+ readonly entry: ArchiveEntry
63
+ /** Decompressed bytes of the inner entry. */
64
+ readonly openStream: () => Readable
65
+ }
66
+
67
+ export type NestedResolver = {
68
+ /**
69
+ * Resolve `path` against the outer container. `kind: "container"`
70
+ * means the path addressed a file inside a nested zip/tar; anything
71
+ * else keeps the literal semantics (a plain entry, possibly one whose
72
+ * name happens to contain `!`).
73
+ */
74
+ readonly resolve: (path: string) => Promise<PathResolution>
75
+ /**
76
+ * List every file entry of the container `outer`. Resolves to
77
+ * `undefined` when `outer` is not a container (or does not exist).
78
+ * Memoized per outer name for the resolver's lifetime.
79
+ */
80
+ readonly list: (
81
+ outer: string,
82
+ ) => Promise<readonly OpenableArchiveEntry[] | undefined>
83
+ }
84
+
85
+ /** Split `outer!inner` at the first separator. */
86
+ export function splitVirtualPath(path: string):
87
+ | {
88
+ readonly outer: string
89
+ readonly inner: string
90
+ }
91
+ | undefined {
92
+ const sep = path.indexOf(VIRTUAL_PATH_SEPARATOR)
93
+ if (sep <= 0) return undefined
94
+ const inner = path.slice(sep + 1)
95
+ if (inner.length === 0) return undefined
96
+ return { outer: path.slice(0, sep), inner }
97
+ }
98
+
99
+ /** Wrap an outer container entry as a {@link ArchiveSource} (real size). */
100
+ export function createOuterArchiveSource(
101
+ outer: OuterEntrySource,
102
+ outerName: string,
103
+ size: number,
104
+ ): ArchiveSource {
105
+ return {
106
+ size,
107
+ readRange: (start, end) => outer.readSlice(outerName, start, end + 1),
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Build a resolver over one outer container. Archive listings are
113
+ * memoized per outer entry name for the resolver's lifetime (a
114
+ * resource-level operation parses each nested archive at most once),
115
+ * and — when a {@link NestedCdCache} is provided — across resolver
116
+ * instances, so a multi-hook pass parses each nested archive once.
117
+ *
118
+ * `scope` disambiguates cache keys when one shared cache serves many
119
+ * containers (e.g. every resource of a server): keys become
120
+ * `<scope>:<outerName>`, so two resources holding same-named archives
121
+ * never serve each other's listings.
122
+ */
123
+ export function createNestedResolver(
124
+ outer: OuterEntrySource,
125
+ opts: { readonly cdCache?: NestedCdCache; readonly scope?: string } = {},
126
+ ): NestedResolver {
127
+ const cdCache = opts.cdCache
128
+ const scope = opts.scope
129
+ const cacheKey = (outerName: string): string =>
130
+ scope === undefined ? outerName : `${scope}:${outerName}`
131
+ const listingMemo = new Map<
132
+ string,
133
+ Promise<readonly ListedEntry[] | undefined>
134
+ >()
135
+ const listedMemo = new Map<
136
+ string,
137
+ Promise<readonly OpenableArchiveEntry[] | undefined>
138
+ >()
139
+
140
+ function listOuter(
141
+ outerName: string,
142
+ ): Promise<readonly ListedEntry[] | undefined> {
143
+ const key = cacheKey(outerName)
144
+ const pending = listingMemo.get(key)
145
+ if (pending !== undefined) return pending
146
+ const shared = cdCache?.get(key) as
147
+ | Promise<readonly ListedEntry[] | undefined>
148
+ | undefined
149
+ if (shared !== undefined) {
150
+ listingMemo.set(key, shared)
151
+ return shared
152
+ }
153
+ const work = listOuterOnce(outerName)
154
+ listingMemo.set(key, work)
155
+ cdCache?.set(key, work)
156
+ void work.then(
157
+ () => undefined,
158
+ () => undefined,
159
+ )
160
+ return work
161
+ }
162
+
163
+ async function listOuterOnce(
164
+ outerName: string,
165
+ ): Promise<readonly ListedEntry[] | undefined> {
166
+ const size = await outer.sizeOf(outerName)
167
+ if (size === undefined) return undefined
168
+ if (size === 0) return undefined
169
+ const head = await outer.readSlice(
170
+ outerName,
171
+ 0,
172
+ Math.min(size, SNIFF_WINDOW_BYTES),
173
+ )
174
+ const format = sniffContainerFormat(head)
175
+ // Zip is the only addressable nested format; tar/7z/rar/xz/gzip
176
+ // are whole-archive formats and report "not a container".
177
+ if (format !== "zip") return undefined
178
+ const source = createOuterArchiveSource(outer, outerName, size)
179
+ const records = await listZipEntriesFromSource(source)
180
+ return records.map((r) => ({
181
+ name: r.name,
182
+ sizeBytes: r.uncompressedSize,
183
+ outerSize: size,
184
+ zip: r,
185
+ }))
186
+ }
187
+
188
+ async function resolve(path: string): Promise<PathResolution> {
189
+ const parts = splitVirtualPath(path)
190
+ if (parts === undefined) return { kind: "literal" }
191
+ const { outer: outerName, inner } = parts
192
+ const entries = await listOuter(outerName)
193
+ const found = entries?.find((e) => e.name === inner)
194
+ if (found === undefined) return { kind: "literal" }
195
+ return {
196
+ kind: "container",
197
+ outer: outerName,
198
+ entry: { name: found.name, sizeBytes: found.sizeBytes },
199
+ openStream: () => openListedEntry(outer, outerName, found),
200
+ }
201
+ }
202
+
203
+ async function list(
204
+ outerName: string,
205
+ ): Promise<readonly OpenableArchiveEntry[] | undefined> {
206
+ const pending = listedMemo.get(outerName)
207
+ if (pending !== undefined) return pending
208
+ const work = listOuter(outerName).then((entries) =>
209
+ entries === undefined
210
+ ? undefined
211
+ : entries.map((e) => listedWithStream(outer, outerName, e)),
212
+ )
213
+ listedMemo.set(outerName, work)
214
+ void work.then(
215
+ () => undefined,
216
+ () => undefined,
217
+ )
218
+ return work
219
+ }
220
+
221
+ return { resolve, list }
222
+ }
223
+
224
+ type ListedEntry = ArchiveEntry & {
225
+ /** Byte size of the outer container entry. */
226
+ readonly outerSize: number
227
+ readonly zip?: import("./zip-entries.ts").ZipEntry
228
+ }
229
+
230
+ function listedWithStream(
231
+ outer: OuterEntrySource,
232
+ outerName: string,
233
+ entry: ListedEntry,
234
+ ): OpenableArchiveEntry {
235
+ return {
236
+ name: entry.name,
237
+ sizeBytes: entry.sizeBytes,
238
+ openStream: () => openListedEntry(outer, outerName, entry),
239
+ }
240
+ }
241
+
242
+ function openListedEntry(
243
+ outer: OuterEntrySource,
244
+ outerName: string,
245
+ entry: ListedEntry,
246
+ ): Readable {
247
+ const source = createOuterArchiveSource(outer, outerName, entry.outerSize)
248
+ if (entry.zip !== undefined) return openZipEntryStream(source, entry.zip)
249
+ throw invalid(
250
+ "resource.archive_open_failed",
251
+ `entry "${entry.name}" has no stream backend`,
252
+ { name: entry.name },
253
+ )
254
+ }
@@ -0,0 +1,40 @@
1
+ import type { Readable } from "node:stream"
2
+ import yazl from "yazl"
3
+
4
+ /**
5
+ * On-the-fly zip exports. Resource sources are stored as bare files on
6
+ * disk — nothing is packed at commit time; this module streams STORED
7
+ * zip bytes straight to the consumer without a staging file.
8
+ */
9
+
10
+ /**
11
+ * A logical zip entry whose bytes are produced on demand. Streams are
12
+ * read once, in order, when the output is consumed.
13
+ */
14
+ export type ZipStreamEntry = {
15
+ readonly name: string
16
+ readonly size: number
17
+ readonly openStream: () => Readable
18
+ }
19
+
20
+ /**
21
+ * Stream a STORED zip from an ordered list of logical entries. Used by
22
+ * the HTTP layer for exports (resource source packs, bulk downloads)
23
+ * without a staging file on disk. Zero-length entries are packed as
24
+ * empty buffers.
25
+ */
26
+ export function streamStoredZip(entries: readonly ZipStreamEntry[]) {
27
+ const zip = new yazl.ZipFile()
28
+ for (const entry of entries) {
29
+ if (entry.size === 0) {
30
+ zip.addBuffer(Buffer.alloc(0), entry.name, { compress: false })
31
+ continue
32
+ }
33
+ zip.addReadStream(entry.openStream(), entry.name, {
34
+ compress: false,
35
+ size: entry.size,
36
+ })
37
+ }
38
+ zip.end()
39
+ return zip.outputStream
40
+ }
@@ -0,0 +1,56 @@
1
+ import { execSync } from "node:child_process"
2
+ import {
3
+ closeSync,
4
+ openSync,
5
+ truncateSync,
6
+ unlinkSync,
7
+ writeSync,
8
+ } from "node:fs"
9
+ import { tmpdir } from "node:os"
10
+ import { join } from "node:path"
11
+ import { afterEach, describe, expect, test } from "vitest"
12
+ import { readFileRange } from "./zip-entries.ts"
13
+
14
+ const INT32_MAX = 2_147_483_647
15
+
16
+ describe("readFileRange", () => {
17
+ const paths: string[] = []
18
+
19
+ afterEach(() => {
20
+ for (const path of paths.splice(0)) {
21
+ try {
22
+ unlinkSync(path)
23
+ } catch {
24
+ // already removed
25
+ }
26
+ }
27
+ })
28
+
29
+ // Generous timeout: without the sparse flag, the positional write past
30
+ // the 2 GiB mark makes NTFS zero-fill the gap synchronously.
31
+ test("reads bytes beyond the Int32 position limit", {
32
+ timeout: 120_000,
33
+ }, async () => {
34
+ const path = join(tmpdir(), `hoard-read-range-${Date.now()}.bin`)
35
+ paths.push(path)
36
+ const fd = openSync(path, "w")
37
+ closeSync(fd)
38
+ if (process.platform === "win32") {
39
+ // Skip the ~2 GiB zero-fill NTFS would otherwise write on the
40
+ // positional write below. Best effort — fsutil needs elevation,
41
+ // and the plain write still works (slowly) without it.
42
+ try {
43
+ execSync(`fsutil sparse setflag "${path}"`)
44
+ } catch {
45
+ // fall back to the slow fill
46
+ }
47
+ }
48
+ truncateSync(path, INT32_MAX + 64)
49
+ const rw = openSync(path, "r+")
50
+ writeSync(rw, Buffer.from("0123456789"), 0, 10, INT32_MAX + 1)
51
+ closeSync(rw)
52
+
53
+ const buf = await readFileRange(path, INT32_MAX + 1, INT32_MAX + 10)
54
+ expect(buf.toString("utf8")).toBe("0123456789")
55
+ })
56
+ })