memory-extract 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 semigarden
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
File without changes
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "memory-extract",
3
+ "description": "Tools for packing interactive scenes into PNG images.",
4
+ "license": "MIT",
5
+ "version": "0.1.0",
6
+ "author": "semigarden",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/semigarden/memory-extract.git"
10
+ },
11
+ "type": "module",
12
+ "files": [
13
+ "src",
14
+ "tools",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "exports": {
19
+ ".": "./src/index.js",
20
+ "./node": "./tools/memoryFormat.mjs",
21
+ "./payload": "./src/payloadFormat.js"
22
+ },
23
+ "bin": {
24
+ "memory-pack": "./tools/pack.mjs",
25
+ "memory-unpack": "./tools/unpack.mjs"
26
+ },
27
+ "scripts": {
28
+ "pack": "node tools/pack.mjs",
29
+ "unpack": "node tools/unpack.mjs",
30
+ "test": "node --test test/memory.test.mjs"
31
+ },
32
+ "dependencies": {
33
+ "sharp": "^0.34.5"
34
+ }
35
+ }
package/src/index.js ADDED
@@ -0,0 +1,25 @@
1
+ export {
2
+ MEMORY_MAGIC,
3
+ MEMORY_CHUNK_TYPE,
4
+ MEMORY_VERSION,
5
+ SUPPORTED_VERSIONS,
6
+ MAX_PAYLOAD_SIZE,
7
+ decodeManifest,
8
+ decodeManifestFile,
9
+ decodeMemoryPayload,
10
+ extractMemory,
11
+ listManifestFiles,
12
+ readMemoryFile,
13
+ } from "./memoryFormat.js";
14
+
15
+ export {
16
+ assertSupportedVersion,
17
+ buildV2Manifest,
18
+ decodeV2Archive,
19
+ encodeV2Archive,
20
+ guessMimeType,
21
+ readManifestFile,
22
+ resolveSafePath,
23
+ } from "./payloadFormat.js";
24
+
25
+ export { createMemoryBlob } from "./loadMemory.js";
@@ -0,0 +1,198 @@
1
+ import {
2
+ listManifestFiles,
3
+ readMemoryFile,
4
+ extractMemory,
5
+ } from "./memoryFormat.js";
6
+
7
+ const textDecoder = new TextDecoder();
8
+
9
+ const REWRITABLE_ATTRIBUTES = [
10
+ ["script", "src"],
11
+ ["link", "href"],
12
+ ["img", "src"],
13
+ ["source", "src"],
14
+ ["video", "src"],
15
+ ["audio", "src"],
16
+ ["image", "href"],
17
+ ];
18
+
19
+ const CSS_URL_PATTERN = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
20
+
21
+ const readInput = async (input) => {
22
+ if (input instanceof Uint8Array) {
23
+ return input;
24
+ }
25
+
26
+ if (input instanceof ArrayBuffer) {
27
+ return new Uint8Array(input);
28
+ }
29
+
30
+ if (input instanceof Blob) {
31
+ return new Uint8Array(await input.arrayBuffer());
32
+ }
33
+
34
+ throw new Error("Expected a PNG memory image.");
35
+ };
36
+
37
+ const normalizeAssetPath = (value) => value.replace(/^\.\//, "");
38
+
39
+ const guessMimeType = (filePath, record) => {
40
+ if (record?.mime) return record.mime;
41
+ if (filePath.endsWith(".html")) return "text/html";
42
+ if (filePath.endsWith(".css")) return "text/css";
43
+ if (filePath.endsWith(".js")) return "text/javascript";
44
+ if (filePath.endsWith(".svg")) return "image/svg+xml";
45
+ if (filePath.endsWith(".png")) return "image/png";
46
+ if (filePath.endsWith(".json")) return "application/json";
47
+ if (filePath.endsWith(".woff2")) return "font/woff2";
48
+ if (filePath.endsWith(".woff")) return "font/woff";
49
+ return "application/octet-stream";
50
+ };
51
+
52
+ const resolveAssetUrl = (rawPath, urlByPath) => {
53
+ if (!rawPath) return null;
54
+ if (/^(?:[a-z]+:|\/\/|#|data:)/i.test(rawPath)) return null;
55
+
56
+ const normalized = normalizeAssetPath(rawPath);
57
+ return urlByPath.get(normalized) ?? urlByPath.get(rawPath) ?? null;
58
+ };
59
+
60
+ const resolveManifestPath = (basePath, rawPath, manifestPaths) => {
61
+ const manifestSet = new Set(manifestPaths);
62
+ const normalized = normalizeAssetPath(rawPath);
63
+
64
+ if (manifestSet.has(normalized)) {
65
+ return normalized;
66
+ }
67
+
68
+ if (manifestSet.has(rawPath)) {
69
+ return rawPath;
70
+ }
71
+
72
+ if (!basePath.includes("/")) {
73
+ return manifestSet.has(normalized) ? normalized : null;
74
+ }
75
+
76
+ const baseDir = basePath.slice(0, basePath.lastIndexOf("/"));
77
+ const joined = normalizeAssetPath(`${baseDir}/${normalized}`);
78
+
79
+ return manifestSet.has(joined) ? joined : null;
80
+ };
81
+
82
+ const extractCssAssetPaths = (cssText) => {
83
+ const paths = [];
84
+ for (const match of cssText.matchAll(CSS_URL_PATTERN)) {
85
+ paths.push(match[2]);
86
+ }
87
+ return paths;
88
+ };
89
+
90
+ const collectRequiredFiles = (manifest, fileBytes) => {
91
+ const manifestPaths = listManifestFiles(manifest);
92
+ const required = new Set([manifest.entry]);
93
+
94
+ for (const filePath of manifestPaths) {
95
+ if (filePath.endsWith(".js") || filePath.endsWith(".css")) {
96
+ required.add(filePath);
97
+ }
98
+ }
99
+
100
+ let previousSize = 0;
101
+ while (required.size !== previousSize) {
102
+ previousSize = required.size;
103
+
104
+ for (const filePath of [...required]) {
105
+ const bytes = readMemoryFile(manifest, filePath, fileBytes);
106
+ const text = textDecoder.decode(bytes);
107
+ const refs = [];
108
+
109
+ if (filePath.endsWith(".html")) {
110
+ const doc = new DOMParser().parseFromString(text, "text/html");
111
+ REWRITABLE_ATTRIBUTES.forEach(([tag, attribute]) => {
112
+ doc.querySelectorAll(`${tag}[${attribute}]`).forEach((element) => {
113
+ refs.push(element.getAttribute(attribute));
114
+ });
115
+ });
116
+ }
117
+
118
+ if (filePath.endsWith(".css")) {
119
+ refs.push(...extractCssAssetPaths(text));
120
+ }
121
+
122
+ refs.forEach((ref) => {
123
+ const resolved = resolveManifestPath(filePath, ref, manifestPaths);
124
+ if (resolved) {
125
+ required.add(resolved);
126
+ }
127
+ });
128
+ }
129
+ }
130
+
131
+ return required;
132
+ };
133
+
134
+ const buildLaunchDocument = (html, urlByPath) => {
135
+ const doc = new DOMParser().parseFromString(html, "text/html");
136
+
137
+ REWRITABLE_ATTRIBUTES.forEach(([tag, attribute]) => {
138
+ doc.querySelectorAll(`${tag}[${attribute}]`).forEach((element) => {
139
+ const rawPath = element.getAttribute(attribute);
140
+ const assetUrl = resolveAssetUrl(rawPath, urlByPath);
141
+
142
+ if (assetUrl) {
143
+ element.setAttribute(attribute, assetUrl);
144
+ }
145
+
146
+ element.removeAttribute("crossorigin");
147
+ });
148
+ });
149
+
150
+ doc.documentElement.querySelectorAll("[crossorigin]").forEach((element) => {
151
+ element.removeAttribute("crossorigin");
152
+ });
153
+
154
+ return `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
155
+ };
156
+
157
+ const getManifestFileMeta = (manifest, filePath) => {
158
+ if (Array.isArray(manifest.files)) {
159
+ return manifest.files.find((file) => file.path === filePath) ?? null;
160
+ }
161
+
162
+ return manifest.files?.[filePath] ?? null;
163
+ };
164
+
165
+ export const createMemoryBlob = async (input) => {
166
+ const bytes = await readInput(input);
167
+ const { manifest, fileBytes } = await extractMemory(bytes);
168
+ const blobUrls = [];
169
+ const urlByPath = new Map();
170
+ const requiredFiles = collectRequiredFiles(manifest, fileBytes);
171
+
172
+ for (const filePath of requiredFiles) {
173
+ const fileMeta = getManifestFileMeta(manifest, filePath);
174
+ const fileContent = readMemoryFile(manifest, filePath, fileBytes);
175
+ const mime = guessMimeType(filePath, fileMeta);
176
+ const assetBlob = new Blob([fileContent], { type: mime });
177
+ const assetUrl = URL.createObjectURL(assetBlob);
178
+ blobUrls.push(assetUrl);
179
+ urlByPath.set(filePath, assetUrl);
180
+ urlByPath.set(normalizeAssetPath(filePath), assetUrl);
181
+ }
182
+
183
+ if (!requiredFiles.has(manifest.entry)) {
184
+ blobUrls.forEach((url) => URL.revokeObjectURL(url));
185
+ throw new Error(`Manifest entry not found: ${manifest.entry}`);
186
+ }
187
+
188
+ const htmlBytes = readMemoryFile(manifest, manifest.entry, fileBytes);
189
+ const html = textDecoder.decode(htmlBytes);
190
+ const launchDocument = buildLaunchDocument(html, urlByPath);
191
+ const launchBlob = new Blob([launchDocument], { type: "text/html" });
192
+
193
+ launchBlob.dispose = () => {
194
+ blobUrls.forEach((url) => URL.revokeObjectURL(url));
195
+ };
196
+
197
+ return launchBlob;
198
+ };
@@ -0,0 +1,213 @@
1
+ import {
2
+ assertSupportedVersion,
3
+ decodeV2Archive,
4
+ listManifestFiles,
5
+ MAX_PAYLOAD_SIZE,
6
+ MEMORY_VERSION,
7
+ readManifestFile,
8
+ SUPPORTED_VERSIONS,
9
+ } from "./payloadFormat.js";
10
+
11
+ export {
12
+ assertSupportedVersion,
13
+ listManifestFiles,
14
+ MAX_PAYLOAD_SIZE,
15
+ MEMORY_VERSION,
16
+ readManifestFile,
17
+ SUPPORTED_VERSIONS,
18
+ } from "./payloadFormat.js";
19
+
20
+ export const MEMORY_MAGIC = new Uint8Array([0x57, 0x4c, 0x46, 0x43]); // WLFC
21
+ export const MEMORY_CHUNK_TYPE = new Uint8Array([0x77, 0x4c, 0x46, 0x43]); // wLFC
22
+
23
+ const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
24
+ const textDecoder = new TextDecoder();
25
+
26
+ const bytesMatch = (view, offset, magic) => {
27
+ if (offset < 0 || offset + magic.length > view.length) return false;
28
+ for (let index = 0; index < magic.length; index += 1) {
29
+ if (view[offset + index] !== magic[index]) return false;
30
+ }
31
+ return true;
32
+ };
33
+
34
+ const readUint32BE = (view, offset) =>
35
+ (view[offset] << 24) |
36
+ (view[offset + 1] << 16) |
37
+ (view[offset + 2] << 8) |
38
+ view[offset + 3];
39
+
40
+ const parsePngChunks = (view) => {
41
+ if (view.length < 8 || !bytesMatch(view, 0, PNG_SIGNATURE)) {
42
+ throw new Error("Invalid PNG signature.");
43
+ }
44
+
45
+ const chunks = [];
46
+ let offset = 8;
47
+
48
+ while (offset + 12 <= view.length) {
49
+ const length = readUint32BE(view, offset);
50
+ const type = view.slice(offset + 4, offset + 8);
51
+ const dataStart = offset + 8;
52
+ const dataEnd = dataStart + length;
53
+
54
+ if (dataEnd + 4 > view.length) {
55
+ throw new Error("PNG chunk exceeds file size.");
56
+ }
57
+
58
+ chunks.push({
59
+ type: String.fromCharCode(...type),
60
+ data: view.slice(dataStart, dataEnd),
61
+ start: offset,
62
+ end: dataEnd + 4,
63
+ });
64
+
65
+ offset = dataEnd + 4;
66
+
67
+ if (chunks[chunks.length - 1].type === "IEND") {
68
+ break;
69
+ }
70
+ }
71
+
72
+ return { view, chunks, trailing: view.slice(offset) };
73
+ };
74
+
75
+ const readMemoryPayload = (chunkData) => {
76
+ if (chunkData.length < 8 || !bytesMatch(chunkData, 0, MEMORY_MAGIC)) {
77
+ throw new Error("Memory chunk magic mismatch.");
78
+ }
79
+
80
+ const version = readUint32BE(chunkData, 4);
81
+ assertSupportedVersion(version);
82
+
83
+ const payload = chunkData.slice(8);
84
+ if (payload.length > MAX_PAYLOAD_SIZE) {
85
+ throw new Error(`Memory payload exceeds ${MAX_PAYLOAD_SIZE} bytes.`);
86
+ }
87
+
88
+ return { version, payload };
89
+ };
90
+
91
+ const gunzip = async (payload) => {
92
+ const stream = new Blob([payload])
93
+ .stream()
94
+ .pipeThrough(new DecompressionStream("gzip"));
95
+ const buffer = await new Response(stream).arrayBuffer();
96
+ return new Uint8Array(buffer);
97
+ };
98
+
99
+ export const decodeManifest = async (payload) => {
100
+ const decompressed = await gunzip(payload);
101
+ return JSON.parse(textDecoder.decode(decompressed));
102
+ };
103
+
104
+ export const decodeMemoryPayload = async (payload, version) => {
105
+ assertSupportedVersion(version);
106
+
107
+ if (version === 1) {
108
+ return {
109
+ manifest: await decodeManifest(payload),
110
+ fileBytes: null,
111
+ };
112
+ }
113
+
114
+ return decodeV2Archive(await gunzip(payload));
115
+ };
116
+
117
+ const extractLegacyFooter = async (view) => {
118
+ let magicIndex = -1;
119
+ for (let index = view.length - MEMORY_MAGIC.length; index >= 0; index -= 1) {
120
+ if (bytesMatch(view, index, MEMORY_MAGIC)) {
121
+ magicIndex = index;
122
+ break;
123
+ }
124
+ }
125
+
126
+ if (magicIndex < 0) {
127
+ throw new Error("Memory footer not found.");
128
+ }
129
+
130
+ const version = readUint32BE(view, magicIndex + 4);
131
+ const payloadLength = readUint32BE(view, magicIndex + 8);
132
+ const payloadStart = magicIndex + 12;
133
+ const payloadEnd = payloadStart + payloadLength;
134
+
135
+ if (version !== 1) {
136
+ throw new Error(`Unsupported legacy memory version: ${version}`);
137
+ }
138
+
139
+ if (payloadEnd > view.length) {
140
+ throw new Error("Memory payload length exceeds file size.");
141
+ }
142
+
143
+ const payload = view.slice(payloadStart, payloadEnd);
144
+ const decoded = await decodeMemoryPayload(payload, version);
145
+
146
+ return {
147
+ png: view.slice(0, magicIndex),
148
+ payload,
149
+ ...decoded,
150
+ version,
151
+ };
152
+ };
153
+
154
+ const decodeExtractedMemory = async (png, payload, version) => {
155
+ const decoded = await decodeMemoryPayload(payload, version);
156
+
157
+ return {
158
+ png,
159
+ payload,
160
+ ...decoded,
161
+ version,
162
+ };
163
+ };
164
+
165
+ export const extractMemory = async (input) => {
166
+ const view = input instanceof Uint8Array ? input : new Uint8Array(input);
167
+
168
+ if (view.length < 12) {
169
+ throw new Error("File is too small to be a memory PNG.");
170
+ }
171
+
172
+ try {
173
+ const { view: pngView, chunks, trailing } = parsePngChunks(view);
174
+ const memoryChunk = chunks.find((chunk) => chunk.type === "wLFC");
175
+
176
+ if (memoryChunk) {
177
+ const { version, payload } = readMemoryPayload(memoryChunk.data);
178
+ return decodeExtractedMemory(pngView, payload, version);
179
+ }
180
+
181
+ if (trailing.length > 0) {
182
+ return extractLegacyFooter(view);
183
+ }
184
+ } catch (error) {
185
+ if (error.message?.includes("Memory footer not found")) {
186
+ throw error;
187
+ }
188
+
189
+ return extractLegacyFooter(view);
190
+ }
191
+
192
+ throw new Error("Memory chunk not found.");
193
+ };
194
+
195
+ export const decodeManifestFile = (fileRecord) => {
196
+ if (!fileRecord?.data) {
197
+ throw new Error("Invalid manifest file record.");
198
+ }
199
+
200
+ if (typeof Uint8Array.fromBase64 === "function") {
201
+ return Uint8Array.fromBase64(fileRecord.data, { alphabet: "base64" });
202
+ }
203
+
204
+ const binary = atob(fileRecord.data);
205
+ const bytes = new Uint8Array(binary.length);
206
+ for (let index = 0; index < binary.length; index += 1) {
207
+ bytes[index] = binary.charCodeAt(index);
208
+ }
209
+ return bytes;
210
+ };
211
+
212
+ export const readMemoryFile = (manifest, filePath, fileBytes = null) =>
213
+ readManifestFile(manifest, filePath, fileBytes, decodeManifestFile);
@@ -0,0 +1,171 @@
1
+ export const MEMORY_VERSION = 2;
2
+ export const SUPPORTED_VERSIONS = [1, 2];
3
+ export const MAX_PAYLOAD_SIZE = 64 * 1024 * 1024;
4
+
5
+ export const readUint32BE = (view, offset) =>
6
+ (view[offset] << 24) |
7
+ (view[offset + 1] << 16) |
8
+ (view[offset + 2] << 8) |
9
+ view[offset + 3];
10
+
11
+ export const writeUint32BE = (view, offset, value) => {
12
+ view[offset] = (value >>> 24) & 0xff;
13
+ view[offset + 1] = (value >>> 16) & 0xff;
14
+ view[offset + 2] = (value >>> 8) & 0xff;
15
+ view[offset + 3] = value & 0xff;
16
+ };
17
+
18
+ export const assertSupportedVersion = (version) => {
19
+ if (!SUPPORTED_VERSIONS.includes(version)) {
20
+ throw new Error(`Unsupported memory version: ${version}`);
21
+ }
22
+ };
23
+
24
+ export const guessMimeType = (filePath) => {
25
+ const lower = filePath.toLowerCase();
26
+
27
+ if (lower.endsWith(".html")) return "text/html";
28
+ if (lower.endsWith(".css")) return "text/css";
29
+ if (lower.endsWith(".js")) return "text/javascript";
30
+ if (lower.endsWith(".svg")) return "image/svg+xml";
31
+ if (lower.endsWith(".png")) return "image/png";
32
+ if (lower.endsWith(".json")) return "application/json";
33
+ if (lower.endsWith(".woff2")) return "font/woff2";
34
+ if (lower.endsWith(".woff")) return "font/woff";
35
+ return "application/octet-stream";
36
+ };
37
+
38
+ export const listManifestFiles = (manifest) => {
39
+ if (Array.isArray(manifest.files)) {
40
+ return manifest.files.map((file) => file.path);
41
+ }
42
+
43
+ return Object.keys(manifest.files ?? {});
44
+ };
45
+
46
+ export const readManifestFile = (
47
+ manifest,
48
+ filePath,
49
+ fileBytes = null,
50
+ decodeV1File = null
51
+ ) => {
52
+ if (fileBytes?.[filePath]) {
53
+ return fileBytes[filePath];
54
+ }
55
+
56
+ const record = manifest.files?.[filePath];
57
+ if (record && decodeV1File) {
58
+ return decodeV1File(record);
59
+ }
60
+
61
+ throw new Error(`Manifest file not found: ${filePath}`);
62
+ };
63
+
64
+ const concatBytes = (parts) => {
65
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
66
+ const output = new Uint8Array(total);
67
+ let offset = 0;
68
+
69
+ for (const part of parts) {
70
+ output.set(part, offset);
71
+ offset += part.length;
72
+ }
73
+
74
+ return output;
75
+ };
76
+
77
+ const toUint8Array = (value) =>
78
+ value instanceof Uint8Array ? value : new Uint8Array(value);
79
+
80
+ export const buildV2Manifest = ({
81
+ name,
82
+ entry,
83
+ files,
84
+ kind = "web-app",
85
+ runtime = "iframe-sandbox",
86
+ }) => {
87
+ const sortedPaths = Object.keys(files).sort();
88
+
89
+ return {
90
+ manifest: {
91
+ v: 2,
92
+ kind,
93
+ name,
94
+ entry,
95
+ runtime,
96
+ files: sortedPaths.map((filePath) => {
97
+ const bytes = toUint8Array(files[filePath]);
98
+ return {
99
+ path: filePath,
100
+ mime: guessMimeType(filePath),
101
+ size: bytes.length,
102
+ };
103
+ }),
104
+ },
105
+ sortedPaths,
106
+ };
107
+ };
108
+
109
+ export const encodeV2Archive = ({ name, entry, files, kind, runtime }) => {
110
+ const { manifest, sortedPaths } = buildV2Manifest({
111
+ name,
112
+ entry,
113
+ files,
114
+ kind,
115
+ runtime,
116
+ });
117
+ const json = new TextEncoder().encode(JSON.stringify(manifest));
118
+ const header = new Uint8Array(4);
119
+ writeUint32BE(header, 0, json.length);
120
+
121
+ return concatBytes([
122
+ header,
123
+ json,
124
+ ...sortedPaths.map((filePath) => toUint8Array(files[filePath])),
125
+ ]);
126
+ };
127
+
128
+ export const decodeV2Archive = (decompressed) => {
129
+ const view = toUint8Array(decompressed);
130
+ const jsonLength = readUint32BE(view, 0);
131
+ const jsonStart = 4;
132
+ const jsonEnd = jsonStart + jsonLength;
133
+
134
+ if (jsonEnd > view.length) {
135
+ throw new Error("Memory manifest length exceeds payload size.");
136
+ }
137
+
138
+ const manifest = JSON.parse(new TextDecoder().decode(view.slice(jsonStart, jsonEnd)));
139
+ const fileBytes = {};
140
+ let offset = jsonEnd;
141
+
142
+ for (const file of manifest.files ?? []) {
143
+ const end = offset + file.size;
144
+ if (end > view.length) {
145
+ throw new Error(`Memory file exceeds payload size: ${file.path}`);
146
+ }
147
+
148
+ fileBytes[file.path] = view.slice(offset, end);
149
+ offset = end;
150
+ }
151
+
152
+ if (offset !== view.length) {
153
+ throw new Error("Memory payload trailing bytes remain after decode.");
154
+ }
155
+
156
+ return { manifest, fileBytes };
157
+ };
158
+
159
+ export const resolveSafePath = (outDir, filePath, pathSep, pathResolve) => {
160
+ const resolvedOutDir = pathResolve(outDir);
161
+ const target = pathResolve(resolvedOutDir, filePath);
162
+ const prefix = resolvedOutDir.endsWith(pathSep)
163
+ ? resolvedOutDir
164
+ : `${resolvedOutDir}${pathSep}`;
165
+
166
+ if (target !== resolvedOutDir && !target.startsWith(prefix)) {
167
+ throw new Error(`Unsafe path in manifest: ${filePath}`);
168
+ }
169
+
170
+ return target;
171
+ };
@@ -0,0 +1,36 @@
1
+ import { gzipSync } from "node:zlib";
2
+ import { createPngChunk, PNG_SIGNATURE } from "./pngUtils.mjs";
3
+
4
+ export const createBlankCover = ({
5
+ width = 64,
6
+ height = 64,
7
+ rgb = [0, 8, 2],
8
+ } = {}) => {
9
+ const rows = Buffer.alloc((width * 3 + 1) * height);
10
+ for (let y = 0; y < height; y += 1) {
11
+ const rowStart = y * (width * 3 + 1);
12
+ rows[rowStart] = 0;
13
+ for (let x = 0; x < width; x += 1) {
14
+ const pixelStart = rowStart + 1 + x * 3;
15
+ rows[pixelStart] = rgb[0];
16
+ rows[pixelStart + 1] = rgb[1];
17
+ rows[pixelStart + 2] = rgb[2];
18
+ }
19
+ }
20
+
21
+ const ihdr = Buffer.alloc(13);
22
+ ihdr.writeUInt32BE(width, 0);
23
+ ihdr.writeUInt32BE(height, 4);
24
+ ihdr[8] = 8;
25
+ ihdr[9] = 2;
26
+ ihdr[10] = 0;
27
+ ihdr[11] = 0;
28
+ ihdr[12] = 0;
29
+
30
+ return Buffer.concat([
31
+ PNG_SIGNATURE,
32
+ createPngChunk("IHDR", ihdr),
33
+ createPngChunk("IDAT", gzipSync(rows, { level: 9 })),
34
+ createPngChunk("IEND", Buffer.alloc(0)),
35
+ ]);
36
+ };