memory-extract 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "memory-extract",
3
- "description": "Tools for packing interactive scenes into PNG images.",
3
+ "description": "Tool for embedding data into PNG images",
4
4
  "license": "MIT",
5
- "version": "0.1.0",
5
+ "version": "0.1.2",
6
6
  "author": "semigarden",
7
7
  "repository": {
8
8
  "type": "git",
@@ -22,14 +22,14 @@
22
22
  },
23
23
  "bin": {
24
24
  "memory-pack": "./tools/pack.mjs",
25
- "memory-unpack": "./tools/unpack.mjs"
25
+ "memory-unpack": "./tools/unpack.mjs",
26
+ "memory-play": "./tools/play.mjs"
26
27
  },
27
28
  "scripts": {
28
29
  "pack": "node tools/pack.mjs",
29
30
  "unpack": "node tools/unpack.mjs",
31
+ "play": "node tools/play.mjs",
32
+ "build:launcher": "node tools/buildPlayLauncher.mjs",
30
33
  "test": "node --test test/memory.test.mjs"
31
- },
32
- "dependencies": {
33
- "sharp": "^0.34.5"
34
34
  }
35
35
  }
package/src/index.js CHANGED
@@ -22,4 +22,4 @@ export {
22
22
  resolveSafePath,
23
23
  } from "./payloadFormat.js";
24
24
 
25
- export { createMemoryBlob } from "./loadMemory.js";
25
+ export { createMemoryBlob, resolveLaunchAssetUrl } from "./loadMemory.js";
package/src/loadMemory.js CHANGED
@@ -3,6 +3,11 @@ import {
3
3
  readMemoryFile,
4
4
  extractMemory,
5
5
  } from "./memoryFormat.js";
6
+ import {
7
+ buildInteractiveBrowseDocument,
8
+ buildMemoryBrowseListings,
9
+ resolveMemoryPlayMode,
10
+ } from "./memoryPlay.js";
6
11
 
7
12
  const textDecoder = new TextDecoder();
8
13
 
@@ -36,6 +41,9 @@ const readInput = async (input) => {
36
41
 
37
42
  const normalizeAssetPath = (value) => value.replace(/^\.\//, "");
38
43
 
44
+ const stripLeadingSlash = (value) =>
45
+ value.startsWith("/") ? value.slice(1) : value;
46
+
39
47
  const guessMimeType = (filePath, record) => {
40
48
  if (record?.mime) return record.mime;
41
49
  if (filePath.endsWith(".html")) return "text/html";
@@ -59,7 +67,7 @@ const resolveAssetUrl = (rawPath, urlByPath) => {
59
67
 
60
68
  const resolveManifestPath = (basePath, rawPath, manifestPaths) => {
61
69
  const manifestSet = new Set(manifestPaths);
62
- const normalized = normalizeAssetPath(rawPath);
70
+ const normalized = stripLeadingSlash(normalizeAssetPath(rawPath));
63
71
 
64
72
  if (manifestSet.has(normalized)) {
65
73
  return normalized;
@@ -87,9 +95,9 @@ const extractCssAssetPaths = (cssText) => {
87
95
  return paths;
88
96
  };
89
97
 
90
- const collectRequiredFiles = (manifest, fileBytes) => {
98
+ const collectRequiredFiles = (manifest, fileBytes, entryPath) => {
91
99
  const manifestPaths = listManifestFiles(manifest);
92
- const required = new Set([manifest.entry]);
100
+ const required = new Set([entryPath]);
93
101
 
94
102
  for (const filePath of manifestPaths) {
95
103
  if (filePath.endsWith(".js") || filePath.endsWith(".css")) {
@@ -131,13 +139,61 @@ const collectRequiredFiles = (manifest, fileBytes) => {
131
139
  return required;
132
140
  };
133
141
 
134
- const buildLaunchDocument = (html, urlByPath) => {
142
+ const BLOB_LAUNCH_BASE = "http://localhost/";
143
+
144
+ const injectBlobLaunchShim = (doc) => {
145
+ const base = doc.createElement("base");
146
+ base.setAttribute("href", BLOB_LAUNCH_BASE);
147
+ doc.head.prepend(base);
148
+
149
+ const shim = doc.createElement("script");
150
+ shim.textContent = `(function () {
151
+ var base = ${JSON.stringify(BLOB_LAUNCH_BASE)};
152
+ var NativeURL = URL;
153
+ var invalidBase = function (value) {
154
+ if (!value) return true;
155
+ var text = String(value);
156
+ return text === "null" || text.indexOf("blob:") === 0;
157
+ };
158
+ URL = function (url, baseUrl) {
159
+ if (typeof url === "string" && url.charAt(0) === "/" && invalidBase(baseUrl)) {
160
+ return new NativeURL(url, base);
161
+ }
162
+ return new NativeURL(url, baseUrl);
163
+ };
164
+ URL.prototype = NativeURL.prototype;
165
+ URL.createObjectURL = NativeURL.createObjectURL.bind(NativeURL);
166
+ URL.revokeObjectURL = NativeURL.revokeObjectURL.bind(NativeURL);
167
+ })();`;
168
+ doc.head.prepend(shim);
169
+ };
170
+
171
+ export const resolveLaunchAssetUrl = (rawPath, urlByPath, entryPath, manifestPaths) => {
172
+ const direct = resolveAssetUrl(rawPath, urlByPath);
173
+ if (direct) {
174
+ return direct;
175
+ }
176
+
177
+ if (!rawPath || !entryPath || !manifestPaths) {
178
+ return null;
179
+ }
180
+
181
+ const resolved = resolveManifestPath(entryPath, rawPath, manifestPaths);
182
+ return resolved ? urlByPath.get(resolved) ?? null : null;
183
+ };
184
+
185
+ const buildLaunchDocument = (html, urlByPath, entryPath, manifestPaths) => {
135
186
  const doc = new DOMParser().parseFromString(html, "text/html");
136
187
 
137
188
  REWRITABLE_ATTRIBUTES.forEach(([tag, attribute]) => {
138
189
  doc.querySelectorAll(`${tag}[${attribute}]`).forEach((element) => {
139
190
  const rawPath = element.getAttribute(attribute);
140
- const assetUrl = resolveAssetUrl(rawPath, urlByPath);
191
+ const assetUrl = resolveLaunchAssetUrl(
192
+ rawPath,
193
+ urlByPath,
194
+ entryPath,
195
+ manifestPaths
196
+ );
141
197
 
142
198
  if (assetUrl) {
143
199
  element.setAttribute(attribute, assetUrl);
@@ -151,6 +207,8 @@ const buildLaunchDocument = (html, urlByPath) => {
151
207
  element.removeAttribute("crossorigin");
152
208
  });
153
209
 
210
+ injectBlobLaunchShim(doc);
211
+
154
212
  return `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
155
213
  };
156
214
 
@@ -162,37 +220,98 @@ const getManifestFileMeta = (manifest, filePath) => {
162
220
  return manifest.files?.[filePath] ?? null;
163
221
  };
164
222
 
165
- export const createMemoryBlob = async (input) => {
166
- const bytes = await readInput(input);
167
- const { manifest, fileBytes } = await extractMemory(bytes);
168
- const blobUrls = [];
223
+ const attachBlobDispose = (blob, blobUrls) => {
224
+ blob.dispose = () => {
225
+ blobUrls.forEach((url) => URL.revokeObjectURL(url));
226
+ };
227
+
228
+ return blob;
229
+ };
230
+
231
+ const createFileLaunchBlob = (manifest, fileBytes, filePath, blobUrls) => {
232
+ const fileMeta = getManifestFileMeta(manifest, filePath);
233
+ const fileContent = readMemoryFile(manifest, filePath, fileBytes);
234
+ const mime = guessMimeType(filePath, fileMeta);
235
+ const launchBlob = new Blob([fileContent], { type: mime });
236
+
237
+ return attachBlobDispose(launchBlob, blobUrls);
238
+ };
239
+
240
+ const createBrowseLaunchBlob = (manifest, fileBytes, filePaths, blobUrls) => {
241
+ const fileUrlByPath = new Map();
242
+
243
+ for (const filePath of filePaths) {
244
+ const fileMeta = getManifestFileMeta(manifest, filePath);
245
+ const fileContent = readMemoryFile(manifest, filePath, fileBytes);
246
+ const mime = guessMimeType(filePath, fileMeta);
247
+ const assetUrl = URL.createObjectURL(new Blob([fileContent], { type: mime }));
248
+ blobUrls.push(assetUrl);
249
+ fileUrlByPath.set(filePath, assetUrl);
250
+ }
251
+
252
+ const listings = buildMemoryBrowseListings(filePaths, fileUrlByPath);
253
+ const launchDocument = buildInteractiveBrowseDocument(listings);
254
+ const launchBlob = new Blob([launchDocument], { type: "text/html" });
255
+
256
+ return attachBlobDispose(launchBlob, blobUrls);
257
+ };
258
+
259
+ const createPlayLaunchBlob = (manifest, fileBytes, entryPath, blobUrls) => {
169
260
  const urlByPath = new Map();
170
- const requiredFiles = collectRequiredFiles(manifest, fileBytes);
261
+ const requiredFiles = collectRequiredFiles(manifest, fileBytes, entryPath);
171
262
 
172
263
  for (const filePath of requiredFiles) {
173
264
  const fileMeta = getManifestFileMeta(manifest, filePath);
174
265
  const fileContent = readMemoryFile(manifest, filePath, fileBytes);
175
266
  const mime = guessMimeType(filePath, fileMeta);
176
- const assetBlob = new Blob([fileContent], { type: mime });
177
- const assetUrl = URL.createObjectURL(assetBlob);
267
+ const assetUrl = URL.createObjectURL(new Blob([fileContent], { type: mime }));
178
268
  blobUrls.push(assetUrl);
179
269
  urlByPath.set(filePath, assetUrl);
180
270
  urlByPath.set(normalizeAssetPath(filePath), assetUrl);
181
271
  }
182
272
 
183
- if (!requiredFiles.has(manifest.entry)) {
184
- blobUrls.forEach((url) => URL.revokeObjectURL(url));
185
- throw new Error(`Manifest entry not found: ${manifest.entry}`);
273
+ if (!requiredFiles.has(entryPath)) {
274
+ throw new Error(`Manifest entry not found: ${entryPath}`);
186
275
  }
187
276
 
188
- const htmlBytes = readMemoryFile(manifest, manifest.entry, fileBytes);
277
+ const htmlBytes = readMemoryFile(manifest, entryPath, fileBytes);
189
278
  const html = textDecoder.decode(htmlBytes);
190
- const launchDocument = buildLaunchDocument(html, urlByPath);
279
+ const manifestPaths = listManifestFiles(manifest);
280
+ const launchDocument = buildLaunchDocument(
281
+ html,
282
+ urlByPath,
283
+ entryPath,
284
+ manifestPaths
285
+ );
191
286
  const launchBlob = new Blob([launchDocument], { type: "text/html" });
192
287
 
193
- launchBlob.dispose = () => {
194
- blobUrls.forEach((url) => URL.revokeObjectURL(url));
195
- };
288
+ return attachBlobDispose(launchBlob, blobUrls);
289
+ };
290
+
291
+ export const createMemoryBlob = async (input) => {
292
+ const bytes = await readInput(input);
293
+ const { manifest, fileBytes } = await extractMemory(bytes);
294
+ const filePaths = listManifestFiles(manifest);
295
+ const playMode = resolveMemoryPlayMode(manifest, filePaths);
296
+ const blobUrls = [];
196
297
 
197
- return launchBlob;
298
+ try {
299
+ if (playMode.mode === "file") {
300
+ return createFileLaunchBlob(manifest, fileBytes, playMode.path, blobUrls);
301
+ }
302
+
303
+ if (playMode.mode === "browse") {
304
+ return createBrowseLaunchBlob(manifest, fileBytes, filePaths, blobUrls);
305
+ }
306
+
307
+ return createPlayLaunchBlob(
308
+ manifest,
309
+ fileBytes,
310
+ playMode.entry ?? manifest.entry,
311
+ blobUrls
312
+ );
313
+ } catch (error) {
314
+ blobUrls.forEach((url) => URL.revokeObjectURL(url));
315
+ throw error;
316
+ }
198
317
  };
@@ -0,0 +1,246 @@
1
+ export const INDEX_NAMES = ["index.html", "index.htm"];
2
+
3
+ export const isHtmlEntry = (filePath) =>
4
+ filePath.endsWith(".html") || filePath.endsWith(".htm");
5
+
6
+ export const resolveMemoryPlayMode = (
7
+ manifest,
8
+ filePaths
9
+ ) => {
10
+ if (filePaths.length === 1) {
11
+ return { mode: "file", path: filePaths[0] };
12
+ }
13
+
14
+ const indexEntry = filePaths.find(
15
+ (filePath) =>
16
+ filePath === "index.html" ||
17
+ filePath.endsWith("/index.html") ||
18
+ filePath === "index.htm" ||
19
+ filePath.endsWith("/index.htm")
20
+ );
21
+
22
+ if (indexEntry) {
23
+ return { mode: "play", entry: indexEntry };
24
+ }
25
+
26
+ if (filePaths.includes(manifest.entry) && isHtmlEntry(manifest.entry)) {
27
+ return { mode: "play", entry: manifest.entry };
28
+ }
29
+
30
+ return { mode: "browse" };
31
+ };
32
+
33
+ const urlPathToPrefix = (urlPath) =>
34
+ urlPath === "/" ? "" : urlPath.slice(1);
35
+
36
+ export const listVirtualEntries = (filePaths, urlPath = "/") => {
37
+ const prefix = urlPathToPrefix(urlPath);
38
+ const entries = new Map();
39
+
40
+ for (const filePath of filePaths) {
41
+ if (prefix && !filePath.startsWith(`${prefix}/`)) {
42
+ continue;
43
+ }
44
+
45
+ const rest = prefix ? filePath.slice(prefix.length + 1) : filePath;
46
+ if (!rest) {
47
+ continue;
48
+ }
49
+
50
+ const slash = rest.indexOf("/");
51
+
52
+ if (slash === -1) {
53
+ entries.set(rest, { name: rest, isDirectory: false });
54
+ continue;
55
+ }
56
+
57
+ entries.set(rest.slice(0, slash), {
58
+ name: rest.slice(0, slash),
59
+ isDirectory: true,
60
+ });
61
+ }
62
+
63
+ return [...entries.values()];
64
+ };
65
+
66
+ export const collectVirtualDirectoryPaths = (filePaths) => {
67
+ const dirs = new Set(["/"]);
68
+
69
+ for (const filePath of filePaths) {
70
+ const parts = filePath.split("/");
71
+
72
+ for (let index = 1; index < parts.length; index += 1) {
73
+ dirs.add(`/${parts.slice(0, index).join("/")}`);
74
+ }
75
+ }
76
+
77
+ return [...dirs].sort(
78
+ (left, right) => right.split("/").length - left.split("/").length
79
+ );
80
+ };
81
+
82
+ export const parentVirtualPath = (urlPath) => {
83
+ if (urlPath === "/") {
84
+ return null;
85
+ }
86
+
87
+ const trimmed = urlPath.replace(/\/+$/, "");
88
+ const slash = trimmed.lastIndexOf("/");
89
+ return slash <= 0 ? "/" : trimmed.slice(0, slash);
90
+ };
91
+
92
+ export const buildBrowseListingDocument = (
93
+ urlPath,
94
+ entries,
95
+ { showParent = false, parentHref = "../" } = {}
96
+ ) => {
97
+ const sorted = [...entries].sort((left, right) => {
98
+ if (left.isDirectory !== right.isDirectory) {
99
+ return left.isDirectory ? -1 : 1;
100
+ }
101
+
102
+ return left.name.localeCompare(right.name);
103
+ });
104
+
105
+ const rows = sorted.map((entry) => {
106
+ const suffix = entry.isDirectory ? "/" : "";
107
+ return `<li><a href="${entry.href}">${entry.name}${suffix}</a></li>`;
108
+ });
109
+
110
+ if (showParent) {
111
+ rows.unshift(`<li><a href="${parentHref}">../</a></li>`);
112
+ }
113
+
114
+ return `<!DOCTYPE html>
115
+ <html lang="en">
116
+ <head>
117
+ <meta charset="utf-8" />
118
+ <title>Index of ${urlPath}</title>
119
+ <style>
120
+ body { font-family: system-ui, sans-serif; margin: 2rem; }
121
+ h1 { font-size: 1.1rem; font-weight: 600; }
122
+ ul { list-style: none; padding: 0; }
123
+ li { margin: 0.35rem 0; }
124
+ a { text-decoration: none; }
125
+ a:hover { text-decoration: underline; }
126
+ </style>
127
+ </head>
128
+ <body>
129
+ <h1>Index of ${urlPath}</h1>
130
+ <ul>
131
+ ${rows.join("\n ")}
132
+ </ul>
133
+ </body>
134
+ </html>
135
+ `;
136
+ };
137
+
138
+ export const buildMemoryBrowseListings = (filePaths, fileUrlByPath) => {
139
+ const listings = {};
140
+
141
+ for (const urlPath of collectVirtualDirectoryPaths(filePaths)) {
142
+ const prefix = urlPath === "/" ? "" : urlPath.slice(1);
143
+
144
+ listings[urlPath] = listVirtualEntries(filePaths, urlPath).map((entry) => {
145
+ const fullPath = prefix ? `${prefix}/${entry.name}` : entry.name;
146
+
147
+ if (entry.isDirectory) {
148
+ return {
149
+ name: entry.name,
150
+ isDirectory: true,
151
+ path: `/${fullPath}`.replace(/\/+/g, "/"),
152
+ };
153
+ }
154
+
155
+ return {
156
+ name: entry.name,
157
+ isDirectory: false,
158
+ href: fileUrlByPath.get(fullPath) ?? "#",
159
+ };
160
+ });
161
+ }
162
+
163
+ return listings;
164
+ };
165
+
166
+ export const buildInteractiveBrowseDocument = (listings, rootPath = "/") => `<!DOCTYPE html>
167
+ <html lang="en">
168
+ <head>
169
+ <meta charset="utf-8" />
170
+ <title>Index of ${rootPath}</title>
171
+ <style>
172
+ body { font-family: system-ui, sans-serif; margin: 2rem; }
173
+ h1 { font-size: 1.1rem; font-weight: 600; }
174
+ ul { list-style: none; padding: 0; }
175
+ li { margin: 0.35rem 0; }
176
+ a { text-decoration: none; color: inherit; }
177
+ a:hover { text-decoration: underline; }
178
+ </style>
179
+ </head>
180
+ <body>
181
+ <h1 id="title">Index of ${rootPath}</h1>
182
+ <ul id="list"></ul>
183
+ <script>
184
+ const listings = ${JSON.stringify(listings)};
185
+ const title = document.getElementById("title");
186
+ const list = document.getElementById("list");
187
+
188
+ const parentPath = (path) => {
189
+ if (path === "/") return "/";
190
+ const trimmed = path.replace(/\\/+$/, "");
191
+ const slash = trimmed.lastIndexOf("/");
192
+ return slash <= 0 ? "/" : trimmed.slice(0, slash);
193
+ };
194
+
195
+ const render = (path) => {
196
+ const entries = listings[path] || [];
197
+ title.textContent = "Index of " + path;
198
+ list.replaceChildren();
199
+
200
+ if (path !== "/") {
201
+ const parentItem = document.createElement("li");
202
+ const parentLink = document.createElement("a");
203
+ parentLink.href = "#";
204
+ parentLink.textContent = "../";
205
+ parentLink.addEventListener("click", (event) => {
206
+ event.preventDefault();
207
+ render(parentPath(path));
208
+ });
209
+ parentItem.appendChild(parentLink);
210
+ list.appendChild(parentItem);
211
+ }
212
+
213
+ entries
214
+ .slice()
215
+ .sort((left, right) => {
216
+ if (left.isDirectory !== right.isDirectory) {
217
+ return left.isDirectory ? -1 : 1;
218
+ }
219
+ return left.name.localeCompare(right.name);
220
+ })
221
+ .forEach((entry) => {
222
+ const item = document.createElement("li");
223
+ const link = document.createElement("a");
224
+
225
+ if (entry.isDirectory) {
226
+ link.href = "#";
227
+ link.textContent = entry.name + "/";
228
+ link.addEventListener("click", (event) => {
229
+ event.preventDefault();
230
+ render(entry.path);
231
+ });
232
+ } else {
233
+ link.href = entry.href;
234
+ link.textContent = entry.name;
235
+ }
236
+
237
+ item.appendChild(link);
238
+ list.appendChild(item);
239
+ });
240
+ };
241
+
242
+ render(${JSON.stringify(rootPath)});
243
+ </script>
244
+ </body>
245
+ </html>
246
+ `;
@@ -83,6 +83,7 @@ export const buildV2Manifest = ({
83
83
  files,
84
84
  kind = "web-app",
85
85
  runtime = "iframe-sandbox",
86
+ source = "",
86
87
  }) => {
87
88
  const sortedPaths = Object.keys(files).sort();
88
89
 
@@ -93,6 +94,7 @@ export const buildV2Manifest = ({
93
94
  name,
94
95
  entry,
95
96
  runtime,
97
+ ...(source ? { source } : {}),
96
98
  files: sortedPaths.map((filePath) => {
97
99
  const bytes = toUint8Array(files[filePath]);
98
100
  return {
@@ -106,13 +108,14 @@ export const buildV2Manifest = ({
106
108
  };
107
109
  };
108
110
 
109
- export const encodeV2Archive = ({ name, entry, files, kind, runtime }) => {
111
+ export const encodeV2Archive = ({ name, entry, files, kind, runtime, source }) => {
110
112
  const { manifest, sortedPaths } = buildV2Manifest({
111
113
  name,
112
114
  entry,
113
115
  files,
114
116
  kind,
115
117
  runtime,
118
+ source,
116
119
  });
117
120
  const json = new TextEncoder().encode(JSON.stringify(manifest));
118
121
  const header = new Uint8Array(4);
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const toolsDir = path.dirname(fileURLToPath(import.meta.url));
7
+ const packageRoot = path.dirname(toolsDir);
8
+ const outfile = path.join(toolsDir, "playLauncher.bundle.js");
9
+
10
+ const result = spawnSync(
11
+ "npx",
12
+ [
13
+ "--yes",
14
+ "esbuild",
15
+ path.join(packageRoot, "src/index.js"),
16
+ "--bundle",
17
+ "--platform=browser",
18
+ "--format=iife",
19
+ "--global-name=MemoryExtract",
20
+ "--target=chrome109,firefox115,safari16",
21
+ `--outfile=${outfile}`,
22
+ ],
23
+ { cwd: packageRoot, stdio: "inherit" }
24
+ );
25
+
26
+ if (result.status !== 0) {
27
+ process.exit(result.status ?? 1);
28
+ }
29
+
30
+ console.log("Built tools/playLauncher.bundle.js");