mirror of
https://github.com/pdfme/pdfme.git
synced 2026-06-16 18:29:17 -04:00
fix(playground): harden jsx worker evaluator
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user