🩹 fix: PPTX black screen — allow blob: workers + harden bootstrap

Manual e2e of the PPTX CDN renderer surfaced a black screen with
"Could not establish connection. Receiving end does not exist."
unhandled-rejection — characteristic of a Web Worker that couldn't
start.

Root cause: pptx-preview's bundled echarts dep spins up Web Workers
via blob: URLs for chart rendering. Our CSP had `default-src 'none'`
and no `worker-src`, so workers fell back to default → blocked. The
async failure deep inside echarts didn't surface through the outer
`previewer.preview()` promise, so my bootstrap's `.catch` never fired,
the loading state was removed, and the iframe sat with the body
background showing through (dark navy in dark mode = "black screen").

Three changes:
- Add `worker-src blob:` to the PPTX CSP. Allows blob:-only worker
  creation without permitting arbitrary worker URLs.
- Bootstrap: window-level `unhandledrejection` and `error` listeners
  so rejections from inside bundled-dep async pipelines surface as
  the user-facing "Preview unavailable" fallback instead of going
  silent.
- Bootstrap: 8-second timeout that checks `container.children.length`
  — if the renderer hasn't appended anything visible by then, assume
  silent failure and show the fallback.

Also wipe `container.innerHTML` when showing the fallback so a partial
render doesn't compete with the message.

DOCX wrapper unchanged: docx-preview doesn't use workers, so the
worker-src directive doesn't apply, and the existing fallback path
already covers its failure modes.

Tests
-----
- Existing PPTX CSP test now also asserts `worker-src blob:` is present.
- Existing fallback-message test extended to cover the new
  unhandledrejection/error/timeout listeners.

packages/api/src/files: 467/467 ✅.
This commit is contained in:
Danny Avila 2026-05-04 16:42:24 -04:00
parent cbdcdd4b96
commit 71d8f106c9
2 changed files with 62 additions and 3 deletions

View file

@ -373,14 +373,29 @@ describe('Office HTML producers', () => {
expect(csp).toMatch(/form-action 'none'/);
expect(csp).toMatch(/script-src https:\/\/cdn\.jsdelivr\.net 'unsafe-inline'/);
expect(csp).not.toMatch(/unsafe-eval/);
/* PPTX must allow blob:-only Web Workers — pptx-preview's
* bundled echarts dep spins up workers via blob: URLs for
* chart rendering. Without this, the renderer's async
* pipeline throws unhandled rejections and the iframe shows
* a black screen. */
expect(csp).toMatch(/worker-src blob:/);
});
test('exposes a fallback message that surfaces if the renderer fails to load', async () => {
test('exposes a fallback message that surfaces if the renderer fails to load or times out', async () => {
const pptx = await buildPptx([{ title: 'X' }]);
const html = await _internal.pptxToHtmlViaCdn(pptx);
// Visible loading state + fallback that swaps in on error.
expect(html).toContain('Loading preview…');
expect(html).toContain('Preview unavailable');
// The renderer-not-loaded check.
expect(html).toContain("typeof pptxPreview === 'undefined'");
// The unhandledrejection + error listeners — pptx-preview's
// bundled deps raise async rejections that don't surface
// through the outer Promise.
expect(html).toContain("addEventListener('unhandledrejection'");
expect(html).toContain("addEventListener('error'");
// The 8-second timeout safety net for silent renderer failures.
expect(html).toContain('renderer-timeout');
});
test('size-fallback threshold is the documented 350 KB', () => {

View file

@ -972,10 +972,20 @@ const MAX_PPTX_CDN_BINARY_BYTES = 350 * 1024;
* once the renderer paints them.
*/
function buildPptxCdnDocument(base64: string): string {
/* PPTX-specific CSP relaxations vs DOCX:
* - `worker-src blob:` — pptx-preview's bundled echarts dep spins up
* Web Workers via blob: URLs for chart rendering. Without this,
* workers default to `default-src 'none'`, get blocked, and
* echarts throws an unhandled-rejection deep inside its async
* pipeline that we can't catch from the bootstrap script. The
* visible symptom is a black iframe with the renderer half-
* started. Allowing blob:-only workers is the minimum surface to
* unblock rendering without permitting arbitrary worker URLs. */
const csp = [
"default-src 'none'",
"script-src https://cdn.jsdelivr.net 'unsafe-inline'",
"style-src 'unsafe-inline'",
'worker-src blob:',
"img-src 'self' data: blob:",
'font-src data:',
"connect-src 'none'",
@ -1006,15 +1016,39 @@ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); fon
<script id="lc-doc-data" type="application/octet-stream;base64">${base64}</script>
<script>
(function () {
var settled = false;
function showFallback(reason) {
if (settled) { return; }
settled = true;
var loading = document.querySelector('.lc-pptx-loading');
if (loading) { loading.remove(); }
var container = document.getElementById('lc-render');
if (container) {
// Wipe any partial render the library left behind so the
// fallback message isn't competing with a half-rendered slide.
container.innerHTML = '';
}
var fallback = document.getElementById('lc-fallback');
if (fallback) {
fallback.hidden = false;
if (reason) { fallback.title = String(reason).slice(0, 200); }
}
}
function markSuccess() { settled = true; }
/* pptx-preview bundled deps (echarts, etc.) raise rejections deep
* inside their async pipelines that the outer Promise from
* previewer.preview does not always surface. Listen at the window
* level so we do not go silent on any of them. */
window.addEventListener('unhandledrejection', function (e) {
if (settled) { return; }
showFallback((e.reason && e.reason.message) || 'unhandled-rejection');
});
window.addEventListener('error', function (e) {
if (settled) { return; }
showFallback((e.error && e.error.message) || e.message || 'script-error');
});
if (typeof pptxPreview === 'undefined' || typeof pptxPreview.init !== 'function') {
showFallback('renderer-not-loaded');
return;
@ -1027,9 +1061,19 @@ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); fon
if (loading) { loading.remove(); }
var previewer = pptxPreview.init(container, { width: 960, height: 540 });
var result = previewer.preview(bytes.buffer);
if (result && typeof result.catch === 'function') {
result.catch(function (err) { showFallback(err && err.message); });
if (result && typeof result.then === 'function') {
result.then(markSuccess).catch(function (err) { showFallback(err && err.message); });
}
/* Final safety net: if 8 seconds in the renderer hasn't appended
* anything visible to the container, assume it failed silently. */
setTimeout(function () {
if (settled) { return; }
if (container && container.children.length > 0) {
markSuccess();
return;
}
showFallback('renderer-timeout');
}, 8000);
} catch (err) {
showFallback(err && err.message);
}