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 +21 -0
- package/README.md +0 -0
- package/package.json +35 -0
- package/src/index.js +25 -0
- package/src/loadMemory.js +198 -0
- package/src/memoryFormat.js +213 -0
- package/src/payloadFormat.js +171 -0
- package/tools/blankCover.mjs +36 -0
- package/tools/glob.mjs +12 -0
- package/tools/hostProject.mjs +45 -0
- package/tools/memoryFormat.mjs +277 -0
- package/tools/pack.mjs +239 -0
- package/tools/packCache.mjs +62 -0
- package/tools/pngInspect.mjs +22 -0
- package/tools/pngUtils.mjs +36 -0
- package/tools/resizeCover.mjs +55 -0
- package/tools/unpack.mjs +53 -0
package/tools/glob.mjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const globToRegExp = (pattern) => {
|
|
2
|
+
const escaped = pattern
|
|
3
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
4
|
+
.replace(/\*\*/g, "::DOUBLESTAR::")
|
|
5
|
+
.replace(/\*/g, "[^/]*")
|
|
6
|
+
.replace(/::DOUBLESTAR::/g, ".*");
|
|
7
|
+
|
|
8
|
+
return new RegExp(`^${escaped}$`);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const matchesAnyGlob = (relativePath, patterns) =>
|
|
12
|
+
patterns.some((pattern) => globToRegExp(pattern).test(relativePath));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_EXCLUDE = ["**/*.map"];
|
|
5
|
+
|
|
6
|
+
export const loadHostProject = async (rootDir = process.cwd()) => {
|
|
7
|
+
try {
|
|
8
|
+
const packagePath = path.join(rootDir, "package.json");
|
|
9
|
+
const pkg = JSON.parse(await readFile(packagePath, "utf8"));
|
|
10
|
+
const memory = pkg.memory ?? {};
|
|
11
|
+
const name = memory.name ?? pkg.name ?? "memory";
|
|
12
|
+
const dist = path.resolve(rootDir, memory.dist ?? "dist");
|
|
13
|
+
const out = memory.out
|
|
14
|
+
? path.resolve(rootDir, memory.out)
|
|
15
|
+
: path.join(dist, `${name}.png`);
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
name,
|
|
19
|
+
dist,
|
|
20
|
+
out,
|
|
21
|
+
cover: memory.cover ? path.resolve(rootDir, memory.cover) : "",
|
|
22
|
+
entry: memory.entry ?? "index.html",
|
|
23
|
+
exclude: [...new Set([...DEFAULT_EXCLUDE, ...(memory.exclude ?? [])])],
|
|
24
|
+
coverMaxSize: memory.coverMaxSize ?? 512,
|
|
25
|
+
coverResize: memory.coverResize !== false,
|
|
26
|
+
cache: memory.cache !== false,
|
|
27
|
+
unpackDir: memory.unpackDir
|
|
28
|
+
? path.resolve(rootDir, memory.unpackDir)
|
|
29
|
+
: path.join(dist, "unpack"),
|
|
30
|
+
};
|
|
31
|
+
} catch {
|
|
32
|
+
return {
|
|
33
|
+
name: "memory",
|
|
34
|
+
dist: path.resolve(rootDir, "dist"),
|
|
35
|
+
out: path.resolve(rootDir, "dist", "memory.png"),
|
|
36
|
+
cover: "",
|
|
37
|
+
entry: "index.html",
|
|
38
|
+
exclude: [...DEFAULT_EXCLUDE],
|
|
39
|
+
coverMaxSize: 512,
|
|
40
|
+
coverResize: true,
|
|
41
|
+
cache: true,
|
|
42
|
+
unpackDir: path.resolve(rootDir, "dist", "unpack"),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { gunzipSync } from "node:zlib";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
assertSupportedVersion,
|
|
5
|
+
buildV2Manifest,
|
|
6
|
+
decodeV2Archive,
|
|
7
|
+
encodeV2Archive,
|
|
8
|
+
guessMimeType,
|
|
9
|
+
listManifestFiles,
|
|
10
|
+
MAX_PAYLOAD_SIZE,
|
|
11
|
+
MEMORY_VERSION,
|
|
12
|
+
readManifestFile,
|
|
13
|
+
SUPPORTED_VERSIONS,
|
|
14
|
+
} from "../src/payloadFormat.js";
|
|
15
|
+
import { PNG_SIGNATURE, createPngChunk, gzipPayload } from "./pngUtils.mjs";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
assertSupportedVersion,
|
|
19
|
+
guessMimeType,
|
|
20
|
+
listManifestFiles,
|
|
21
|
+
MAX_PAYLOAD_SIZE,
|
|
22
|
+
MEMORY_VERSION,
|
|
23
|
+
SUPPORTED_VERSIONS,
|
|
24
|
+
} from "../src/payloadFormat.js";
|
|
25
|
+
|
|
26
|
+
export const MEMORY_MAGIC = Buffer.from("WLFC");
|
|
27
|
+
export const MEMORY_CHUNK_TYPE = Buffer.from("wLFC");
|
|
28
|
+
|
|
29
|
+
const parsePngChunks = (buffer) => {
|
|
30
|
+
const view = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
31
|
+
|
|
32
|
+
if (view.length < 8 || !view.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
33
|
+
throw new Error("Invalid PNG signature.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const chunks = [];
|
|
37
|
+
let offset = 8;
|
|
38
|
+
|
|
39
|
+
while (offset + 12 <= view.length) {
|
|
40
|
+
const length = view.readUInt32BE(offset);
|
|
41
|
+
const type = view.subarray(offset + 4, offset + 8);
|
|
42
|
+
const dataStart = offset + 8;
|
|
43
|
+
const dataEnd = dataStart + length;
|
|
44
|
+
|
|
45
|
+
if (dataEnd + 4 > view.length) {
|
|
46
|
+
throw new Error("PNG chunk exceeds file size.");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
chunks.push({
|
|
50
|
+
type: type.toString("ascii"),
|
|
51
|
+
data: view.subarray(dataStart, dataEnd),
|
|
52
|
+
start: offset,
|
|
53
|
+
end: dataEnd + 4,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
offset = dataEnd + 4;
|
|
57
|
+
|
|
58
|
+
if (type.equals(Buffer.from("IEND"))) {
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { view, chunks, trailing: view.subarray(offset) };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const readMemoryPayload = (chunkData) => {
|
|
67
|
+
if (chunkData.length < 8) {
|
|
68
|
+
throw new Error("Memory chunk is too small.");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!chunkData.subarray(0, 4).equals(MEMORY_MAGIC)) {
|
|
72
|
+
throw new Error("Memory chunk magic mismatch.");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const version = chunkData.readUInt32BE(4);
|
|
76
|
+
assertSupportedVersion(version);
|
|
77
|
+
|
|
78
|
+
const payload = chunkData.subarray(8);
|
|
79
|
+
if (payload.length > MAX_PAYLOAD_SIZE) {
|
|
80
|
+
throw new Error(`Memory payload exceeds ${MAX_PAYLOAD_SIZE} bytes.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { version, payload };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const extractLegacyFooter = (view) => {
|
|
87
|
+
const magicIndex = view.lastIndexOf(MEMORY_MAGIC);
|
|
88
|
+
if (magicIndex < 8) {
|
|
89
|
+
throw new Error("Memory footer not found.");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const version = view.readUInt32BE(magicIndex + 4);
|
|
93
|
+
const payloadLength = view.readUInt32BE(magicIndex + 8);
|
|
94
|
+
const payloadStart = magicIndex + 12;
|
|
95
|
+
const payloadEnd = payloadStart + payloadLength;
|
|
96
|
+
|
|
97
|
+
if (version !== 1) {
|
|
98
|
+
throw new Error(`Unsupported legacy memory version: ${version}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (payloadEnd > view.length) {
|
|
102
|
+
throw new Error("Memory payload length exceeds file size.");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
png: view.subarray(0, magicIndex),
|
|
107
|
+
payload: view.subarray(payloadStart, payloadEnd),
|
|
108
|
+
version,
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const buildManifest = ({
|
|
113
|
+
name,
|
|
114
|
+
entry,
|
|
115
|
+
files,
|
|
116
|
+
kind = "web-app",
|
|
117
|
+
runtime = "iframe-sandbox",
|
|
118
|
+
}) => ({
|
|
119
|
+
v: 1,
|
|
120
|
+
kind,
|
|
121
|
+
name,
|
|
122
|
+
entry,
|
|
123
|
+
runtime,
|
|
124
|
+
files,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
export const encodeManifest = (manifest) =>
|
|
128
|
+
gzipPayload(Buffer.from(JSON.stringify(manifest), "utf8"));
|
|
129
|
+
|
|
130
|
+
export const decodeManifest = (payload) =>
|
|
131
|
+
JSON.parse(gunzipSync(payload).toString("utf8"));
|
|
132
|
+
|
|
133
|
+
export const encodeMemoryPayload = ({
|
|
134
|
+
name,
|
|
135
|
+
entry,
|
|
136
|
+
files,
|
|
137
|
+
kind = "web-app",
|
|
138
|
+
runtime = "iframe-sandbox",
|
|
139
|
+
version = MEMORY_VERSION,
|
|
140
|
+
}) => {
|
|
141
|
+
if (version === 1) {
|
|
142
|
+
const encodedFiles = {};
|
|
143
|
+
|
|
144
|
+
for (const [filePath, buffer] of Object.entries(files)) {
|
|
145
|
+
encodedFiles[filePath] = encodeManifestFile(buffer, filePath);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const manifest = buildManifest({ name, entry, files: encodedFiles, kind, runtime });
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
version: 1,
|
|
152
|
+
manifest,
|
|
153
|
+
payload: encodeManifest(manifest),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (version !== 2) {
|
|
158
|
+
throw new Error(`Unsupported memory version: ${version}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const archive = encodeV2Archive({ name, entry, files, kind, runtime });
|
|
162
|
+
const { manifest } = buildV2Manifest({ name, entry, files, kind, runtime });
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
version: 2,
|
|
166
|
+
manifest,
|
|
167
|
+
payload: gzipPayload(Buffer.from(archive)),
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const decodeMemoryPayload = (payload, version) => {
|
|
172
|
+
assertSupportedVersion(version);
|
|
173
|
+
|
|
174
|
+
if (version === 1) {
|
|
175
|
+
return {
|
|
176
|
+
manifest: decodeManifest(payload),
|
|
177
|
+
fileBytes: null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return decodeV2Archive(gunzipSync(payload));
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const decodeManifestFile = (fileRecord) => {
|
|
185
|
+
if (!fileRecord?.data) {
|
|
186
|
+
throw new Error("Invalid manifest file record.");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return Buffer.from(fileRecord.data, fileRecord.encoding ?? "base64");
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export { readManifestFile };
|
|
193
|
+
|
|
194
|
+
export const readMemoryFile = (manifest, filePath, fileBytes = null) =>
|
|
195
|
+
readManifestFile(manifest, filePath, fileBytes, decodeManifestFile);
|
|
196
|
+
|
|
197
|
+
export const encodeManifestFile = (buffer, filePath = "") => ({
|
|
198
|
+
encoding: "base64",
|
|
199
|
+
mime: guessMimeType(filePath),
|
|
200
|
+
data: Buffer.from(buffer).toString("base64"),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
export const embedMemory = (pngBuffer, payloadBuffer, version = MEMORY_VERSION) => {
|
|
204
|
+
if (!Buffer.isBuffer(pngBuffer) || pngBuffer.length === 0) {
|
|
205
|
+
throw new Error("Cover PNG buffer is empty.");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!Buffer.isBuffer(payloadBuffer) || payloadBuffer.length === 0) {
|
|
209
|
+
throw new Error("Payload buffer is empty.");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
assertSupportedVersion(version);
|
|
213
|
+
|
|
214
|
+
const { view, chunks } = parsePngChunks(pngBuffer);
|
|
215
|
+
const iendChunk = chunks.find((chunk) => chunk.type === "IEND");
|
|
216
|
+
|
|
217
|
+
if (!iendChunk) {
|
|
218
|
+
throw new Error("PNG IEND chunk not found.");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const chunkData = Buffer.alloc(8 + payloadBuffer.length);
|
|
222
|
+
MEMORY_MAGIC.copy(chunkData, 0);
|
|
223
|
+
chunkData.writeUInt32BE(version, 4);
|
|
224
|
+
payloadBuffer.copy(chunkData, 8);
|
|
225
|
+
|
|
226
|
+
const memoryChunk = createPngChunk(MEMORY_CHUNK_TYPE, chunkData);
|
|
227
|
+
|
|
228
|
+
return Buffer.concat([
|
|
229
|
+
view.subarray(0, iendChunk.start),
|
|
230
|
+
memoryChunk,
|
|
231
|
+
view.subarray(iendChunk.start),
|
|
232
|
+
]);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const decodeExtractedMemory = (png, payload, version) => {
|
|
236
|
+
const { manifest, fileBytes } = decodeMemoryPayload(payload, version);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
png,
|
|
240
|
+
payload,
|
|
241
|
+
manifest,
|
|
242
|
+
fileBytes,
|
|
243
|
+
version,
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
export const extractMemory = (buffer) => {
|
|
248
|
+
const view = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
249
|
+
|
|
250
|
+
if (view.length < 12) {
|
|
251
|
+
throw new Error("File is too small to be a memory PNG.");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const { view: pngView, chunks, trailing } = parsePngChunks(view);
|
|
256
|
+
const memoryChunk = chunks.find((chunk) => chunk.type === "wLFC");
|
|
257
|
+
|
|
258
|
+
if (memoryChunk) {
|
|
259
|
+
const { version, payload } = readMemoryPayload(memoryChunk.data);
|
|
260
|
+
return decodeExtractedMemory(pngView, payload, version);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (trailing.length > 0) {
|
|
264
|
+
const legacy = extractLegacyFooter(view);
|
|
265
|
+
return decodeExtractedMemory(legacy.png, legacy.payload, legacy.version);
|
|
266
|
+
}
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (error.message?.includes("Memory footer not found")) {
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const legacy = extractLegacyFooter(view);
|
|
273
|
+
return decodeExtractedMemory(legacy.png, legacy.payload, legacy.version);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
throw new Error("Memory chunk not found.");
|
|
277
|
+
};
|
package/tools/pack.mjs
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createBlankCover } from "./blankCover.mjs";
|
|
5
|
+
import { matchesAnyGlob } from "./glob.mjs";
|
|
6
|
+
import { loadHostProject } from "./hostProject.mjs";
|
|
7
|
+
import {
|
|
8
|
+
buildPackFingerprint,
|
|
9
|
+
isPackUpToDate,
|
|
10
|
+
savePackCache,
|
|
11
|
+
} from "./packCache.mjs";
|
|
12
|
+
import { prepareCover } from "./resizeCover.mjs";
|
|
13
|
+
import {
|
|
14
|
+
embedMemory,
|
|
15
|
+
encodeMemoryPayload,
|
|
16
|
+
} from "./memoryFormat.mjs";
|
|
17
|
+
|
|
18
|
+
const shouldSkipPath = (absolutePath, skipPaths) => {
|
|
19
|
+
const resolved = path.resolve(absolutePath);
|
|
20
|
+
|
|
21
|
+
return skipPaths.some((skipPath) => {
|
|
22
|
+
const resolvedSkip = path.resolve(skipPath);
|
|
23
|
+
return (
|
|
24
|
+
resolved === resolvedSkip ||
|
|
25
|
+
resolved.startsWith(`${resolvedSkip}${path.sep}`)
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const collectFiles = async (dir, baseDir, skipPaths, excludePatterns) => {
|
|
31
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
32
|
+
const files = {};
|
|
33
|
+
const fileReads = [];
|
|
34
|
+
const subdirs = [];
|
|
35
|
+
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const absolutePath = path.join(dir, entry.name);
|
|
38
|
+
|
|
39
|
+
if (shouldSkipPath(absolutePath, skipPaths)) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
subdirs.push(absolutePath);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const relativePath = path.relative(baseDir, absolutePath)
|
|
49
|
+
.split(path.sep)
|
|
50
|
+
.join("/");
|
|
51
|
+
|
|
52
|
+
if (matchesAnyGlob(relativePath, excludePatterns)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fileReads.push(
|
|
57
|
+
readFile(absolutePath).then((buffer) => {
|
|
58
|
+
files[relativePath] = buffer;
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await Promise.all([
|
|
64
|
+
...fileReads,
|
|
65
|
+
...subdirs.map((subdir) =>
|
|
66
|
+
collectFiles(subdir, baseDir, skipPaths, excludePatterns).then(
|
|
67
|
+
(nested) => {
|
|
68
|
+
Object.assign(files, nested);
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
return files;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const buildSkipPaths = (options) =>
|
|
78
|
+
[options.out, path.join(path.resolve(options.dist), "unpack")].map((entry) =>
|
|
79
|
+
path.resolve(entry)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const parseArgs = async (argv) => {
|
|
83
|
+
const rootDir = process.cwd();
|
|
84
|
+
const host = await loadHostProject(rootDir);
|
|
85
|
+
const options = {
|
|
86
|
+
root: rootDir,
|
|
87
|
+
dist: host.dist,
|
|
88
|
+
cover: host.cover,
|
|
89
|
+
out: host.out,
|
|
90
|
+
name: host.name,
|
|
91
|
+
entry: host.entry,
|
|
92
|
+
exclude: [...host.exclude],
|
|
93
|
+
coverMaxSize: host.coverMaxSize,
|
|
94
|
+
coverResize: host.coverResize,
|
|
95
|
+
cache: host.cache,
|
|
96
|
+
force: false,
|
|
97
|
+
check: false,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
101
|
+
const arg = argv[index];
|
|
102
|
+
if (arg === "--root") {
|
|
103
|
+
options.root = path.resolve(argv[index + 1] ?? "");
|
|
104
|
+
index += 1;
|
|
105
|
+
} else if (arg === "--dist") {
|
|
106
|
+
options.dist = path.resolve(argv[index + 1] ?? "");
|
|
107
|
+
index += 1;
|
|
108
|
+
} else if (arg === "--cover") {
|
|
109
|
+
options.cover = path.resolve(argv[index + 1] ?? "");
|
|
110
|
+
index += 1;
|
|
111
|
+
} else if (arg === "--out") {
|
|
112
|
+
options.out = path.resolve(argv[index + 1] ?? "");
|
|
113
|
+
index += 1;
|
|
114
|
+
} else if (arg === "--name") {
|
|
115
|
+
options.name = argv[index + 1] ?? options.name;
|
|
116
|
+
index += 1;
|
|
117
|
+
} else if (arg === "--entry") {
|
|
118
|
+
options.entry = argv[index + 1] ?? options.entry;
|
|
119
|
+
index += 1;
|
|
120
|
+
} else if (arg === "--exclude") {
|
|
121
|
+
options.exclude.push(argv[index + 1] ?? "");
|
|
122
|
+
index += 1;
|
|
123
|
+
} else if (arg === "--cover-max-size") {
|
|
124
|
+
options.coverMaxSize = Number(argv[index + 1] ?? options.coverMaxSize);
|
|
125
|
+
index += 1;
|
|
126
|
+
} else if (arg === "--no-cover-resize") {
|
|
127
|
+
options.coverResize = false;
|
|
128
|
+
} else if (arg === "--no-cache") {
|
|
129
|
+
options.cache = false;
|
|
130
|
+
} else if (arg === "--force") {
|
|
131
|
+
options.force = true;
|
|
132
|
+
} else if (arg === "--check") {
|
|
133
|
+
options.check = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
options.exclude = [...new Set(options.exclude.filter(Boolean))];
|
|
138
|
+
|
|
139
|
+
return options;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const formatKb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
|
|
143
|
+
|
|
144
|
+
const main = async () => {
|
|
145
|
+
const options = await parseArgs(process.argv.slice(2));
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
await stat(options.dist);
|
|
149
|
+
} catch {
|
|
150
|
+
throw new Error(`Build output not found at ${options.dist}. Run your build first.`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let cover;
|
|
154
|
+
if (options.cover) {
|
|
155
|
+
const sourceCover = await readFile(options.cover);
|
|
156
|
+
const prepared = await prepareCover(sourceCover, {
|
|
157
|
+
maxSize: options.coverMaxSize,
|
|
158
|
+
resize: options.coverResize,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
cover = prepared.buffer;
|
|
162
|
+
|
|
163
|
+
if (prepared.changed && prepared.from && prepared.info) {
|
|
164
|
+
console.log(
|
|
165
|
+
`Resized cover from ${prepared.from.width}x${prepared.from.height} (${formatKb(prepared.from.bytes)}) to ${prepared.info.width}x${prepared.info.height} (${formatKb(prepared.info.bytes)})`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
console.log(`Using cover image ${options.cover}`);
|
|
170
|
+
} else {
|
|
171
|
+
cover = createBlankCover();
|
|
172
|
+
console.log("Using blank cover image");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const skipPaths = buildSkipPaths(options);
|
|
176
|
+
const files = await collectFiles(
|
|
177
|
+
options.dist,
|
|
178
|
+
options.dist,
|
|
179
|
+
skipPaths,
|
|
180
|
+
options.exclude
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
if (!files[options.entry]) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Entry not found in dist: ${options.entry}. Checked ${Object.keys(files).length} files.`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const fingerprint = buildPackFingerprint(options, files, cover);
|
|
190
|
+
|
|
191
|
+
if (options.check && !options.cache) {
|
|
192
|
+
throw new Error("Pack cache is disabled. Remove --check or enable cache in memory config.");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const upToDate =
|
|
196
|
+
options.cache &&
|
|
197
|
+
!options.force &&
|
|
198
|
+
(await isPackUpToDate(options.root, fingerprint, options.out));
|
|
199
|
+
|
|
200
|
+
if (options.check) {
|
|
201
|
+
if (upToDate) {
|
|
202
|
+
console.log(`Memory pack is up to date: ${options.out}`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
console.error(`Memory pack is stale: ${options.out}`);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (upToDate) {
|
|
211
|
+
console.log(`Memory unchanged, skipping pack: ${options.out}`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const { payload, version } = encodeMemoryPayload({
|
|
216
|
+
name: options.name,
|
|
217
|
+
entry: options.entry,
|
|
218
|
+
files,
|
|
219
|
+
});
|
|
220
|
+
const memory = embedMemory(cover, payload, version);
|
|
221
|
+
|
|
222
|
+
const tempPath = `${options.out}.tmp`;
|
|
223
|
+
await writeFile(tempPath, memory);
|
|
224
|
+
await rename(tempPath, options.out);
|
|
225
|
+
|
|
226
|
+
if (options.cache) {
|
|
227
|
+
await savePackCache(options.root, fingerprint);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
console.log(
|
|
231
|
+
`Packed ${Object.keys(files).length} files into ${options.out} (${formatKb(memory.length)})`
|
|
232
|
+
);
|
|
233
|
+
console.log(` cover: ${formatKb(cover.length)}, payload: ${formatKb(payload.length)}, format: v${version}`);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
main().catch((error) => {
|
|
237
|
+
console.error(error.message ?? error);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { MEMORY_VERSION } from "./memoryFormat.mjs";
|
|
5
|
+
|
|
6
|
+
export const CACHE_FILE = ".memory-pack-cache.json";
|
|
7
|
+
|
|
8
|
+
export const hashBuffer = (buffer) =>
|
|
9
|
+
createHash("sha256").update(buffer).digest("hex");
|
|
10
|
+
|
|
11
|
+
export const buildPackFingerprint = (options, files, cover) => ({
|
|
12
|
+
cacheVersion: 1,
|
|
13
|
+
formatVersion: MEMORY_VERSION,
|
|
14
|
+
name: options.name,
|
|
15
|
+
entry: options.entry,
|
|
16
|
+
out: path.resolve(options.out),
|
|
17
|
+
exclude: [...options.exclude].sort(),
|
|
18
|
+
coverMaxSize: options.coverMaxSize,
|
|
19
|
+
coverResize: options.coverResize,
|
|
20
|
+
cover: hashBuffer(cover),
|
|
21
|
+
files: Object.fromEntries(
|
|
22
|
+
Object.keys(files)
|
|
23
|
+
.sort()
|
|
24
|
+
.map((filePath) => [filePath, hashBuffer(files[filePath])])
|
|
25
|
+
),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const fingerprintsEqual = (left, right) =>
|
|
29
|
+
JSON.stringify(left) === JSON.stringify(right);
|
|
30
|
+
|
|
31
|
+
export const cachePathFor = (rootDir) => path.join(rootDir, CACHE_FILE);
|
|
32
|
+
|
|
33
|
+
export const loadPackCache = async (rootDir) => {
|
|
34
|
+
try {
|
|
35
|
+
const raw = await readFile(cachePathFor(rootDir), "utf8");
|
|
36
|
+
return JSON.parse(raw);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const savePackCache = async (rootDir, fingerprint) => {
|
|
43
|
+
await writeFile(
|
|
44
|
+
cachePathFor(rootDir),
|
|
45
|
+
`${JSON.stringify({ fingerprint }, null, 2)}\n`
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const isPackUpToDate = async (rootDir, fingerprint, outputPath) => {
|
|
50
|
+
try {
|
|
51
|
+
await stat(outputPath);
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const cache = await loadPackCache(rootDir);
|
|
57
|
+
if (!cache?.fingerprint) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return fingerprintsEqual(cache.fingerprint, fingerprint);
|
|
62
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const PNG_SIGNATURE = Buffer.from([
|
|
2
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
3
|
+
]);
|
|
4
|
+
|
|
5
|
+
export const inspectPng = (buffer) => {
|
|
6
|
+
const view = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
7
|
+
|
|
8
|
+
if (view.length < 24 || !view.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const chunkType = view.subarray(12, 16).toString("ascii");
|
|
13
|
+
if (chunkType !== "IHDR") {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
width: view.readUInt32BE(16),
|
|
19
|
+
height: view.readUInt32BE(20),
|
|
20
|
+
bytes: view.length,
|
|
21
|
+
};
|
|
22
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { gzipSync } from "node:zlib";
|
|
2
|
+
|
|
3
|
+
const crcTable = (() => {
|
|
4
|
+
const table = new Uint32Array(256);
|
|
5
|
+
for (let index = 0; index < 256; index += 1) {
|
|
6
|
+
let value = index;
|
|
7
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
8
|
+
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
|
9
|
+
}
|
|
10
|
+
table[index] = value >>> 0;
|
|
11
|
+
}
|
|
12
|
+
return table;
|
|
13
|
+
})();
|
|
14
|
+
|
|
15
|
+
export const crc32 = (buffer) => {
|
|
16
|
+
let value = 0xffffffff;
|
|
17
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
18
|
+
value = crcTable[(value ^ buffer[index]) & 0xff] ^ (value >>> 8);
|
|
19
|
+
}
|
|
20
|
+
return (value ^ 0xffffffff) >>> 0;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const createPngChunk = (type, data) => {
|
|
24
|
+
const length = Buffer.alloc(4);
|
|
25
|
+
length.writeUInt32BE(data.length);
|
|
26
|
+
const typeBuffer = Buffer.isBuffer(type) ? type : Buffer.from(type);
|
|
27
|
+
const crc = Buffer.alloc(4);
|
|
28
|
+
crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])));
|
|
29
|
+
return Buffer.concat([length, typeBuffer, data, crc]);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const PNG_SIGNATURE = Buffer.from([
|
|
33
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export const gzipPayload = (buffer) => gzipSync(buffer, { level: 9 });
|