preview: inline uploaded assets as data: URLs so they render in the sandboxed Preview
Preview (and version-history Preview) renders the page in an <iframe sandbox srcdoc> without allow-same-origin, i.e. an opaque origin. Every site-builder asset response carries Cross-Origin-Resource-Policy: same-site (whp libs/SiteBuilderAssetServe.php), so each upload requested from that frame is cross-site and blocked (ERR_BLOCKED_BY_RESPONSE.NotSameSite, naturalWidth 0). The canvas was fine; Preview images were broken for everyone. CORP and the sandbox stay as they are. Before the HTML is handed to the sandboxed frame, the editor (panel origin, same-origin to the proxy) fetches this site's proxy assets and swaps each URL for a data: URL in the HTML text (utils/preview-assets.ts): img src/srcset, poster, video src, <link> icon, inline-style and <style> url(), "/& forms, both proxy URL forms. <a href>, <meta content>, other sites, other origins are left alone. Per-asset (8 MiB), total (40 MiB), count (200) and timeout (15 s) caps; any per-asset failure keeps the original URL. Save/Publish/export are untouched. blob: was measured and rejected: in Chromium and Firefox a blob: URL made by the editor window or by the Preview tab does not load inside the null-origin frame; data: does. Tests: vitest for the collector/rewriter; new real-browser suite test:browser:preview-assets (real HTTP stub with the backend's CORP header, Preview opened by a click through the real openers) reproduces the block with the old opener and passes with the new one in Chromium and Firefox, measures blob vs data, and checks the real backend publish holds only assets/<file>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185ukXgk6UunFCKyXqLe2Cq
This commit is contained in:
1 parent
c6809293c4
commit
8ff0e01c45
7 files changed
+596
-4
No files matched your search
@@ -10,7 +10,7 @@ import { useNotices } from '../../state/NoticeContext';
|
||||
import { useRegisterCommands } from '../../editor/commands';
|
||||
import { useHistoryApi, type HistoryItem } from '../../hooks/useHistoryApi';
|
||||
import { repairOrphanNodes } from '../../utils/orphan-repair';
|
||||
import { openSandboxedPreview } from '../../utils/sandboxed-preview';
|
||||
import { openPreviewWithInlinedAssets } from '../../utils/preview-assets';
|
||||
import {
|
||||
buildSnapshotPreviewHtml,
|
||||
planPageRestore,
|
||||
@@ -159,7 +159,13 @@ export const HistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
const html = buildSnapshotPreviewHtml(snapshot, pageIndex, window.location.origin);
|
||||
// The snapshot's head code is customer-controlled: sandboxed srcdoc iframe only.
|
||||
const pageName = snapshotToPages(snapshot)[pageIndex]?.name;
|
||||
openSandboxedPreview(html, { title: pageName ? `Version preview - ${pageName}` : 'Version preview', win });
|
||||
// Uploads are inlined as data: URLs first: the sandboxed frame's opaque origin cannot load the
|
||||
// CORP same-site proxy (utils/preview-assets.ts). Its own failures fall back to the plain HTML.
|
||||
const siteId = (window as { WHP_CONFIG?: { siteId?: number } }).WHP_CONFIG?.siteId;
|
||||
void openPreviewWithInlinedAssets(html, {
|
||||
title: pageName ? `Version preview - ${pageName}` : 'Version preview', win,
|
||||
origin: window.location.origin, siteId,
|
||||
}).catch((e) => { console.error('History preview failed:', e); win?.close(); });
|
||||
} catch (e) {
|
||||
console.error('History preview failed:', e);
|
||||
win?.close();
|
||||
|
||||
@@ -279,7 +279,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
const win = window.open('', '_blank');
|
||||
try {
|
||||
const serialized = query.serialize();
|
||||
Promise.all([import('../../utils/html-export'), import('../../utils/sandboxed-preview')]).then(([{ exportComposedPreviewHtml, exportBodyHtml }, { openSandboxedPreview }]) => {
|
||||
Promise.all([import('../../utils/html-export'), import('../../utils/preview-assets')]).then(([{ exportComposedPreviewHtml, exportBodyHtml }, { openPreviewWithInlinedAssets }]) => {
|
||||
// Get header HTML
|
||||
let headerHtml = '';
|
||||
try {
|
||||
@@ -338,7 +338,10 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
html = html.replace(/url\('\/api\//g, `url('${origin}/api/`);
|
||||
// Customer head code is in `html`: render it ONLY in a sandboxed srcdoc
|
||||
// iframe (no allow-same-origin), never as a panel-origin document.
|
||||
openSandboxedPreview(html, { title, win });
|
||||
// That frame has an opaque origin, so the proxy's CORP: same-site blocks
|
||||
// the uploads it requests; the editor (same origin) fetches them first and
|
||||
// hands them over as data: URLs (Preview only -- see utils/preview-assets.ts).
|
||||
return openPreviewWithInlinedAssets(html, { title, win, origin, siteId: whpConfig?.siteId });
|
||||
}).catch((e) => {
|
||||
console.error('Preview failed:', e);
|
||||
win?.close();
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { collectPreviewAssetUrls, inlinePreviewAssets } from './preview-assets';
|
||||
|
||||
const O = 'https://panel.example:8443';
|
||||
const P = (f: string, site = 7) => `/api/assets-proxy/?site_id=${site}&filename=${f}`;
|
||||
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
|
||||
const PNG_DATA = 'data:image/png;base64,' + btoa(String.fromCharCode(...PNG));
|
||||
|
||||
function fakeFetch(map: Record<string, { status?: number; type?: string; body?: Uint8Array; len?: string } | 'throw'>) {
|
||||
return vi.fn(async (url: string) => {
|
||||
const r = map[url];
|
||||
if (!r) return new Response('', { status: 404 });
|
||||
if (r === 'throw') throw new TypeError('network');
|
||||
const headers: Record<string, string> = { 'content-type': r.type ?? 'image/png' };
|
||||
if (r.len) headers['content-length'] = r.len;
|
||||
return new Response((r.body ?? PNG) as unknown as BodyInit, { status: r.status ?? 200, headers });
|
||||
}) as unknown as typeof fetch & ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
describe('collectPreviewAssetUrls', () => {
|
||||
it('finds every resource context in document order: img src, srcset, poster, video src, link icon, inline style, <style>, " and & forms', () => {
|
||||
const html = [
|
||||
`<link rel="icon" href="${P('fav.png')}">`,
|
||||
`<style>.h{background:url("${P('css.png')}")} .k{background:url(${P('bare.png')})}</style>`,
|
||||
`<img src="${O}${P('abs.png')}" srcset="${P('a1.png')} 1x, ${P('a2.png')} 2x">`,
|
||||
`<video poster="${P('poster.png')}" src="${P('clip.mp4')}"></video>`,
|
||||
`<div style="background-image:url("${P('q.png')}")"></div>`,
|
||||
`<img src="/api/site-builder.php?action=serve_asset&site_id=7&filename=old.png">`,
|
||||
].join('');
|
||||
const hits = collectPreviewAssetUrls(html, { origin: O, siteId: 7 });
|
||||
expect(hits.map((h) => new URL(h.key).searchParams.get('filename'))).toEqual(
|
||||
['fav.png', 'css.png', 'bare.png', 'abs.png', 'a1.png', 'a2.png', 'poster.png', 'clip.mp4', 'q.png', 'old.png']);
|
||||
// the " after the URL is not swallowed, the & form is decoded for fetching
|
||||
const q = hits.find((h) => h.raw.includes('q.png'))!;
|
||||
expect(html.slice(q.end, q.end + 6)).toBe('"');
|
||||
expect(hits[hits.length - 1].key).toBe(`${O}/api/site-builder.php?action=serve_asset&site_id=7&filename=old.png`);
|
||||
});
|
||||
|
||||
it('leaves links, meta values, other sites, other origins, off-origin protocol-relative and plain assets/ paths alone', () => {
|
||||
const html = [
|
||||
`<a href="${P('link.png')}">x</a>`,
|
||||
`<meta property="og:image" content="${O}${P('og.png')}">`,
|
||||
`<img src="${P('other.png', 8)}">`,
|
||||
`<img src="https://evil.example${P('evil.png')}">`,
|
||||
`<img src="//evil.example${P('pr.png')}">`,
|
||||
`<img src="assets/plain.png">`,
|
||||
].join('');
|
||||
expect(collectPreviewAssetUrls(html, { origin: O, siteId: 7 })).toEqual([]);
|
||||
// without a site id, other sites' proxy URLs on the same origin are collected
|
||||
expect(collectPreviewAssetUrls(`<img src="${P('other.png', 8)}">`, { origin: O }).length).toBe(1);
|
||||
// a protocol-relative URL on the panel's own host is same-origin
|
||||
expect(collectPreviewAssetUrls(`<img src="//panel.example:8443${P('pr.png')}">`, { origin: O, siteId: 7 })[0].key).toBe(`${O}${P('pr.png')}`);
|
||||
});
|
||||
|
||||
it('a trailing query parameter is part of the URL (replaced with it), a closing " is not', () => {
|
||||
const html = `<div style="background:url("${P('a.png')}&v=2")"></div>`;
|
||||
const [h] = collectPreviewAssetUrls(html, { origin: O, siteId: 7 });
|
||||
expect(h.raw).toBe(`${P('a.png')}&v=2`);
|
||||
expect(h.key).toBe(`${O}${P('a.png')}&v=2`);
|
||||
});
|
||||
|
||||
it('decodes nothing it cannot: a php-urlencoded name is fetched exactly as written', () => {
|
||||
const [h] = collectPreviewAssetUrls(`<img src="${P('my+photo%2Bv2.png')}">`, { origin: O, siteId: '7' });
|
||||
expect(h.key).toBe(`${O}${P('my+photo%2Bv2.png')}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inlinePreviewAssets', () => {
|
||||
it('replaces each occurrence with a data: URL, fetching each distinct asset once, keeping the rest byte-identical', async () => {
|
||||
const f = fakeFetch({ [`${O}${P('a.png')}`]: {} });
|
||||
const html = `<head><script>/*head code*/</script></head><img src="${P('a.png')}"><div style="background:url('${O}${P('a.png')}')"></div>`;
|
||||
const r = await inlinePreviewAssets(html, { origin: O, siteId: 7, fetchImpl: f });
|
||||
expect(f).toHaveBeenCalledTimes(1);
|
||||
expect(r.html).toBe(`<head><script>/*head code*/</script></head><img src="${PNG_DATA}"><div style="background:url('${PNG_DATA}')"></div>`);
|
||||
expect(r).toMatchObject({ found: 1, inlined: 1, skipped: 0 });
|
||||
});
|
||||
|
||||
it('a failing asset keeps its URL; the others are still inlined', async () => {
|
||||
const f = fakeFetch({
|
||||
[`${O}${P('ok.png')}`]: {},
|
||||
[`${O}${P('err.png')}`]: { status: 500 },
|
||||
[`${O}${P('net.png')}`]: 'throw',
|
||||
[`${O}${P('html.png')}`]: { type: 'text/html' },
|
||||
[`${O}${P('empty.png')}`]: { body: new Uint8Array() },
|
||||
});
|
||||
const html = ['ok', 'err', 'net', 'missing', 'html', 'empty'].map((n) => `<img src="${P(n + '.png')}">`).join('');
|
||||
const r = await inlinePreviewAssets(html, { origin: O, siteId: 7, fetchImpl: f });
|
||||
expect(r.html).toBe(`<img src="${PNG_DATA}">` + ['err', 'net', 'missing', 'html', 'empty'].map((n) => `<img src="${P(n + '.png')}">`).join(''));
|
||||
expect(r).toMatchObject({ found: 6, inlined: 1, skipped: 5 });
|
||||
});
|
||||
|
||||
it('honours the per-asset, total and count caps', async () => {
|
||||
const big = new Uint8Array(100);
|
||||
const f = fakeFetch({
|
||||
[`${O}${P('a.png')}`]: { body: big }, [`${O}${P('b.png')}`]: { body: big },
|
||||
[`${O}${P('c.png')}`]: { body: big, len: '100' }, [`${O}${P('d.png')}`]: {},
|
||||
});
|
||||
const html = ['a', 'b', 'c', 'd'].map((n) => `<img src="${P(n + '.png')}">`).join('');
|
||||
const perAsset = await inlinePreviewAssets(html, { origin: O, siteId: 7, fetchImpl: f, limits: { maxAssetBytes: 50 } });
|
||||
expect(perAsset.inlined).toBe(1); // only d (7 bytes)
|
||||
const total = await inlinePreviewAssets(html, { origin: O, siteId: 7, fetchImpl: f, limits: { maxTotalBytes: 150, concurrency: 1 } });
|
||||
expect(total.inlined).toBe(2); // a (100) + d (7); b and c would pass 150
|
||||
const count = await inlinePreviewAssets(html, { origin: O, siteId: 7, fetchImpl: f, limits: { maxAssets: 2 } });
|
||||
expect(count.inlined).toBe(2);
|
||||
expect(count.html.endsWith(`<img src="${P('c.png')}"><img src="${P('d.png')}">`)).toBe(true);
|
||||
});
|
||||
|
||||
it('times out a hung request and leaves its URL', async () => {
|
||||
const f = vi.fn((_u: string, init?: RequestInit) => new Promise<Response>((_res, rej) => {
|
||||
init?.signal?.addEventListener('abort', () => rej(new DOMException('aborted', 'AbortError')));
|
||||
})) as unknown as typeof fetch;
|
||||
const html = `<img src="${P('hang.png')}">`;
|
||||
const r = await inlinePreviewAssets(html, { origin: O, siteId: 7, fetchImpl: f, limits: { timeoutMs: 20 } });
|
||||
expect(r.html).toBe(html);
|
||||
});
|
||||
|
||||
it('no proxy URL: no fetch at all, input returned as is', async () => {
|
||||
const f = fakeFetch({});
|
||||
const html = '<img src="https://cdn.example/x.png"><img src="assets/y.png">';
|
||||
expect((await inlinePreviewAssets(html, { origin: O, fetchImpl: f })).html).toBe(html);
|
||||
expect(f).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Preview-only: inline the site's uploaded assets as `data:` URLs before the page is
|
||||
* handed to the sandboxed Preview frame.
|
||||
*
|
||||
* Why: the editor stores an upload as the panel proxy URL
|
||||
* (`/api/assets-proxy/?site_id=N&filename=F`, or the older
|
||||
* `/api/site-builder.php?action=serve_asset&site_id=N&filename=F`). Every asset response
|
||||
* carries `Cross-Origin-Resource-Policy: same-site` (whp libs/SiteBuilderAssetServe.php,
|
||||
* a deliberate control: customer files must not be embeddable cross-site). Preview
|
||||
* renders in `<iframe sandbox srcdoc>` WITHOUT allow-same-origin (sandboxed-preview.ts),
|
||||
* so the frame has an opaque origin, every request it makes is cross-site, and the
|
||||
* browser blocks the image (ERR_BLOCKED_BY_RESPONSE.NotSameSite, naturalWidth 0).
|
||||
*
|
||||
* Fix, without touching CORP or the sandbox: the editor (panel origin, same-origin to the
|
||||
* proxy) fetches each referenced proxy asset itself and swaps the URL for a `data:` URL
|
||||
* in the HTML text. Measured in Chromium and Firefox (scripts/e2e-preview-assets.mjs):
|
||||
* inside the null-origin srcdoc frame a `data:` image loads; a `blob:` URL does NOT, whether
|
||||
* created by the editor window or by the Preview tab (both are panel-origin blob URLs, and
|
||||
* a blob URL only resolves for its own origin), so blob: is not an option here.
|
||||
*
|
||||
* Scope and limits:
|
||||
* - only same-origin proxy URLs (root-relative, or absolute on `origin`) for `siteId`
|
||||
* (when given) are touched; anything else -- other sites, other hosts, plain
|
||||
* `assets/x` paths, `<a href>` links, `<meta content>` -- is left byte-for-byte;
|
||||
* - the HTML is edited as text (no parse/reserialise), so everything else in the
|
||||
* document, including customer head code, is unchanged;
|
||||
* - per-asset, total-byte, count and time caps; any failure (HTTP error, wrong type,
|
||||
* over a cap, timeout) leaves THAT URL as it was -- no worse than before;
|
||||
* - the result is only ever given to Preview; Save/Publish/export never call this,
|
||||
* so published output is unaffected.
|
||||
* data: URLs are plain strings, so there is nothing to revoke: they go away with the
|
||||
* Preview tab's document.
|
||||
*/
|
||||
import { openSandboxedPreview } from './sandboxed-preview';
|
||||
|
||||
export interface InlineLimits {
|
||||
/** Largest single asset inlined (bytes). Bigger ones keep their URL. */
|
||||
maxAssetBytes: number;
|
||||
/** Total bytes inlined per Preview. Assets past it keep their URL. */
|
||||
maxTotalBytes: number;
|
||||
/** Most distinct assets fetched per Preview. */
|
||||
maxAssets: number;
|
||||
/** Per-request timeout (ms). */
|
||||
timeoutMs: number;
|
||||
/** Requests in flight at once. */
|
||||
concurrency: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_INLINE_LIMITS: InlineLimits = {
|
||||
maxAssetBytes: 8 * 1024 * 1024,
|
||||
maxTotalBytes: 40 * 1024 * 1024,
|
||||
maxAssets: 200,
|
||||
timeoutMs: 15000,
|
||||
concurrency: 6,
|
||||
};
|
||||
|
||||
/** Types we will put in a data: URL. The proxy's own allowlist decides what it serves; this is belt and braces. */
|
||||
const INLINE_TYPES = new Set([
|
||||
'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml',
|
||||
'image/x-icon', 'image/vnd.microsoft.icon', 'image/bmp',
|
||||
'video/mp4', 'video/webm', 'video/ogg',
|
||||
]);
|
||||
|
||||
export interface InlineOptions {
|
||||
/** The panel origin (window.location.origin). Absolute proxy URLs on any other origin are left alone. */
|
||||
origin: string;
|
||||
/** Only this site's assets are inlined (WHP_CONFIG.siteId); undefined/null = any site id. */
|
||||
siteId?: string | number | null;
|
||||
limits?: Partial<InlineLimits>;
|
||||
/** Test seam; defaults to the global fetch. */
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface InlineResult {
|
||||
html: string;
|
||||
/** Distinct asset URLs found / inlined / left as they were. */
|
||||
found: number;
|
||||
inlined: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
// One proxy URL, same grammar as web-files/libs/SiteBuilderAssetUrls.php and editor-asset-urls.ts PROXY_URL,
|
||||
// but tighter at the end: the filename stops at `&`, whitespace, quotes, parens, `<>`, `,` (srcset) and `\`;
|
||||
// further `&key=value` parameters (e.g. a cache-buster) belong to the URL and are replaced with it, while an
|
||||
// entity such as the `"` closing a style url() has no `=` and is never swallowed.
|
||||
const PROXY_RE = /(?:(?:https?:)?\/\/[^/"'\s()<>]+)?\/api\/(?:assets-proxy\/\?|site-builder\.php\?action=serve_asset(?:&|&))site_id=([^&"'\s()<>]+)(?:&|&)filename=([^&"'\s()<>,\\]+)(?:(?:&|&)[A-Za-z0-9_]+=[^&"'\s()<>,\\]*)*/g;
|
||||
|
||||
interface Hit { start: number; end: number; raw: string; key: string }
|
||||
|
||||
/** True when the match at `start` is the value of an attribute we must not rewrite (a link target, a meta value). */
|
||||
function inSkippedAttr(html: string, start: number): boolean {
|
||||
const before = html.slice(Math.max(0, start - 64), start);
|
||||
const m = /\b(href|content|action|formaction)\s*=\s*["']?$/i.exec(before);
|
||||
if (!m) return false;
|
||||
if (m[1].toLowerCase() !== 'href') return true;
|
||||
// <link href> (favicon, preload) is a resource; <a href>/<area href> is a link and stays a link.
|
||||
const tagStart = html.lastIndexOf('<', start);
|
||||
return !(tagStart >= 0 && /^<link\b/i.test(html.slice(tagStart, tagStart + 6)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every rewritable proxy URL in `html`, in document order, with the absolute URL to fetch (`key`).
|
||||
* Exported for tests.
|
||||
*/
|
||||
export function collectPreviewAssetUrls(html: string, opts: Pick<InlineOptions, 'origin' | 'siteId'>): Hit[] {
|
||||
const hits: Hit[] = [];
|
||||
if (!html.includes('/api/')) return hits;
|
||||
const want = opts.siteId === undefined || opts.siteId === null || opts.siteId === '' ? null : String(opts.siteId);
|
||||
PROXY_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = PROXY_RE.exec(html))) {
|
||||
const raw = m[0];
|
||||
const start = m.index;
|
||||
if (want !== null && m[1] !== want) continue;
|
||||
if (inSkippedAttr(html, start)) continue;
|
||||
const decoded = raw.replace(/&/g, '&');
|
||||
let abs: URL;
|
||||
try { abs = new URL(decoded, opts.origin + '/'); } catch { continue; }
|
||||
if (abs.origin !== opts.origin) continue;
|
||||
hits.push({ start, end: start + raw.length, raw, key: abs.href });
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
async function toDataUrl(buf: ArrayBuffer, type: string): Promise<string> {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = '';
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK) as unknown as number[]);
|
||||
}
|
||||
return `data:${type};base64,${btoa(bin)}`;
|
||||
}
|
||||
|
||||
/** Fetch one asset; null when it must keep its URL. `reserve` claims bytes against the total cap. */
|
||||
async function fetchOne(url: string, lim: InlineLimits, f: typeof fetch, reserve: (n: number) => boolean): Promise<string | null> {
|
||||
const ctl = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
const timer = ctl ? setTimeout(() => ctl.abort(), lim.timeoutMs) : null;
|
||||
try {
|
||||
const res = await f(url, { credentials: 'same-origin', cache: 'force-cache', signal: ctl?.signal, redirect: 'error' });
|
||||
if (!res.ok) return null;
|
||||
const type = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
||||
if (!INLINE_TYPES.has(type)) return null;
|
||||
const declared = Number(res.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > lim.maxAssetBytes) { ctl?.abort(); return null; }
|
||||
const buf = await res.arrayBuffer();
|
||||
if (buf.byteLength === 0 || buf.byteLength > lim.maxAssetBytes) return null;
|
||||
if (!reserve(buf.byteLength)) return null;
|
||||
return await toDataUrl(buf, type);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `html` with this site's same-origin proxy asset URLs replaced by `data:` URLs (see the file header).
|
||||
* Never throws for a per-asset problem; the worst case is the input unchanged.
|
||||
*/
|
||||
export async function inlinePreviewAssets(html: string, opts: InlineOptions): Promise<InlineResult> {
|
||||
const lim: InlineLimits = { ...DEFAULT_INLINE_LIMITS, ...(opts.limits || {}) };
|
||||
const f = opts.fetchImpl ?? (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
|
||||
const hits = collectPreviewAssetUrls(html, opts);
|
||||
const keys: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const h of hits) if (!seen.has(h.key)) { seen.add(h.key); keys.push(h.key); }
|
||||
if (!f || keys.length === 0) return { html, found: keys.length, inlined: 0, skipped: keys.length };
|
||||
|
||||
const todo = keys.slice(0, Math.max(0, lim.maxAssets));
|
||||
const data = new Map<string, string>();
|
||||
let total = 0;
|
||||
// Assets are started in document order; bytes are granted first come, first served up to the total cap.
|
||||
const reserve = (n: number): boolean => {
|
||||
if (total + n > lim.maxTotalBytes) return false;
|
||||
total += n;
|
||||
return true;
|
||||
};
|
||||
let next = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
while (next < todo.length) {
|
||||
const key = todo[next++];
|
||||
const d = await fetchOne(key, lim, f, reserve);
|
||||
if (d) data.set(key, d);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.max(1, Math.min(lim.concurrency, todo.length)) }, worker));
|
||||
|
||||
if (data.size === 0) return { html, found: keys.length, inlined: 0, skipped: keys.length };
|
||||
let out = '';
|
||||
let pos = 0;
|
||||
for (const h of hits) {
|
||||
const d = data.get(h.key);
|
||||
if (!d) continue;
|
||||
out += html.slice(pos, h.start) + d;
|
||||
pos = h.end;
|
||||
}
|
||||
out += html.slice(pos);
|
||||
return { html: out, found: keys.length, inlined: data.size, skipped: keys.length - data.size };
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview entry point: inline the assets, then render in the sandboxed tab. Any failure of the inlining
|
||||
* itself falls back to the HTML as given (today's behaviour). When `win` (the tab opened inside the
|
||||
* click) was closed while the assets were loading, nothing is opened.
|
||||
*/
|
||||
export async function openPreviewWithInlinedAssets(
|
||||
html: string,
|
||||
opts: InlineOptions & { title?: string; win?: Window | null },
|
||||
): Promise<Window | null> {
|
||||
let out = html;
|
||||
try {
|
||||
out = (await inlinePreviewAssets(html, opts)).html;
|
||||
} catch (e) {
|
||||
console.warn('Preview: could not inline assets', e);
|
||||
}
|
||||
if (opts.win && opts.win.closed) return null;
|
||||
return openSandboxedPreview(out, { title: opts.title, win: opts.win });
|
||||
}
|
||||
Reference in new issue
Block a user