@ammduncan/easel 0.3.3 → 0.4.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/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  All notable changes to easel. This project adheres to [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## 0.4.0 — 2026-05-26
6
+
7
+ ### Added
8
+ - **Remote images are now inlined server-side at push time, so pushes that embed cross-origin images export correctly.** A push that referenced a remote image (e.g. a mobbin.com screenshot in an `app`/`mockup` recreation) rendered fine on screen but **couldn't be exported to PNG/PDF**: the browser blocks cross-origin images from canvas rasterisation (CORS), and drawing one taints the canvas so `toDataURL` throws — export would stall until the 30s watchdog fired (see 0.3.3). The fix moves the fetch to the server: `POST /api/push` now scans incoming HTML for remote `<img src>` and CSS `url(...)` references, fetches each server-side (no CORS limit), and rewrites them to self-contained `data:` URLs before storing the push. Exports then work, and the push survives the original URL later expiring (mobbin's `…/mcp/short/…` links are ephemeral). Fetches run in parallel, each bounded by an 8s timeout with an 8MB size cap and an image-content-type check; a URL that can't be inlined (timeout, non-image, too large, network error) is left untouched — it still displays, just won't appear in an export — so a dead link degrades gracefully instead of failing the push. Opt out with `EASEL_INLINE_IMAGES=0`. Covered by `tests/unit/inline-images.test.mjs`.
9
+
5
10
  ## 0.3.3 — 2026-05-26
6
11
 
7
12
  ### Fixed
@@ -6,6 +6,7 @@ import { appendPush, deletePush, deleteSession, getSessionView, listSessionSumma
6
6
  import { readConfig, writeConfig } from "./config-store.js";
7
7
  import { clearLockIfMine, writeLock } from "./server-manager.js";
8
8
  import { resolvePort } from "./paths.js";
9
+ import { inlineRemoteImages } from "./inline-images.js";
9
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
11
  const CLIENT_DIR = resolve(__dirname, "client");
11
12
  const clients = new Map();
@@ -157,7 +158,7 @@ export function startHttpServer() {
157
158
  const ok = deleteSession(id);
158
159
  res.json({ ok });
159
160
  });
160
- app.post("/api/push", (req, res) => {
161
+ app.post("/api/push", async (req, res) => {
161
162
  const { sessionId, html, title, kind } = req.body ?? {};
162
163
  if (typeof sessionId !== "string" || !sessionId.trim()) {
163
164
  res.status(400).json({ error: "sessionId required" });
@@ -167,7 +168,24 @@ export function startHttpServer() {
167
168
  res.status(400).json({ error: "html required" });
168
169
  return;
169
170
  }
170
- const push = appendPush(sessionId, { html, title, kind });
171
+ // Inline remote images server-side so the stored push is self-contained
172
+ // and exportable (cross-origin images are CORS-blocked from client-side
173
+ // rasterisation). Best-effort: on any failure we store the original html.
174
+ let storedHtml = html;
175
+ if (process.env.EASEL_INLINE_IMAGES !== "0") {
176
+ try {
177
+ const result = await inlineRemoteImages(html);
178
+ storedHtml = result.html;
179
+ if (result.failed.length > 0) {
180
+ console.warn(`[easel] ${result.failed.length} remote image(s) left un-inlined (won't export): ` +
181
+ result.failed.map((f) => `${f.url} — ${f.reason}`).join("; "));
182
+ }
183
+ }
184
+ catch (err) {
185
+ console.warn("[easel] image inlining failed; storing original html:", err);
186
+ }
187
+ }
188
+ const push = appendPush(sessionId, { html: storedHtml, title, kind });
171
189
  touchSession(sessionId);
172
190
  broadcast(sessionId, "push", push);
173
191
  if (Math.random() < 0.05) {
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Inline remote images into pushed HTML so exports work.
3
+ *
4
+ * Cross-origin images (e.g. mobbin screenshots) render on screen but can't be
5
+ * rasterised client-side: fetching them for inlining is CORS-blocked, and
6
+ * drawing a cross-origin image taints the canvas so PNG/PDF export throws. We
7
+ * fetch them server-side (no CORS) at push time and rewrite the references to
8
+ * self-contained `data:` URLs, so the stored push exports cleanly and survives
9
+ * the original URL later expiring.
10
+ *
11
+ * A remote URL that can't be inlined (timeout, non-image, too large, network
12
+ * error) is left untouched — it still displays cross-origin, it just won't
13
+ * appear in an export — so a dead link degrades gracefully instead of failing
14
+ * the push. Only `http(s)` URLs are touched; `data:`, `blob:`, and relative
15
+ * references are left alone.
16
+ */
17
+ const PER_IMAGE_TIMEOUT_MS = 8000;
18
+ const MAX_IMAGE_BYTES = 8_000_000;
19
+ // `src="https://…"` (img/source/…) and CSS `url(https://…)` with optional quotes.
20
+ // Capture group 2 is the URL in both.
21
+ const URL_PATTERNS = [
22
+ /\bsrc\s*=\s*(["'])(https?:\/\/[^"']+?)\1/gi,
23
+ /url\(\s*(["']?)(https?:\/\/[^)"']+?)\1\s*\)/gi,
24
+ ];
25
+ function collectRemoteUrls(html) {
26
+ const urls = new Set();
27
+ for (const pattern of URL_PATTERNS) {
28
+ const re = new RegExp(pattern.source, pattern.flags);
29
+ let match;
30
+ while ((match = re.exec(html)) !== null) {
31
+ urls.add(match[2]);
32
+ }
33
+ }
34
+ return [...urls];
35
+ }
36
+ async function fetchAsDataUri(url, fetchImpl) {
37
+ const ac = new AbortController();
38
+ const timer = setTimeout(() => ac.abort(), PER_IMAGE_TIMEOUT_MS);
39
+ try {
40
+ const res = await fetchImpl(url, { signal: ac.signal, redirect: "follow" });
41
+ if (!res.ok) {
42
+ throw new Error(`HTTP ${res.status}`);
43
+ }
44
+ const type = (res.headers.get("content-type") || "").split(";")[0].trim();
45
+ if (!type.startsWith("image/")) {
46
+ throw new Error(`not an image (${type || "unknown content-type"})`);
47
+ }
48
+ const buf = Buffer.from(await res.arrayBuffer());
49
+ if (buf.byteLength > MAX_IMAGE_BYTES) {
50
+ throw new Error(`too large (${buf.byteLength} bytes)`);
51
+ }
52
+ return `data:${type};base64,${buf.toString("base64")}`;
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ }
57
+ }
58
+ /**
59
+ * Fetch every remote image referenced in `html` and rewrite the references to
60
+ * `data:` URIs. Fetches run in parallel, each bounded by its own timeout, so
61
+ * total latency is roughly the slowest single image, not the sum.
62
+ *
63
+ * @param fetchImpl - injectable for tests; defaults to global `fetch`.
64
+ */
65
+ export async function inlineRemoteImages(html, fetchImpl = fetch) {
66
+ const urls = collectRemoteUrls(html);
67
+ if (urls.length === 0) {
68
+ return { html, inlined: 0, failed: [] };
69
+ }
70
+ const dataUriByUrl = new Map();
71
+ const failed = [];
72
+ await Promise.all(urls.map(async (url) => {
73
+ try {
74
+ dataUriByUrl.set(url, await fetchAsDataUri(url, fetchImpl));
75
+ }
76
+ catch (err) {
77
+ failed.push({ url, reason: err instanceof Error ? err.message : String(err) });
78
+ }
79
+ }));
80
+ // Swap each URL inside its matched attribute/url() context only, so a URL
81
+ // that is a prefix of another can't be corrupted by a blind global replace.
82
+ let out = html;
83
+ for (const pattern of URL_PATTERNS) {
84
+ const re = new RegExp(pattern.source, pattern.flags);
85
+ out = out.replace(re, (full, _quote, url) => {
86
+ const dataUri = dataUriByUrl.get(url);
87
+ return dataUri ? full.replace(url, dataUri) : full;
88
+ });
89
+ }
90
+ return { html: out, inlined: dataUriByUrl.size, failed };
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ammduncan/easel",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "A live browser tab for every Claude Code (and MCP) session. The push MCP tool appends HTML cards to a scrolling feed you keep open in split-screen.",
5
5
  "type": "module",
6
6
  "license": "MIT",