polotno-node 3.1.3 → 3.3.0

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/lib/instance.js CHANGED
@@ -1,17 +1,18 @@
1
- import path from "path";
2
- import fs from "fs";
3
- import os from "os";
4
- import crypto from "crypto";
5
- import { execSync } from "child_process";
6
- import { pathToFileURL, fileURLToPath } from "url";
7
- import { Readable } from "stream";
8
- import { pipeline } from "stream/promises";
9
- import { assertValidDesign } from "@polotno/schema";
10
- import * as fontCache from "./font-cache.js";
11
- import { polotnoError, rehydrateError } from "./errors.js";
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import crypto from 'crypto';
5
+ import { execFileSync } from 'child_process';
6
+ import { pathToFileURL, fileURLToPath } from 'url';
7
+ import { Readable } from 'stream';
8
+ import { pipeline } from 'stream/promises';
9
+ import { assertValidDesign } from '@polotno/schema';
10
+ import * as fontCache from './font-cache.js';
11
+ import { polotnoError, rehydrateError } from './errors.js';
12
+ import { assertUrlAllowed } from './ssrf-guard.js';
12
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
14
  // Compiled to lib/instance.js; the client bundle is published to ../dist.
14
- const DEFAULT_CLIENT = `file:${path.join(__dirname, "..", "dist", "index.html")}`;
15
+ const DEFAULT_CLIENT = `file:${path.join(__dirname, '..', 'dist', 'index.html')}`;
15
16
  // Walk Polotno nodes recursively (depth-first)
16
17
  // cb(node) may return true to stop traversing the current branch early
17
18
  const forEveryChild = (node, cb) => {
@@ -32,61 +33,82 @@ const collectMediaSourcePointers = (json) => {
32
33
  // videos in elements tree
33
34
  for (const page of json?.pages || []) {
34
35
  for (const el of page?.children || []) {
35
- if (el?.type === "video" && typeof el.src === "string") {
36
- pointers.push({ kind: "video", target: el, key: "src" });
36
+ if (el?.type === 'video' && typeof el.src === 'string') {
37
+ pointers.push({ kind: 'video', target: el, key: 'src' });
37
38
  }
38
39
  forEveryChild(el, (child) => {
39
- if (child?.type === "video" && typeof child.src === "string") {
40
- pointers.push({ kind: "video", target: child, key: "src" });
40
+ if (child?.type === 'video' && typeof child.src === 'string') {
41
+ pointers.push({ kind: 'video', target: child, key: 'src' });
41
42
  }
42
43
  });
43
44
  }
44
45
  }
45
46
  // audios array
46
47
  for (const audio of json?.audios || []) {
47
- if (audio && typeof audio.src === "string") {
48
- pointers.push({ kind: "audio", target: audio, key: "src" });
48
+ if (audio && typeof audio.src === 'string') {
49
+ pointers.push({ kind: 'audio', target: audio, key: 'src' });
49
50
  }
50
51
  }
51
52
  return pointers;
52
53
  };
53
54
  const isDataOrFileSrc = (src) => {
54
- if (typeof src !== "string")
55
+ if (typeof src !== 'string')
55
56
  return true;
56
- return (src.startsWith("data:") ||
57
- src.startsWith("file:") ||
58
- src.startsWith("blob:"));
57
+ return (src.startsWith('data:') ||
58
+ src.startsWith('file:') ||
59
+ src.startsWith('blob:'));
59
60
  };
60
- const guessExtension = (urlString, contentType) => {
61
- try {
62
- const u = new URL(urlString);
63
- const ext = path.extname(u.pathname || "");
64
- if (ext)
65
- return ext;
66
- }
67
- catch (e) {
68
- // ignore; fallback to content-type
69
- }
70
- const ct = String(contentType || "")
71
- .split(";")[0]
61
+ const CONTENT_TYPE_EXTENSION = {
62
+ 'video/mp4': '.mp4',
63
+ 'video/webm': '.webm',
64
+ 'video/quicktime': '.mov',
65
+ 'audio/mpeg': '.mp3',
66
+ 'audio/mp4': '.m4a',
67
+ 'audio/wav': '.wav',
68
+ 'audio/x-wav': '.wav',
69
+ 'audio/ogg': '.ogg',
70
+ 'audio/webm': '.webm',
71
+ };
72
+ // The temp file's extension is later passed to ffmpeg/ffprobe and used by
73
+ // Chromium for the `file://` MIME. Take it ONLY from the response content-type,
74
+ // never the URL — a URL-derived extension like `x.$(touch pwned)` is how the
75
+ // original RCE injected a shell command (CWE-78). The name is otherwise a UUID.
76
+ export const guessExtension = (contentType, kind) => {
77
+ const ct = String(contentType || '')
78
+ .split(';')[0]
72
79
  .trim()
73
80
  .toLowerCase();
74
- const map = {
75
- "video/mp4": ".mp4",
76
- "video/webm": ".webm",
77
- "video/quicktime": ".mov",
78
- "audio/mpeg": ".mp3",
79
- "audio/mp4": ".m4a",
80
- "audio/wav": ".wav",
81
- "audio/x-wav": ".wav",
82
- "audio/ogg": ".ogg",
83
- "audio/webm": ".webm",
84
- };
85
- return map[ct] || ".bin";
81
+ return CONTENT_TYPE_EXTENSION[ct] || (kind === 'audio' ? '.m4a' : '.mp4');
86
82
  };
87
- const downloadUrlToFile = async (urlString, destinationPath) => {
88
- if (typeof fetch !== "function") {
89
- throw polotnoError("FETCH_FAILED", "Global fetch API is not available in this Node.js runtime. Please use Node 18+.", { url: urlString });
83
+ // Follow redirects by hand, re-running the SSRF guard on every hop. `fetch`
84
+ // follows redirects transparently, so a guard that only checked the original
85
+ // URL would be trivially bypassed by a public URL that 302s to an internal
86
+ // address. `redirect: 'manual'` hands us each 3xx so we can validate its
87
+ // Location before chasing it.
88
+ const MAX_REDIRECTS = 5;
89
+ const guardedFetch = async (startUrl, policy) => {
90
+ let url = startUrl;
91
+ for (let hop = 0;; hop++) {
92
+ await assertUrlAllowed(url, policy);
93
+ const res = await fetch(url, { redirect: 'manual' });
94
+ if (res.status >= 300 && res.status < 400) {
95
+ const location = res.headers.get('location');
96
+ if (!location)
97
+ return res; // 3xx without a target — let caller handle it
98
+ if (hop >= MAX_REDIRECTS) {
99
+ throw polotnoError('FETCH_FAILED', `Too many redirects while downloading: ${startUrl}`, { url: startUrl });
100
+ }
101
+ // Resolve relative Location against the current URL, drop the body.
102
+ url = new URL(location, url).toString();
103
+ await res.body?.cancel?.().catch(() => { });
104
+ continue;
105
+ }
106
+ return res;
107
+ }
108
+ };
109
+ const downloadUrlToFile = async (urlString, destinationPath, policy = {}) => {
110
+ if (typeof fetch !== 'function') {
111
+ throw polotnoError('FETCH_FAILED', 'Global fetch API is not available in this Node.js runtime. Please use Node 18+.', { url: urlString });
90
112
  }
91
113
  const MAX_ATTEMPTS = 3;
92
114
  const BASE_DELAY_MS = 300;
@@ -95,14 +117,14 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
95
117
  try {
96
118
  // Ensure no partial file exists from previous attempts
97
119
  await fs.promises.rm(destinationPath, { force: true });
98
- const res = await fetch(urlString);
120
+ const res = await guardedFetch(urlString, policy);
99
121
  if (!res.ok) {
100
122
  // Best-effort read of error body (small) for debugging.
101
- let bodyPreview = "";
123
+ let bodyPreview = '';
102
124
  try {
103
125
  bodyPreview = await res.text();
104
126
  if (bodyPreview.length > 500) {
105
- bodyPreview = bodyPreview.slice(0, 500) + "...";
127
+ bodyPreview = bodyPreview.slice(0, 500) + '...';
106
128
  }
107
129
  }
108
130
  catch (e) {
@@ -113,19 +135,23 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
113
135
  bodyPreview ? `body: ${JSON.stringify(bodyPreview)}` : null,
114
136
  ]
115
137
  .filter(Boolean)
116
- .join(", ");
117
- throw polotnoError("FETCH_FAILED", `HTTP error while downloading: ${details}`, { url: urlString, status: res.status });
138
+ .join(', ');
139
+ throw polotnoError('FETCH_FAILED', `HTTP error while downloading: ${details}`, { url: urlString, status: res.status });
118
140
  }
119
141
  if (!res.body) {
120
- throw polotnoError("FETCH_FAILED", "Empty response body", {
142
+ throw polotnoError('FETCH_FAILED', 'Empty response body', {
121
143
  url: urlString,
122
144
  });
123
145
  }
124
146
  await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(destinationPath));
125
- return res.headers.get("content-type") || "";
147
+ return res.headers.get('content-type') || '';
126
148
  }
127
149
  catch (e) {
128
150
  lastError = e;
151
+ // A blocked-by-policy (SSRF) rejection is deterministic — surface it now
152
+ // instead of burning the retry budget re-resolving the same bad host.
153
+ if (e?.ssrfBlocked)
154
+ throw e;
129
155
  if (attempt < MAX_ATTEMPTS) {
130
156
  const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
131
157
  await new Promise((r) => setTimeout(r, delay));
@@ -134,24 +160,34 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
134
160
  }
135
161
  }
136
162
  const message = lastError && lastError.message ? lastError.message : String(lastError);
137
- throw polotnoError("FETCH_FAILED", `Failed to download media after ${MAX_ATTEMPTS} attempts: ${urlString}\nLast error: ${message}`, { url: urlString, attempts: MAX_ATTEMPTS }, lastError);
163
+ throw polotnoError('FETCH_FAILED', `Failed to download media after ${MAX_ATTEMPTS} attempts: ${urlString}\nLast error: ${message}`, { url: urlString, attempts: MAX_ATTEMPTS }, lastError);
138
164
  };
139
165
  // Returns a human-readable reason string if the video file needs transcoding
140
166
  // for Chromium headless compatibility, or null if it is already render-safe.
141
167
  const getVideoNormalizationReason = (filePath) => {
142
168
  try {
143
- const raw = execSync(`ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,codec_tag_string,pix_fmt -of json "${filePath}"`, { encoding: "utf8" });
169
+ const raw = execFileSync('ffprobe', [
170
+ '-v',
171
+ 'error',
172
+ '-select_streams',
173
+ 'v:0',
174
+ '-show_entries',
175
+ 'stream=codec_name,codec_tag_string,pix_fmt',
176
+ '-of',
177
+ 'json',
178
+ filePath,
179
+ ], { encoding: 'utf8' });
144
180
  const info = JSON.parse(raw);
145
181
  const vs = info?.streams?.[0];
146
182
  if (!vs)
147
183
  return null;
148
- const codec = (vs.codec_name || "").toLowerCase();
149
- const tag = (vs.codec_tag_string || "").toLowerCase();
150
- if (["hevc", "h265"].includes(codec) || ["hvc1", "hev1"].includes(tag)) {
151
- return "HEVC/H.265 is not supported by Chromium headless";
184
+ const codec = (vs.codec_name || '').toLowerCase();
185
+ const tag = (vs.codec_tag_string || '').toLowerCase();
186
+ if (['hevc', 'h265'].includes(codec) || ['hvc1', 'hev1'].includes(tag)) {
187
+ return 'HEVC/H.265 is not supported by Chromium headless';
152
188
  }
153
- const pix = (vs.pix_fmt || "").toLowerCase();
154
- if (pix && pix !== "yuv420p") {
189
+ const pix = (vs.pix_fmt || '').toLowerCase();
190
+ if (pix && pix !== 'yuv420p') {
155
191
  return `pixel format "${pix}" is not the preferred yuv420p`;
156
192
  }
157
193
  return null;
@@ -162,7 +198,7 @@ const getVideoNormalizationReason = (filePath) => {
162
198
  };
163
199
  const isFfmpegAvailable = () => {
164
200
  try {
165
- execSync("ffmpeg -version", { stdio: "ignore" });
201
+ execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' });
166
202
  return true;
167
203
  }
168
204
  catch {
@@ -171,7 +207,17 @@ const isFfmpegAvailable = () => {
171
207
  };
172
208
  const getAudioCodec = (filePath) => {
173
209
  try {
174
- const result = execSync(`ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 "${filePath}"`, { encoding: "utf8" });
210
+ const result = execFileSync('ffprobe', [
211
+ '-v',
212
+ 'error',
213
+ '-select_streams',
214
+ 'a:0',
215
+ '-show_entries',
216
+ 'stream=codec_name',
217
+ '-of',
218
+ 'csv=p=0',
219
+ filePath,
220
+ ], { encoding: 'utf8' });
175
221
  return result.trim();
176
222
  }
177
223
  catch {
@@ -179,11 +225,9 @@ const getAudioCodec = (filePath) => {
179
225
  }
180
226
  };
181
227
  const convertAudioToAac = (inputPath, outputPath) => {
182
- execSync(`ffmpeg -y -i "${inputPath}" -c:v copy -c:a aac "${outputPath}"`, {
183
- stdio: "ignore",
184
- });
228
+ execFileSync('ffmpeg', ['-y', '-i', inputPath, '-c:v', 'copy', '-c:a', 'aac', outputPath], { stdio: 'ignore' });
185
229
  };
186
- const prepareLocalMediaForVideoExport = async (json) => {
230
+ export const prepareLocalMediaForVideoExport = async (json, policy = {}) => {
187
231
  const clonedJson = JSON.parse(JSON.stringify(json));
188
232
  const pointers = collectMediaSourcePointers(clonedJson);
189
233
  const rewriteMap = new Map();
@@ -195,7 +239,7 @@ const prepareLocalMediaForVideoExport = async (json) => {
195
239
  continue;
196
240
  try {
197
241
  const u = new URL(src);
198
- if (u.protocol !== "http:" && u.protocol !== "https:")
242
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
199
243
  continue;
200
244
  }
201
245
  catch (e) {
@@ -205,13 +249,13 @@ const prepareLocalMediaForVideoExport = async (json) => {
205
249
  rewriteMap.set(src, null);
206
250
  urlsToDownload.push(src);
207
251
  }
208
- if (p.kind === "video")
252
+ if (p.kind === 'video')
209
253
  videoUrls.add(src);
210
254
  }
211
255
  if (urlsToDownload.length === 0) {
212
256
  return { json: clonedJson, cleanup: async () => { } };
213
257
  }
214
- const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "polotno-node-assets-"));
258
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'polotno-node-assets-'));
215
259
  let didCleanup = false;
216
260
  const cleanup = async () => {
217
261
  if (didCleanup)
@@ -223,17 +267,31 @@ const prepareLocalMediaForVideoExport = async (json) => {
223
267
  await Promise.all(urlsToDownload.map(async (src) => {
224
268
  const tmpBase = crypto.randomUUID
225
269
  ? crypto.randomUUID()
226
- : crypto.randomBytes(16).toString("hex");
270
+ : crypto.randomBytes(16).toString('hex');
227
271
  const tmpPath = path.join(tempDir, tmpBase);
228
- const contentType = await downloadUrlToFile(src, tmpPath);
229
- const ext = guessExtension(src, contentType);
272
+ const contentType = await downloadUrlToFile(src, tmpPath, policy);
273
+ const kind = videoUrls.has(src) ? 'video' : 'audio';
274
+ const ext = guessExtension(contentType, kind);
230
275
  let finalPath = tmpPath + ext;
231
276
  await fs.promises.rename(tmpPath, finalPath);
232
277
  if (videoUrls.has(src)) {
233
278
  const reason = getVideoNormalizationReason(finalPath);
234
279
  if (reason) {
235
- const normalizedPath = finalPath + ".normalized.mp4";
236
- execSync(`ffmpeg -y -i "${finalPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -movflags +faststart "${normalizedPath}"`, { stdio: "ignore" });
280
+ const normalizedPath = finalPath + '.normalized.mp4';
281
+ execFileSync('ffmpeg', [
282
+ '-y',
283
+ '-i',
284
+ finalPath,
285
+ '-c:v',
286
+ 'libx264',
287
+ '-pix_fmt',
288
+ 'yuv420p',
289
+ '-c:a',
290
+ 'aac',
291
+ '-movflags',
292
+ '+faststart',
293
+ normalizedPath,
294
+ ], { stdio: 'ignore' });
237
295
  finalPath = normalizedPath;
238
296
  }
239
297
  }
@@ -253,16 +311,23 @@ const prepareLocalMediaForVideoExport = async (json) => {
253
311
  throw e;
254
312
  }
255
313
  };
256
- export const createPage = async (browser, url, requestInterceptor, useFontCache = true) => {
314
+ // `page.goto` below used to inherit the browser driver's 30s default. That is
315
+ // too tight for this navigation — it loads the whole polotno client bundle, and
316
+ // on a slow or loaded machine createInstance would throw `Navigation timeout of
317
+ // 30000 ms exceeded` with no option to tune: `protocolTimeout` governs CDP
318
+ // calls, not the navigation lifecycle. 2 minutes is generous but still bounded,
319
+ // unlike `timeout: 0` ("disabled"), which turns a wedged load into a hang.
320
+ const DEFAULT_NAVIGATION_TIMEOUT = 120 * 1000;
321
+ export const createPage = async (browser, url, requestInterceptor, useFontCache = true, navigationTimeout = DEFAULT_NAVIGATION_TIMEOUT) => {
257
322
  const page = await browser.newPage();
258
323
  // Attach request interceptor if provided
259
- if (useFontCache || typeof requestInterceptor === "function") {
324
+ if (useFontCache || typeof requestInterceptor === 'function') {
260
325
  await page.setRequestInterception(true);
261
- page.on("request", (request) => {
326
+ page.on('request', (request) => {
262
327
  if (useFontCache && fontCache.tryRespond(request)) {
263
328
  return;
264
329
  }
265
- if (typeof requestInterceptor === "function") {
330
+ if (typeof requestInterceptor === 'function') {
266
331
  requestInterceptor(request);
267
332
  }
268
333
  else {
@@ -270,7 +335,7 @@ export const createPage = async (browser, url, requestInterceptor, useFontCache
270
335
  }
271
336
  });
272
337
  if (useFontCache) {
273
- page.on("response", (response) => fontCache.capture(response));
338
+ page.on('response', (response) => fontCache.capture(response));
274
339
  }
275
340
  }
276
341
  // we need to make sure that headless is inside user agent
@@ -278,31 +343,31 @@ export const createPage = async (browser, url, requestInterceptor, useFontCache
278
343
  // but also lets NOT just overwrite default one, because Google Fonts may send "reduced" version of font
279
344
  // so some languages may not work
280
345
  const userAgent = await page.evaluate(() => navigator.userAgent);
281
- if (userAgent.indexOf("Headless") === -1) {
282
- await page.setUserAgent(userAgent + " Chrome Headless");
346
+ if (userAgent.indexOf('Headless') === -1) {
347
+ await page.setUserAgent(userAgent + ' Chrome Headless');
283
348
  }
284
- page.on("console", (msg) => {
349
+ page.on('console', (msg) => {
285
350
  msg.args().forEach(async (message) => {
286
351
  // skip style information
287
- if (message.toString().indexOf("margin") >= 0) {
352
+ if (message.toString().indexOf('margin') >= 0) {
288
353
  return;
289
354
  }
290
355
  const { subtype, description } = message.remoteObject();
291
- if (subtype === "error") {
356
+ if (subtype === 'error') {
292
357
  console.error(description);
293
358
  return;
294
359
  }
295
360
  const text = message
296
361
  .toString()
297
- .replace("JSHandle:", "")
298
- .replace("%c", "");
362
+ .replace('JSHandle:', '')
363
+ .replace('%c', '');
299
364
  console.log(text);
300
365
  });
301
366
  });
302
- page.on("pageerror", (msg) => {
367
+ page.on('pageerror', (msg) => {
303
368
  console.error(msg);
304
369
  });
305
- await page.goto(url);
370
+ await page.goto(url, { timeout: navigationTimeout });
306
371
  return page;
307
372
  };
308
373
  export const run = async (page, func, args) => {
@@ -355,18 +420,30 @@ export const jsonToBlob = async (page, json, attrs) => {
355
420
  // is an enable-only flag: the setter is called with `true` only when the option
356
421
  // is truthy (passing `false` is a no-op, leaving the client default in place).
357
422
  const CONFIG_OPTION_SETTERS = [
358
- { option: "assetLoadTimeout", method: "setAssetLoadTimeout", arg: "value" },
359
- { option: "fontLoadTimeout", method: "setFontLoadTimeout", arg: "value" },
360
- { option: "legacyRichTextEnabled", method: "setLegacyRichTextEnabled", arg: true },
361
- { option: "textVerticalResizeEnabled", method: "setTextVerticalResizeEnabled", arg: "value" },
362
- { option: "textSplitAllowed", method: "unstable_setTextSplitAllowed", arg: true },
363
- { option: "textOverflow", method: "setTextOverflow", arg: "value" },
423
+ { option: 'assetLoadTimeout', method: 'setAssetLoadTimeout', arg: 'value' },
424
+ { option: 'fontLoadTimeout', method: 'setFontLoadTimeout', arg: 'value' },
425
+ {
426
+ option: 'legacyRichTextEnabled',
427
+ method: 'setLegacyRichTextEnabled',
428
+ arg: true,
429
+ },
430
+ {
431
+ option: 'textVerticalResizeEnabled',
432
+ method: 'setTextVerticalResizeEnabled',
433
+ arg: 'value',
434
+ },
435
+ {
436
+ option: 'textSplitAllowed',
437
+ method: 'unstable_setTextSplitAllowed',
438
+ arg: true,
439
+ },
440
+ { option: 'textOverflow', method: 'setTextOverflow', arg: 'value' },
364
441
  ];
365
442
  // Apply the option→config setters above to a page in a single round-trip.
366
443
  const applyConfigOptions = async (page, attrs = {}) => {
367
444
  const calls = [];
368
445
  for (const { option, method, arg } of CONFIG_OPTION_SETTERS) {
369
- if (arg === "value") {
446
+ if (arg === 'value') {
370
447
  // value passthrough — apply on any explicit value, including `false`
371
448
  if (attrs[option] !== undefined)
372
449
  calls.push({ method, value: attrs[option] });
@@ -391,12 +468,13 @@ const applyConfigOptions = async (page, attrs = {}) => {
391
468
  };
392
469
  const busyPages = [];
393
470
  const createdInstances = [];
394
- export const createInstance = async ({ key, url, useParallelPages = true, browser, requestInterceptor, useFontCache = true, } = {}) => {
471
+ export const createInstance = async ({ key, url, useParallelPages = true, browser, requestInterceptor, useFontCache = true, navigationTimeout, } = {}) => {
395
472
  const visitPage = url || `${DEFAULT_CLIENT}?key=${key}`;
396
473
  const hasCustomClientUrl = Boolean(url);
397
- const firstPage = useParallelPages
398
- ? null
399
- : await createPage(browser, visitPage, requestInterceptor, useFontCache);
474
+ // Every page this instance opens is configured identically; bind it once so
475
+ // the three call sites below cannot drift apart.
476
+ const openClientPage = () => createPage(browser, visitPage, requestInterceptor, useFontCache, navigationTimeout);
477
+ const firstPage = useParallelPages ? null : await openClientPage();
400
478
  async function ensureAssetErrorHelpers(page) {
401
479
  await page.evaluate(() => {
402
480
  // Define helpers once per page. They unify asset-loading error handling
@@ -411,11 +489,11 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
411
489
  // The client stashes a structured `{code, message, details}`
412
490
  // payload (image timeouts arrive as IMAGE_FAILED/reason:'timeout',
413
491
  // so skip flags cover them too).
414
- const code = typeof payload === "object" ? payload.code : null;
415
- if (code === "FONT_FAILED" && attrs && attrs.skipFontError) {
492
+ const code = typeof payload === 'object' ? payload.code : null;
493
+ if (code === 'FONT_FAILED' && attrs && attrs.skipFontError) {
416
494
  return null;
417
495
  }
418
- if (code === "IMAGE_FAILED" && attrs && attrs.skipImageError) {
496
+ if (code === 'IMAGE_FAILED' && attrs && attrs.skipImageError) {
419
497
  return null;
420
498
  }
421
499
  // Return the payload as-is (object, or string from a legacy client);
@@ -431,7 +509,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
431
509
  // this throw is flattened by page.evaluate (thrown Errors cross
432
510
  // the boundary as message-only).
433
511
  window._polotnoError = payload;
434
- throw new Error(typeof payload === "string" ? payload : String(payload.message));
512
+ throw new Error(typeof payload === 'string' ? payload : String(payload.message));
435
513
  }
436
514
  };
437
515
  }
@@ -452,11 +530,9 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
452
530
  }
453
531
  }
454
532
  const run = async (func, ...args) => {
455
- const page = useParallelPages
456
- ? await createPage(browser, visitPage, requestInterceptor, useFontCache)
457
- : firstPage;
533
+ const page = useParallelPages ? await openClientPage() : firstPage;
458
534
  if (busyPages.indexOf(page) >= 0) {
459
- throw polotnoError("EXPORT_FAILED", "Current rendering context is busy with another task. Please use `useParallelPages: true` option to run multiple tasks in parallel or make sure previous task is finished before starting a new one.", { reason: "busy" });
535
+ throw polotnoError('EXPORT_FAILED', 'Current rendering context is busy with another task. Please use `useParallelPages: true` option to run multiple tasks in parallel or make sure previous task is finished before starting a new one.', { reason: 'busy' });
460
536
  }
461
537
  busyPages.push(page);
462
538
  // Setup profiling if profilePath is provided
@@ -464,15 +540,15 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
464
540
  const profilePath = args[1]?.profilePath;
465
541
  if (profilePath) {
466
542
  cdpSession = await page.target().createCDPSession();
467
- await cdpSession.send("Profiler.enable");
468
- await cdpSession.send("Profiler.start");
543
+ await cdpSession.send('Profiler.enable');
544
+ await cdpSession.send('Profiler.start');
469
545
  }
470
546
  try {
471
547
  // All direct `window.config.*` setters are applied from one table.
472
548
  await applyConfigOptions(page, args[1]);
473
549
  if (args[1]?.onProgress) {
474
550
  const originalProgress = args[1].onProgress;
475
- await page.exposeFunction("onProgress", async (progress, frameTime) => {
551
+ await page.exposeFunction('onProgress', async (progress, frameTime) => {
476
552
  originalProgress(progress, frameTime);
477
553
  });
478
554
  }
@@ -490,19 +566,23 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
490
566
  const e = error;
491
567
  window._polotnoError =
492
568
  e && e.code
493
- ? { code: e.code, message: String(e.message), details: e.details }
569
+ ? {
570
+ code: e.code,
571
+ message: String(e.message),
572
+ details: e.details,
573
+ }
494
574
  : String(error);
495
575
  });
496
576
  }
497
577
  else {
498
- console.error("onLoadError function is not defined in the client. Error handling will not work.");
578
+ console.error('onLoadError function is not defined in the client. Error handling will not work.');
499
579
  }
500
580
  });
501
581
  await ensureAssetErrorHelpers(page);
502
582
  const result = await page.evaluate(func, ...args);
503
583
  // Stop profiler and save profile if enabled
504
584
  if (cdpSession && profilePath) {
505
- const { profile } = await cdpSession.send("Profiler.stop");
585
+ const { profile } = await cdpSession.send('Profiler.stop');
506
586
  fs.writeFileSync(profilePath, JSON.stringify(profile));
507
587
  console.log(`CPU profile saved to: ${profilePath}`);
508
588
  }
@@ -520,14 +600,14 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
520
600
  catch (e) {
521
601
  // Stop profiler and save profile even on error
522
602
  if (cdpSession && profilePath) {
523
- console.log("saving profile");
603
+ console.log('saving profile');
524
604
  try {
525
- const { profile } = await cdpSession.send("Profiler.stop");
605
+ const { profile } = await cdpSession.send('Profiler.stop');
526
606
  fs.writeFileSync(profilePath, JSON.stringify(profile));
527
607
  console.log(`CPU profile saved to: ${profilePath}`);
528
608
  }
529
609
  catch (profileError) {
530
- console.error("Failed to save profile:", profileError.message);
610
+ console.error('Failed to save profile:', profileError.message);
531
611
  }
532
612
  }
533
613
  // If `page.evaluate()` failed (e.g. thrown during onProgress), still prefer
@@ -550,7 +630,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
550
630
  // than rejecting input the store would happily render), so this only throws on
551
631
  // genuinely malformed JSON.
552
632
  const assertValidJson = (json) => {
553
- assertValidDesign(json, { mode: "partial" });
633
+ assertValidDesign(json, { mode: 'partial' });
554
634
  };
555
635
  const jsonToDataURL = async (json, attrs) => {
556
636
  await assertValidJson(json);
@@ -562,11 +642,11 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
562
642
  };
563
643
  const jsonToGIFBase64 = async (json, attrs) => {
564
644
  const url = await jsonToGIFDataURL(json, attrs);
565
- return url.split("base64,")[1];
645
+ return url.split('base64,')[1];
566
646
  };
567
647
  const jsonToImageBase64 = async (json, attrs) => {
568
648
  const url = await jsonToDataURL(json, attrs);
569
- return url.split("base64,")[1];
649
+ return url.split('base64,')[1];
570
650
  };
571
651
  const jsonToPDFDataURL = async (json, attrs) => {
572
652
  await assertValidJson(json);
@@ -574,18 +654,21 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
574
654
  };
575
655
  const jsonToPDFBase64 = async (json, attrs) => {
576
656
  const url = await jsonToPDFDataURL(json, attrs);
577
- return url.split("base64,")[1];
657
+ return url.split('base64,')[1];
578
658
  };
579
659
  const jsonToBlob = async (json, attrs) => {
580
660
  const base64 = await jsonToImageBase64(json, attrs);
581
- const blob = Buffer.from(base64, "base64");
661
+ const blob = Buffer.from(base64, 'base64');
582
662
  return blob;
583
663
  };
584
664
  const jsonToVideoFile = async (json, outputPath, attrs = {}) => {
585
665
  await assertValidJson(json);
586
666
  const shouldDownloadMedia = !hasCustomClientUrl && !(attrs && attrs.skipDownloads);
587
667
  const prepared = shouldDownloadMedia
588
- ? await prepareLocalMediaForVideoExport(json)
668
+ ? await prepareLocalMediaForVideoExport(json, {
669
+ blockPrivateNetwork: attrs?.blockPrivateNetwork,
670
+ fetchGuard: attrs?.fetchGuard,
671
+ })
589
672
  : { json, cleanup: async () => { } };
590
673
  const writeStream = fs.createWriteStream(outputPath);
591
674
  try {
@@ -609,9 +692,9 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
609
692
  }
610
693
  // For video export, we always use 'resize' text overflow mode
611
694
  // to ensure text fits properly in animations. User preferences are ignored for now.
612
- window.config.setTextOverflow("resize");
695
+ window.config.setTextOverflow('resize');
613
696
  if (!window.__polotnoLoadVideoExport) {
614
- throw new Error("Video export module loader is not defined in the client. Expected window.__polotnoLoadVideoExport().");
697
+ throw new Error('Video export module loader is not defined in the client. Expected window.__polotnoLoadVideoExport().');
615
698
  }
616
699
  const { storeToVideo } = await window.__polotnoLoadVideoExport();
617
700
  // Create abort controller to cancel video render on error
@@ -667,7 +750,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
667
750
  for (let offset = 0; offset < uint8Array.length; offset += CHUNK_SIZE) {
668
751
  const end = Math.min(offset + CHUNK_SIZE, uint8Array.length);
669
752
  const chunk = uint8Array.slice(offset, end);
670
- let binary = "";
753
+ let binary = '';
671
754
  for (let i = 0; i < chunk.length; i++) {
672
755
  binary += String.fromCharCode(chunk[i]);
673
756
  }
@@ -678,18 +761,18 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
678
761
  ...attrs,
679
762
  exposeFunctions: {
680
763
  __polotnoWriteChunk: (base64Chunk) => {
681
- const buffer = Buffer.from(base64Chunk, "base64");
764
+ const buffer = Buffer.from(base64Chunk, 'base64');
682
765
  writeStream.write(buffer);
683
766
  },
684
767
  },
685
768
  });
686
769
  writeStream.end();
687
- await new Promise((resolve) => writeStream.on("finish", resolve));
770
+ await new Promise((resolve) => writeStream.on('finish', resolve));
688
771
  // Convert audio to AAC if needed
689
772
  if (isFfmpegAvailable()) {
690
773
  const codec = getAudioCodec(outputPath);
691
- if (codec && codec !== "aac") {
692
- const tempPath = outputPath + ".tmp.mp4";
774
+ if (codec && codec !== 'aac') {
775
+ const tempPath = outputPath + '.tmp.mp4';
693
776
  convertAudioToAac(outputPath, tempPath);
694
777
  await fs.promises.rename(tempPath, outputPath);
695
778
  }
@@ -705,7 +788,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
705
788
  try {
706
789
  const mimeType = await jsonToVideoFile(json, tempPath, attrs);
707
790
  const buffer = await fs.promises.readFile(tempPath);
708
- return `data:${mimeType || "video/mp4"};base64,${buffer.toString("base64")}`;
791
+ return `data:${mimeType || 'video/mp4'};base64,${buffer.toString('base64')}`;
709
792
  }
710
793
  finally {
711
794
  await fs.promises.rm(tempPath, { force: true });
@@ -716,7 +799,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
716
799
  try {
717
800
  await jsonToVideoFile(json, tempPath, attrs);
718
801
  const buffer = await fs.promises.readFile(tempPath);
719
- return buffer.toString("base64");
802
+ return buffer.toString('base64');
720
803
  }
721
804
  finally {
722
805
  await fs.promises.rm(tempPath, { force: true });
@@ -740,7 +823,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
740
823
  jsonToVideoDataURL,
741
824
  jsonToVideoBase64,
742
825
  jsonToVideoFile,
743
- createPage: async () => await createPage(browser, visitPage, requestInterceptor, useFontCache),
826
+ createPage: openClientPage,
744
827
  };
745
828
  createdInstances.push(instance);
746
829
  return instance;
@@ -750,7 +833,7 @@ const closeAllInstances = async () => {
750
833
  await Promise.all(createdInstances.map((instance) => instance.close()));
751
834
  };
752
835
  // Also handle other termination signals
753
- ["SIGINT", "SIGTERM", "SIGQUIT", "exit"].forEach((signal) => {
836
+ ['SIGINT', 'SIGTERM', 'SIGQUIT', 'exit'].forEach((signal) => {
754
837
  process.on(signal, () => {
755
838
  closeAllInstances();
756
839
  });