memory-extract 0.1.0 → 0.1.1

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.1",
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
 
@@ -87,9 +92,9 @@ const extractCssAssetPaths = (cssText) => {
87
92
  return paths;
88
93
  };
89
94
 
90
- const collectRequiredFiles = (manifest, fileBytes) => {
95
+ const collectRequiredFiles = (manifest, fileBytes, entryPath) => {
91
96
  const manifestPaths = listManifestFiles(manifest);
92
- const required = new Set([manifest.entry]);
97
+ const required = new Set([entryPath]);
93
98
 
94
99
  for (const filePath of manifestPaths) {
95
100
  if (filePath.endsWith(".js") || filePath.endsWith(".css")) {
@@ -131,13 +136,61 @@ const collectRequiredFiles = (manifest, fileBytes) => {
131
136
  return required;
132
137
  };
133
138
 
134
- const buildLaunchDocument = (html, urlByPath) => {
139
+ const BLOB_LAUNCH_BASE = "http://localhost/";
140
+
141
+ const injectBlobLaunchShim = (doc) => {
142
+ const base = doc.createElement("base");
143
+ base.setAttribute("href", BLOB_LAUNCH_BASE);
144
+ doc.head.prepend(base);
145
+
146
+ const shim = doc.createElement("script");
147
+ shim.textContent = `(function () {
148
+ var base = ${JSON.stringify(BLOB_LAUNCH_BASE)};
149
+ var NativeURL = URL;
150
+ var invalidBase = function (value) {
151
+ if (!value) return true;
152
+ var text = String(value);
153
+ return text === "null" || text.indexOf("blob:") === 0;
154
+ };
155
+ URL = function (url, baseUrl) {
156
+ if (typeof url === "string" && url.charAt(0) === "/" && invalidBase(baseUrl)) {
157
+ return new NativeURL(url, base);
158
+ }
159
+ return new NativeURL(url, baseUrl);
160
+ };
161
+ URL.prototype = NativeURL.prototype;
162
+ URL.createObjectURL = NativeURL.createObjectURL.bind(NativeURL);
163
+ URL.revokeObjectURL = NativeURL.revokeObjectURL.bind(NativeURL);
164
+ })();`;
165
+ doc.head.prepend(shim);
166
+ };
167
+
168
+ export const resolveLaunchAssetUrl = (rawPath, urlByPath, entryPath, manifestPaths) => {
169
+ const direct = resolveAssetUrl(rawPath, urlByPath);
170
+ if (direct) {
171
+ return direct;
172
+ }
173
+
174
+ if (!rawPath || !entryPath || !manifestPaths) {
175
+ return null;
176
+ }
177
+
178
+ const resolved = resolveManifestPath(entryPath, rawPath, manifestPaths);
179
+ return resolved ? urlByPath.get(resolved) ?? null : null;
180
+ };
181
+
182
+ const buildLaunchDocument = (html, urlByPath, entryPath, manifestPaths) => {
135
183
  const doc = new DOMParser().parseFromString(html, "text/html");
136
184
 
137
185
  REWRITABLE_ATTRIBUTES.forEach(([tag, attribute]) => {
138
186
  doc.querySelectorAll(`${tag}[${attribute}]`).forEach((element) => {
139
187
  const rawPath = element.getAttribute(attribute);
140
- const assetUrl = resolveAssetUrl(rawPath, urlByPath);
188
+ const assetUrl = resolveLaunchAssetUrl(
189
+ rawPath,
190
+ urlByPath,
191
+ entryPath,
192
+ manifestPaths
193
+ );
141
194
 
142
195
  if (assetUrl) {
143
196
  element.setAttribute(attribute, assetUrl);
@@ -151,6 +204,8 @@ const buildLaunchDocument = (html, urlByPath) => {
151
204
  element.removeAttribute("crossorigin");
152
205
  });
153
206
 
207
+ injectBlobLaunchShim(doc);
208
+
154
209
  return `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
155
210
  };
156
211
 
@@ -162,37 +217,98 @@ const getManifestFileMeta = (manifest, filePath) => {
162
217
  return manifest.files?.[filePath] ?? null;
163
218
  };
164
219
 
165
- export const createMemoryBlob = async (input) => {
166
- const bytes = await readInput(input);
167
- const { manifest, fileBytes } = await extractMemory(bytes);
168
- const blobUrls = [];
220
+ const attachBlobDispose = (blob, blobUrls) => {
221
+ blob.dispose = () => {
222
+ blobUrls.forEach((url) => URL.revokeObjectURL(url));
223
+ };
224
+
225
+ return blob;
226
+ };
227
+
228
+ const createFileLaunchBlob = (manifest, fileBytes, filePath, blobUrls) => {
229
+ const fileMeta = getManifestFileMeta(manifest, filePath);
230
+ const fileContent = readMemoryFile(manifest, filePath, fileBytes);
231
+ const mime = guessMimeType(filePath, fileMeta);
232
+ const launchBlob = new Blob([fileContent], { type: mime });
233
+
234
+ return attachBlobDispose(launchBlob, blobUrls);
235
+ };
236
+
237
+ const createBrowseLaunchBlob = (manifest, fileBytes, filePaths, blobUrls) => {
238
+ const fileUrlByPath = new Map();
239
+
240
+ for (const filePath of filePaths) {
241
+ const fileMeta = getManifestFileMeta(manifest, filePath);
242
+ const fileContent = readMemoryFile(manifest, filePath, fileBytes);
243
+ const mime = guessMimeType(filePath, fileMeta);
244
+ const assetUrl = URL.createObjectURL(new Blob([fileContent], { type: mime }));
245
+ blobUrls.push(assetUrl);
246
+ fileUrlByPath.set(filePath, assetUrl);
247
+ }
248
+
249
+ const listings = buildMemoryBrowseListings(filePaths, fileUrlByPath);
250
+ const launchDocument = buildInteractiveBrowseDocument(listings);
251
+ const launchBlob = new Blob([launchDocument], { type: "text/html" });
252
+
253
+ return attachBlobDispose(launchBlob, blobUrls);
254
+ };
255
+
256
+ const createPlayLaunchBlob = (manifest, fileBytes, entryPath, blobUrls) => {
169
257
  const urlByPath = new Map();
170
- const requiredFiles = collectRequiredFiles(manifest, fileBytes);
258
+ const requiredFiles = collectRequiredFiles(manifest, fileBytes, entryPath);
171
259
 
172
260
  for (const filePath of requiredFiles) {
173
261
  const fileMeta = getManifestFileMeta(manifest, filePath);
174
262
  const fileContent = readMemoryFile(manifest, filePath, fileBytes);
175
263
  const mime = guessMimeType(filePath, fileMeta);
176
- const assetBlob = new Blob([fileContent], { type: mime });
177
- const assetUrl = URL.createObjectURL(assetBlob);
264
+ const assetUrl = URL.createObjectURL(new Blob([fileContent], { type: mime }));
178
265
  blobUrls.push(assetUrl);
179
266
  urlByPath.set(filePath, assetUrl);
180
267
  urlByPath.set(normalizeAssetPath(filePath), assetUrl);
181
268
  }
182
269
 
183
- if (!requiredFiles.has(manifest.entry)) {
184
- blobUrls.forEach((url) => URL.revokeObjectURL(url));
185
- throw new Error(`Manifest entry not found: ${manifest.entry}`);
270
+ if (!requiredFiles.has(entryPath)) {
271
+ throw new Error(`Manifest entry not found: ${entryPath}`);
186
272
  }
187
273
 
188
- const htmlBytes = readMemoryFile(manifest, manifest.entry, fileBytes);
274
+ const htmlBytes = readMemoryFile(manifest, entryPath, fileBytes);
189
275
  const html = textDecoder.decode(htmlBytes);
190
- const launchDocument = buildLaunchDocument(html, urlByPath);
276
+ const manifestPaths = listManifestFiles(manifest);
277
+ const launchDocument = buildLaunchDocument(
278
+ html,
279
+ urlByPath,
280
+ entryPath,
281
+ manifestPaths
282
+ );
191
283
  const launchBlob = new Blob([launchDocument], { type: "text/html" });
192
284
 
193
- launchBlob.dispose = () => {
194
- blobUrls.forEach((url) => URL.revokeObjectURL(url));
195
- };
285
+ return attachBlobDispose(launchBlob, blobUrls);
286
+ };
287
+
288
+ export const createMemoryBlob = async (input) => {
289
+ const bytes = await readInput(input);
290
+ const { manifest, fileBytes } = await extractMemory(bytes);
291
+ const filePaths = listManifestFiles(manifest);
292
+ const playMode = resolveMemoryPlayMode(manifest, filePaths);
293
+ const blobUrls = [];
196
294
 
197
- return launchBlob;
295
+ try {
296
+ if (playMode.mode === "file") {
297
+ return createFileLaunchBlob(manifest, fileBytes, playMode.path, blobUrls);
298
+ }
299
+
300
+ if (playMode.mode === "browse") {
301
+ return createBrowseLaunchBlob(manifest, fileBytes, filePaths, blobUrls);
302
+ }
303
+
304
+ return createPlayLaunchBlob(
305
+ manifest,
306
+ fileBytes,
307
+ playMode.entry ?? manifest.entry,
308
+ blobUrls
309
+ );
310
+ } catch (error) {
311
+ blobUrls.forEach((url) => URL.revokeObjectURL(url));
312
+ throw error;
313
+ }
198
314
  };
@@ -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");