@ammduncan/easel 0.3.3 → 0.4.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/CHANGELOG.md +10 -0
- package/dist/client/viewer.js +1 -0
- package/dist/http-server.js +20 -2
- package/dist/inline-images.js +91 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to easel. This project adheres to [Semantic Versioning](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## 0.4.1 — 2026-05-26
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **App-fidelity pushes (`kind:"app"`/`"mockup"`) can now be exported — they were silently hanging.** The 0.3.3 export fix (toSvg-based, rAF-free bridge) was only wired into `buildDefaultWrapper`'s *normal* branch and `injectBridge`. `buildDefaultWrapper` returns early for app-fidelity with a separate template, and that branch loaded the html-to-image CDN but never injected the `imageExportScript` bridge — so `kind:"app"`/`"mockup"` cards had **no `easel:image` handler at all**. Clicking export posted a message nothing listened for; the push never reported back and the 30s watchdog fired ("Export timed out…"). The bridge is now injected into the app-fidelity branch too. The `image-export` regression test now asserts the bridge is referenced by **both** `buildDefaultWrapper` branches plus `injectBridge`, so no single render path can lose it again. Verified live: an app-fidelity auth mockup that timed out now exports in ~0.5s.
|
|
9
|
+
|
|
10
|
+
## 0.4.0 — 2026-05-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **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`.
|
|
14
|
+
|
|
5
15
|
## 0.3.3 — 2026-05-26
|
|
6
16
|
|
|
7
17
|
### Fixed
|
package/dist/client/viewer.js
CHANGED
package/dist/http-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
"version": "0.4.1",
|
|
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",
|