fix(playground): harden jsx worker evaluator

This commit is contained in:
hand-dot
2026-05-07 17:58:04 +09:00
parent be02669b0c
commit 66945cb000
4 changed files with 115 additions and 38 deletions

View File

@@ -21,6 +21,20 @@ describe('JSX playground runtime', () => {
expect(() => compileJsxFunctionBody('return window.localStorage;')).toThrow(
'does not allow window',
);
expect(() =>
compileJsxFunctionBody('return importScripts("https://example.com/a.js");'),
).toThrow('does not allow importScripts');
expect(() =>
compileJsxFunctionBody('return (<Page><Text height={10}>{fetch("/")}</Text></Page>);'),
).toThrow('does not allow fetch');
});
it('allows restricted words in rendered text content', () => {
expect(() =>
compileJsxFunctionBody(
'return (<Page><Text height={10}>window document importScripts</Text></Page>);',
),
).not.toThrow();
});
it('renders JSX into a normal pdfme template result', async () => {

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Template } from '@pdfme/common';
import type { RenderResult } from '@pdfme/jsx';
import { Viewer } from '@pdfme/ui';
@@ -16,43 +16,22 @@ const RENDER_TIMEOUT_MS = 15_000;
type WorkerResponse =
| {
id: number;
ok: true;
result: RenderResult;
}
| {
error: string;
id: number;
ok: false;
};
const renderJsxSourceInWorker = (source: string) =>
new Promise<RenderResult>((resolve, reject) => {
const worker = new JsxPlaygroundWorker();
const timeoutId = window.setTimeout(() => {
worker.terminate();
reject(new Error('JSX render timed out.'));
}, RENDER_TIMEOUT_MS);
const cleanup = () => {
window.clearTimeout(timeoutId);
worker.terminate();
};
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
cleanup();
if (event.data.ok) {
resolve(event.data.result);
} else {
reject(new Error(event.data.error));
}
};
worker.onerror = (event) => {
cleanup();
reject(new Error(event.message || 'JSX render worker failed.'));
};
worker.postMessage({ font: getFontsData(), source });
});
type PendingRender = {
id: number;
reject: (error: Error) => void;
resolve: (result: RenderResult) => void;
timeoutId: number;
};
const configureJsxEditor: Parameters<typeof CodeEditor>[0]['beforeMount'] = (monaco) => {
const typeScriptLanguage = monaco.languages.typescript;
@@ -96,6 +75,9 @@ declare function PageBreak(props?: Record<string, unknown>): unknown;
export default function JsxPlayground() {
const viewerRootRef = useRef<HTMLDivElement | null>(null);
const viewerRef = useRef<Viewer | null>(null);
const renderWorkerRef = useRef<Worker | null>(null);
const pendingRenderRef = useRef<PendingRender | null>(null);
const nextRenderRequestIdRef = useRef(0);
const [source, setSource] = useState(initialJsx);
const [template, setTemplate] = useState<Template | null>(null);
const [inputs, setInputs] = useState<Record<string, string>[]>([{}]);
@@ -104,6 +86,69 @@ export default function JsxPlayground() {
const [pdfDuration, setPdfDuration] = useState<number | null>(null);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const terminateRenderWorker = useCallback(() => {
renderWorkerRef.current?.terminate();
renderWorkerRef.current = null;
}, []);
const clearPendingRender = useCallback((error?: Error) => {
const pendingRender = pendingRenderRef.current;
if (!pendingRender) return;
window.clearTimeout(pendingRender.timeoutId);
pendingRenderRef.current = null;
if (error) pendingRender.reject(error);
}, []);
const getRenderWorker = useCallback(() => {
if (renderWorkerRef.current) return renderWorkerRef.current;
const worker = new JsxPlaygroundWorker();
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const pendingRender = pendingRenderRef.current;
if (!pendingRender || event.data.id !== pendingRender.id) return;
clearPendingRender();
if (event.data.ok) {
pendingRender.resolve(event.data.result);
} else {
pendingRender.reject(new Error(event.data.error));
}
};
worker.onerror = (event) => {
const pendingRender = pendingRenderRef.current;
clearPendingRender();
terminateRenderWorker();
pendingRender?.reject(new Error(event.message || 'JSX render worker failed.'));
};
renderWorkerRef.current = worker;
return worker;
}, [clearPendingRender, terminateRenderWorker]);
const renderJsxSourceInWorker = useCallback(
(nextSource: string) =>
new Promise<RenderResult>((resolve, reject) => {
if (pendingRenderRef.current) {
clearPendingRender(new Error('JSX render cancelled.'));
terminateRenderWorker();
}
const worker = getRenderWorker();
const id = (nextRenderRequestIdRef.current += 1);
const timeoutId = window.setTimeout(() => {
const pendingRender = pendingRenderRef.current;
if (!pendingRender || pendingRender.id !== id) return;
clearPendingRender(new Error('JSX render timed out.'));
terminateRenderWorker();
}, RENDER_TIMEOUT_MS);
pendingRenderRef.current = { id, reject, resolve, timeoutId };
worker.postMessage({ font: getFontsData(), id, source: nextSource });
}),
[clearPendingRender, getRenderWorker, terminateRenderWorker],
);
useEffect(() => {
let cancelled = false;
const timer = window.setTimeout(async () => {
@@ -126,7 +171,7 @@ export default function JsxPlayground() {
cancelled = true;
window.clearTimeout(timer);
};
}, [source]);
}, [renderJsxSourceInWorker, source]);
useEffect(() => {
if (!viewerRootRef.current || !template) return;
@@ -155,10 +200,12 @@ export default function JsxPlayground() {
useEffect(() => {
return () => {
clearPendingRender(new Error('JSX render cancelled.'));
terminateRenderWorker();
viewerRef.current?.destroy();
viewerRef.current = null;
};
}, []);
}, [clearPendingRender, terminateRenderWorker]);
const onGeneratePdf = async () => {
if (isGeneratingPdf) return;

View File

@@ -33,6 +33,7 @@ const RESTRICTED_GLOBALS = [
'fetch',
'Function',
'globalThis',
'importScripts',
'indexedDB',
'localStorage',
'location',
@@ -50,6 +51,8 @@ const RESTRICTED_GLOBALS = [
const IMPORT_EXPORT_PATTERN = /^\s*(import|export)\b/m;
const RESTRICTED_GLOBAL_PATTERN = new RegExp(`\\b(${RESTRICTED_GLOBALS.join('|')})\\b`);
const JS_COMMENT_OR_STRING_PATTERN =
/\/\*[\s\S]*?\*\/|\/\/[^\r\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g;
const jsxScope = {
Absolute,
@@ -85,14 +88,20 @@ const createElement = (
return jsx(type, nextProps);
};
const assertAllowedJsxSource = (source: string) => {
const stripCommentsAndQuotedStrings = (source: string) =>
source.replace(JS_COMMENT_OR_STRING_PATTERN, '');
const assertNoImportExport = (source: string) => {
if (IMPORT_EXPORT_PATTERN.test(source)) {
throw new Error(
'The JSX playground beta does not support import/export. Use a function body that returns <Page> nodes.',
);
}
};
const restrictedGlobal = source.match(RESTRICTED_GLOBAL_PATTERN)?.[1];
const assertNoRestrictedGlobals = (source: string) => {
const restrictedGlobal =
stripCommentsAndQuotedStrings(source).match(RESTRICTED_GLOBAL_PATTERN)?.[1];
if (restrictedGlobal) {
throw new Error(
`The JSX playground beta does not allow ${restrictedGlobal}. Only pdfme JSX components are available.`,
@@ -101,16 +110,18 @@ const assertAllowedJsxSource = (source: string) => {
};
export const compileJsxFunctionBody = (source: string) => {
assertAllowedJsxSource(source);
assertNoImportExport(source);
try {
return transform(source, {
const compiled = transform(source, {
filePath: 'playground.tsx',
jsxFragmentPragma: 'Fragment',
jsxPragma: 'createElement',
production: true,
transforms: ['typescript', 'jsx'],
}).code;
assertNoRestrictedGlobals(compiled);
return compiled;
} catch (error) {
throw new Error(error instanceof Error ? error.message : String(error));
}

View File

@@ -4,16 +4,19 @@ import { renderJsxSource } from './jsxPlaygroundRuntime';
type RenderRequest = {
font: Font;
id: number;
source: string;
};
type RenderResponse =
| {
id: number;
ok: true;
result: RenderResult;
}
| {
error: string;
id: number;
ok: false;
};
@@ -23,12 +26,14 @@ const workerScope = self as unknown as {
};
workerScope.onmessage = async (event: MessageEvent<RenderRequest>) => {
const { font, id, source } = event.data;
try {
const result = await renderJsxSource(event.data.source, event.data.font);
workerScope.postMessage({ ok: true, result } satisfies RenderResponse);
const result = await renderJsxSource(source, font);
workerScope.postMessage({ id, ok: true, result } satisfies RenderResponse);
} catch (error) {
workerScope.postMessage({
error: error instanceof Error ? error.message : String(error),
id,
ok: false,
} satisfies RenderResponse);
}