polotno-node 3.1.2 → 3.2.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,17 @@
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 { 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';
12
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
13
  // Compiled to lib/instance.js; the client bundle is published to ../dist.
14
- const DEFAULT_CLIENT = `file:${path.join(__dirname, "..", "dist", "index.html")}`;
14
+ const DEFAULT_CLIENT = `file:${path.join(__dirname, '..', 'dist', 'index.html')}`;
15
15
  // Walk Polotno nodes recursively (depth-first)
16
16
  // cb(node) may return true to stop traversing the current branch early
17
17
  const forEveryChild = (node, cb) => {
@@ -32,61 +32,61 @@ const collectMediaSourcePointers = (json) => {
32
32
  // videos in elements tree
33
33
  for (const page of json?.pages || []) {
34
34
  for (const el of page?.children || []) {
35
- if (el?.type === "video" && typeof el.src === "string") {
36
- pointers.push({ kind: "video", target: el, key: "src" });
35
+ if (el?.type === 'video' && typeof el.src === 'string') {
36
+ pointers.push({ kind: 'video', target: el, key: 'src' });
37
37
  }
38
38
  forEveryChild(el, (child) => {
39
- if (child?.type === "video" && typeof child.src === "string") {
40
- pointers.push({ kind: "video", target: child, key: "src" });
39
+ if (child?.type === 'video' && typeof child.src === 'string') {
40
+ pointers.push({ kind: 'video', target: child, key: 'src' });
41
41
  }
42
42
  });
43
43
  }
44
44
  }
45
45
  // audios array
46
46
  for (const audio of json?.audios || []) {
47
- if (audio && typeof audio.src === "string") {
48
- pointers.push({ kind: "audio", target: audio, key: "src" });
47
+ if (audio && typeof audio.src === 'string') {
48
+ pointers.push({ kind: 'audio', target: audio, key: 'src' });
49
49
  }
50
50
  }
51
51
  return pointers;
52
52
  };
53
53
  const isDataOrFileSrc = (src) => {
54
- if (typeof src !== "string")
54
+ if (typeof src !== 'string')
55
55
  return true;
56
- return (src.startsWith("data:") ||
57
- src.startsWith("file:") ||
58
- src.startsWith("blob:"));
56
+ return (src.startsWith('data:') ||
57
+ src.startsWith('file:') ||
58
+ src.startsWith('blob:'));
59
59
  };
60
60
  const guessExtension = (urlString, contentType) => {
61
61
  try {
62
62
  const u = new URL(urlString);
63
- const ext = path.extname(u.pathname || "");
63
+ const ext = path.extname(u.pathname || '');
64
64
  if (ext)
65
65
  return ext;
66
66
  }
67
67
  catch (e) {
68
68
  // ignore; fallback to content-type
69
69
  }
70
- const ct = String(contentType || "")
71
- .split(";")[0]
70
+ const ct = String(contentType || '')
71
+ .split(';')[0]
72
72
  .trim()
73
73
  .toLowerCase();
74
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",
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
84
  };
85
- return map[ct] || ".bin";
85
+ return map[ct] || '.bin';
86
86
  };
87
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 });
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 });
90
90
  }
91
91
  const MAX_ATTEMPTS = 3;
92
92
  const BASE_DELAY_MS = 300;
@@ -98,11 +98,11 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
98
98
  const res = await fetch(urlString);
99
99
  if (!res.ok) {
100
100
  // Best-effort read of error body (small) for debugging.
101
- let bodyPreview = "";
101
+ let bodyPreview = '';
102
102
  try {
103
103
  bodyPreview = await res.text();
104
104
  if (bodyPreview.length > 500) {
105
- bodyPreview = bodyPreview.slice(0, 500) + "...";
105
+ bodyPreview = bodyPreview.slice(0, 500) + '...';
106
106
  }
107
107
  }
108
108
  catch (e) {
@@ -113,16 +113,16 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
113
113
  bodyPreview ? `body: ${JSON.stringify(bodyPreview)}` : null,
114
114
  ]
115
115
  .filter(Boolean)
116
- .join(", ");
117
- throw polotnoError("FETCH_FAILED", `HTTP error while downloading: ${details}`, { url: urlString, status: res.status });
116
+ .join(', ');
117
+ throw polotnoError('FETCH_FAILED', `HTTP error while downloading: ${details}`, { url: urlString, status: res.status });
118
118
  }
119
119
  if (!res.body) {
120
- throw polotnoError("FETCH_FAILED", "Empty response body", {
120
+ throw polotnoError('FETCH_FAILED', 'Empty response body', {
121
121
  url: urlString,
122
122
  });
123
123
  }
124
124
  await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(destinationPath));
125
- return res.headers.get("content-type") || "";
125
+ return res.headers.get('content-type') || '';
126
126
  }
127
127
  catch (e) {
128
128
  lastError = e;
@@ -134,24 +134,24 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
134
134
  }
135
135
  }
136
136
  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);
137
+ throw polotnoError('FETCH_FAILED', `Failed to download media after ${MAX_ATTEMPTS} attempts: ${urlString}\nLast error: ${message}`, { url: urlString, attempts: MAX_ATTEMPTS }, lastError);
138
138
  };
139
139
  // Returns a human-readable reason string if the video file needs transcoding
140
140
  // for Chromium headless compatibility, or null if it is already render-safe.
141
141
  const getVideoNormalizationReason = (filePath) => {
142
142
  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" });
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' });
144
144
  const info = JSON.parse(raw);
145
145
  const vs = info?.streams?.[0];
146
146
  if (!vs)
147
147
  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";
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';
152
152
  }
153
- const pix = (vs.pix_fmt || "").toLowerCase();
154
- if (pix && pix !== "yuv420p") {
153
+ const pix = (vs.pix_fmt || '').toLowerCase();
154
+ if (pix && pix !== 'yuv420p') {
155
155
  return `pixel format "${pix}" is not the preferred yuv420p`;
156
156
  }
157
157
  return null;
@@ -162,7 +162,7 @@ const getVideoNormalizationReason = (filePath) => {
162
162
  };
163
163
  const isFfmpegAvailable = () => {
164
164
  try {
165
- execSync("ffmpeg -version", { stdio: "ignore" });
165
+ execSync('ffmpeg -version', { stdio: 'ignore' });
166
166
  return true;
167
167
  }
168
168
  catch {
@@ -171,7 +171,7 @@ const isFfmpegAvailable = () => {
171
171
  };
172
172
  const getAudioCodec = (filePath) => {
173
173
  try {
174
- const result = execSync(`ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 "${filePath}"`, { encoding: "utf8" });
174
+ const result = execSync(`ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 "${filePath}"`, { encoding: 'utf8' });
175
175
  return result.trim();
176
176
  }
177
177
  catch {
@@ -180,7 +180,7 @@ const getAudioCodec = (filePath) => {
180
180
  };
181
181
  const convertAudioToAac = (inputPath, outputPath) => {
182
182
  execSync(`ffmpeg -y -i "${inputPath}" -c:v copy -c:a aac "${outputPath}"`, {
183
- stdio: "ignore",
183
+ stdio: 'ignore',
184
184
  });
185
185
  };
186
186
  const prepareLocalMediaForVideoExport = async (json) => {
@@ -195,7 +195,7 @@ const prepareLocalMediaForVideoExport = async (json) => {
195
195
  continue;
196
196
  try {
197
197
  const u = new URL(src);
198
- if (u.protocol !== "http:" && u.protocol !== "https:")
198
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
199
199
  continue;
200
200
  }
201
201
  catch (e) {
@@ -205,13 +205,13 @@ const prepareLocalMediaForVideoExport = async (json) => {
205
205
  rewriteMap.set(src, null);
206
206
  urlsToDownload.push(src);
207
207
  }
208
- if (p.kind === "video")
208
+ if (p.kind === 'video')
209
209
  videoUrls.add(src);
210
210
  }
211
211
  if (urlsToDownload.length === 0) {
212
212
  return { json: clonedJson, cleanup: async () => { } };
213
213
  }
214
- const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "polotno-node-assets-"));
214
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'polotno-node-assets-'));
215
215
  let didCleanup = false;
216
216
  const cleanup = async () => {
217
217
  if (didCleanup)
@@ -223,7 +223,7 @@ const prepareLocalMediaForVideoExport = async (json) => {
223
223
  await Promise.all(urlsToDownload.map(async (src) => {
224
224
  const tmpBase = crypto.randomUUID
225
225
  ? crypto.randomUUID()
226
- : crypto.randomBytes(16).toString("hex");
226
+ : crypto.randomBytes(16).toString('hex');
227
227
  const tmpPath = path.join(tempDir, tmpBase);
228
228
  const contentType = await downloadUrlToFile(src, tmpPath);
229
229
  const ext = guessExtension(src, contentType);
@@ -232,8 +232,8 @@ const prepareLocalMediaForVideoExport = async (json) => {
232
232
  if (videoUrls.has(src)) {
233
233
  const reason = getVideoNormalizationReason(finalPath);
234
234
  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" });
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' });
237
237
  finalPath = normalizedPath;
238
238
  }
239
239
  }
@@ -253,16 +253,23 @@ const prepareLocalMediaForVideoExport = async (json) => {
253
253
  throw e;
254
254
  }
255
255
  };
256
- export const createPage = async (browser, url, requestInterceptor, useFontCache = true) => {
256
+ // `page.goto` below used to inherit the browser driver's 30s default. That is
257
+ // too tight for this navigation — it loads the whole polotno client bundle, and
258
+ // on a slow or loaded machine createInstance would throw `Navigation timeout of
259
+ // 30000 ms exceeded` with no option to tune: `protocolTimeout` governs CDP
260
+ // calls, not the navigation lifecycle. 2 minutes is generous but still bounded,
261
+ // unlike `timeout: 0` ("disabled"), which turns a wedged load into a hang.
262
+ const DEFAULT_NAVIGATION_TIMEOUT = 120 * 1000;
263
+ export const createPage = async (browser, url, requestInterceptor, useFontCache = true, navigationTimeout = DEFAULT_NAVIGATION_TIMEOUT) => {
257
264
  const page = await browser.newPage();
258
265
  // Attach request interceptor if provided
259
- if (useFontCache || typeof requestInterceptor === "function") {
266
+ if (useFontCache || typeof requestInterceptor === 'function') {
260
267
  await page.setRequestInterception(true);
261
- page.on("request", (request) => {
268
+ page.on('request', (request) => {
262
269
  if (useFontCache && fontCache.tryRespond(request)) {
263
270
  return;
264
271
  }
265
- if (typeof requestInterceptor === "function") {
272
+ if (typeof requestInterceptor === 'function') {
266
273
  requestInterceptor(request);
267
274
  }
268
275
  else {
@@ -270,7 +277,7 @@ export const createPage = async (browser, url, requestInterceptor, useFontCache
270
277
  }
271
278
  });
272
279
  if (useFontCache) {
273
- page.on("response", (response) => fontCache.capture(response));
280
+ page.on('response', (response) => fontCache.capture(response));
274
281
  }
275
282
  }
276
283
  // we need to make sure that headless is inside user agent
@@ -278,31 +285,31 @@ export const createPage = async (browser, url, requestInterceptor, useFontCache
278
285
  // but also lets NOT just overwrite default one, because Google Fonts may send "reduced" version of font
279
286
  // so some languages may not work
280
287
  const userAgent = await page.evaluate(() => navigator.userAgent);
281
- if (userAgent.indexOf("Headless") === -1) {
282
- await page.setUserAgent(userAgent + " Chrome Headless");
288
+ if (userAgent.indexOf('Headless') === -1) {
289
+ await page.setUserAgent(userAgent + ' Chrome Headless');
283
290
  }
284
- page.on("console", (msg) => {
291
+ page.on('console', (msg) => {
285
292
  msg.args().forEach(async (message) => {
286
293
  // skip style information
287
- if (message.toString().indexOf("margin") >= 0) {
294
+ if (message.toString().indexOf('margin') >= 0) {
288
295
  return;
289
296
  }
290
297
  const { subtype, description } = message.remoteObject();
291
- if (subtype === "error") {
298
+ if (subtype === 'error') {
292
299
  console.error(description);
293
300
  return;
294
301
  }
295
302
  const text = message
296
303
  .toString()
297
- .replace("JSHandle:", "")
298
- .replace("%c", "");
304
+ .replace('JSHandle:', '')
305
+ .replace('%c', '');
299
306
  console.log(text);
300
307
  });
301
308
  });
302
- page.on("pageerror", (msg) => {
309
+ page.on('pageerror', (msg) => {
303
310
  console.error(msg);
304
311
  });
305
- await page.goto(url);
312
+ await page.goto(url, { timeout: navigationTimeout });
306
313
  return page;
307
314
  };
308
315
  export const run = async (page, func, args) => {
@@ -355,18 +362,30 @@ export const jsonToBlob = async (page, json, attrs) => {
355
362
  // is an enable-only flag: the setter is called with `true` only when the option
356
363
  // is truthy (passing `false` is a no-op, leaving the client default in place).
357
364
  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" },
365
+ { option: 'assetLoadTimeout', method: 'setAssetLoadTimeout', arg: 'value' },
366
+ { option: 'fontLoadTimeout', method: 'setFontLoadTimeout', arg: 'value' },
367
+ {
368
+ option: 'legacyRichTextEnabled',
369
+ method: 'setLegacyRichTextEnabled',
370
+ arg: true,
371
+ },
372
+ {
373
+ option: 'textVerticalResizeEnabled',
374
+ method: 'setTextVerticalResizeEnabled',
375
+ arg: 'value',
376
+ },
377
+ {
378
+ option: 'textSplitAllowed',
379
+ method: 'unstable_setTextSplitAllowed',
380
+ arg: true,
381
+ },
382
+ { option: 'textOverflow', method: 'setTextOverflow', arg: 'value' },
364
383
  ];
365
384
  // Apply the option→config setters above to a page in a single round-trip.
366
385
  const applyConfigOptions = async (page, attrs = {}) => {
367
386
  const calls = [];
368
387
  for (const { option, method, arg } of CONFIG_OPTION_SETTERS) {
369
- if (arg === "value") {
388
+ if (arg === 'value') {
370
389
  // value passthrough — apply on any explicit value, including `false`
371
390
  if (attrs[option] !== undefined)
372
391
  calls.push({ method, value: attrs[option] });
@@ -391,12 +410,13 @@ const applyConfigOptions = async (page, attrs = {}) => {
391
410
  };
392
411
  const busyPages = [];
393
412
  const createdInstances = [];
394
- export const createInstance = async ({ key, url, useParallelPages = true, browser, requestInterceptor, useFontCache = true, } = {}) => {
413
+ export const createInstance = async ({ key, url, useParallelPages = true, browser, requestInterceptor, useFontCache = true, navigationTimeout, } = {}) => {
395
414
  const visitPage = url || `${DEFAULT_CLIENT}?key=${key}`;
396
415
  const hasCustomClientUrl = Boolean(url);
397
- const firstPage = useParallelPages
398
- ? null
399
- : await createPage(browser, visitPage, requestInterceptor, useFontCache);
416
+ // Every page this instance opens is configured identically; bind it once so
417
+ // the three call sites below cannot drift apart.
418
+ const openClientPage = () => createPage(browser, visitPage, requestInterceptor, useFontCache, navigationTimeout);
419
+ const firstPage = useParallelPages ? null : await openClientPage();
400
420
  async function ensureAssetErrorHelpers(page) {
401
421
  await page.evaluate(() => {
402
422
  // Define helpers once per page. They unify asset-loading error handling
@@ -411,11 +431,11 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
411
431
  // The client stashes a structured `{code, message, details}`
412
432
  // payload (image timeouts arrive as IMAGE_FAILED/reason:'timeout',
413
433
  // so skip flags cover them too).
414
- const code = typeof payload === "object" ? payload.code : null;
415
- if (code === "FONT_FAILED" && attrs && attrs.skipFontError) {
434
+ const code = typeof payload === 'object' ? payload.code : null;
435
+ if (code === 'FONT_FAILED' && attrs && attrs.skipFontError) {
416
436
  return null;
417
437
  }
418
- if (code === "IMAGE_FAILED" && attrs && attrs.skipImageError) {
438
+ if (code === 'IMAGE_FAILED' && attrs && attrs.skipImageError) {
419
439
  return null;
420
440
  }
421
441
  // Return the payload as-is (object, or string from a legacy client);
@@ -431,7 +451,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
431
451
  // this throw is flattened by page.evaluate (thrown Errors cross
432
452
  // the boundary as message-only).
433
453
  window._polotnoError = payload;
434
- throw new Error(typeof payload === "string" ? payload : String(payload.message));
454
+ throw new Error(typeof payload === 'string' ? payload : String(payload.message));
435
455
  }
436
456
  };
437
457
  }
@@ -452,11 +472,9 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
452
472
  }
453
473
  }
454
474
  const run = async (func, ...args) => {
455
- const page = useParallelPages
456
- ? await createPage(browser, visitPage, requestInterceptor, useFontCache)
457
- : firstPage;
475
+ const page = useParallelPages ? await openClientPage() : firstPage;
458
476
  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" });
477
+ 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
478
  }
461
479
  busyPages.push(page);
462
480
  // Setup profiling if profilePath is provided
@@ -464,15 +482,15 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
464
482
  const profilePath = args[1]?.profilePath;
465
483
  if (profilePath) {
466
484
  cdpSession = await page.target().createCDPSession();
467
- await cdpSession.send("Profiler.enable");
468
- await cdpSession.send("Profiler.start");
485
+ await cdpSession.send('Profiler.enable');
486
+ await cdpSession.send('Profiler.start');
469
487
  }
470
488
  try {
471
489
  // All direct `window.config.*` setters are applied from one table.
472
490
  await applyConfigOptions(page, args[1]);
473
491
  if (args[1]?.onProgress) {
474
492
  const originalProgress = args[1].onProgress;
475
- await page.exposeFunction("onProgress", async (progress, frameTime) => {
493
+ await page.exposeFunction('onProgress', async (progress, frameTime) => {
476
494
  originalProgress(progress, frameTime);
477
495
  });
478
496
  }
@@ -490,19 +508,23 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
490
508
  const e = error;
491
509
  window._polotnoError =
492
510
  e && e.code
493
- ? { code: e.code, message: String(e.message), details: e.details }
511
+ ? {
512
+ code: e.code,
513
+ message: String(e.message),
514
+ details: e.details,
515
+ }
494
516
  : String(error);
495
517
  });
496
518
  }
497
519
  else {
498
- console.error("onLoadError function is not defined in the client. Error handling will not work.");
520
+ console.error('onLoadError function is not defined in the client. Error handling will not work.');
499
521
  }
500
522
  });
501
523
  await ensureAssetErrorHelpers(page);
502
524
  const result = await page.evaluate(func, ...args);
503
525
  // Stop profiler and save profile if enabled
504
526
  if (cdpSession && profilePath) {
505
- const { profile } = await cdpSession.send("Profiler.stop");
527
+ const { profile } = await cdpSession.send('Profiler.stop');
506
528
  fs.writeFileSync(profilePath, JSON.stringify(profile));
507
529
  console.log(`CPU profile saved to: ${profilePath}`);
508
530
  }
@@ -520,14 +542,14 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
520
542
  catch (e) {
521
543
  // Stop profiler and save profile even on error
522
544
  if (cdpSession && profilePath) {
523
- console.log("saving profile");
545
+ console.log('saving profile');
524
546
  try {
525
- const { profile } = await cdpSession.send("Profiler.stop");
547
+ const { profile } = await cdpSession.send('Profiler.stop');
526
548
  fs.writeFileSync(profilePath, JSON.stringify(profile));
527
549
  console.log(`CPU profile saved to: ${profilePath}`);
528
550
  }
529
551
  catch (profileError) {
530
- console.error("Failed to save profile:", profileError.message);
552
+ console.error('Failed to save profile:', profileError.message);
531
553
  }
532
554
  }
533
555
  // If `page.evaluate()` failed (e.g. thrown during onProgress), still prefer
@@ -550,7 +572,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
550
572
  // than rejecting input the store would happily render), so this only throws on
551
573
  // genuinely malformed JSON.
552
574
  const assertValidJson = (json) => {
553
- assertValidDesign(json, { mode: "partial" });
575
+ assertValidDesign(json, { mode: 'partial' });
554
576
  };
555
577
  const jsonToDataURL = async (json, attrs) => {
556
578
  await assertValidJson(json);
@@ -562,11 +584,11 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
562
584
  };
563
585
  const jsonToGIFBase64 = async (json, attrs) => {
564
586
  const url = await jsonToGIFDataURL(json, attrs);
565
- return url.split("base64,")[1];
587
+ return url.split('base64,')[1];
566
588
  };
567
589
  const jsonToImageBase64 = async (json, attrs) => {
568
590
  const url = await jsonToDataURL(json, attrs);
569
- return url.split("base64,")[1];
591
+ return url.split('base64,')[1];
570
592
  };
571
593
  const jsonToPDFDataURL = async (json, attrs) => {
572
594
  await assertValidJson(json);
@@ -574,11 +596,11 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
574
596
  };
575
597
  const jsonToPDFBase64 = async (json, attrs) => {
576
598
  const url = await jsonToPDFDataURL(json, attrs);
577
- return url.split("base64,")[1];
599
+ return url.split('base64,')[1];
578
600
  };
579
601
  const jsonToBlob = async (json, attrs) => {
580
602
  const base64 = await jsonToImageBase64(json, attrs);
581
- const blob = Buffer.from(base64, "base64");
603
+ const blob = Buffer.from(base64, 'base64');
582
604
  return blob;
583
605
  };
584
606
  const jsonToVideoFile = async (json, outputPath, attrs = {}) => {
@@ -609,9 +631,9 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
609
631
  }
610
632
  // For video export, we always use 'resize' text overflow mode
611
633
  // to ensure text fits properly in animations. User preferences are ignored for now.
612
- window.config.setTextOverflow("resize");
634
+ window.config.setTextOverflow('resize');
613
635
  if (!window.__polotnoLoadVideoExport) {
614
- throw new Error("Video export module loader is not defined in the client. Expected window.__polotnoLoadVideoExport().");
636
+ throw new Error('Video export module loader is not defined in the client. Expected window.__polotnoLoadVideoExport().');
615
637
  }
616
638
  const { storeToVideo } = await window.__polotnoLoadVideoExport();
617
639
  // Create abort controller to cancel video render on error
@@ -667,7 +689,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
667
689
  for (let offset = 0; offset < uint8Array.length; offset += CHUNK_SIZE) {
668
690
  const end = Math.min(offset + CHUNK_SIZE, uint8Array.length);
669
691
  const chunk = uint8Array.slice(offset, end);
670
- let binary = "";
692
+ let binary = '';
671
693
  for (let i = 0; i < chunk.length; i++) {
672
694
  binary += String.fromCharCode(chunk[i]);
673
695
  }
@@ -678,18 +700,18 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
678
700
  ...attrs,
679
701
  exposeFunctions: {
680
702
  __polotnoWriteChunk: (base64Chunk) => {
681
- const buffer = Buffer.from(base64Chunk, "base64");
703
+ const buffer = Buffer.from(base64Chunk, 'base64');
682
704
  writeStream.write(buffer);
683
705
  },
684
706
  },
685
707
  });
686
708
  writeStream.end();
687
- await new Promise((resolve) => writeStream.on("finish", resolve));
709
+ await new Promise((resolve) => writeStream.on('finish', resolve));
688
710
  // Convert audio to AAC if needed
689
711
  if (isFfmpegAvailable()) {
690
712
  const codec = getAudioCodec(outputPath);
691
- if (codec && codec !== "aac") {
692
- const tempPath = outputPath + ".tmp.mp4";
713
+ if (codec && codec !== 'aac') {
714
+ const tempPath = outputPath + '.tmp.mp4';
693
715
  convertAudioToAac(outputPath, tempPath);
694
716
  await fs.promises.rename(tempPath, outputPath);
695
717
  }
@@ -705,7 +727,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
705
727
  try {
706
728
  const mimeType = await jsonToVideoFile(json, tempPath, attrs);
707
729
  const buffer = await fs.promises.readFile(tempPath);
708
- return `data:${mimeType || "video/mp4"};base64,${buffer.toString("base64")}`;
730
+ return `data:${mimeType || 'video/mp4'};base64,${buffer.toString('base64')}`;
709
731
  }
710
732
  finally {
711
733
  await fs.promises.rm(tempPath, { force: true });
@@ -716,7 +738,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
716
738
  try {
717
739
  await jsonToVideoFile(json, tempPath, attrs);
718
740
  const buffer = await fs.promises.readFile(tempPath);
719
- return buffer.toString("base64");
741
+ return buffer.toString('base64');
720
742
  }
721
743
  finally {
722
744
  await fs.promises.rm(tempPath, { force: true });
@@ -740,7 +762,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
740
762
  jsonToVideoDataURL,
741
763
  jsonToVideoBase64,
742
764
  jsonToVideoFile,
743
- createPage: async () => await createPage(browser, visitPage, requestInterceptor, useFontCache),
765
+ createPage: openClientPage,
744
766
  };
745
767
  createdInstances.push(instance);
746
768
  return instance;
@@ -750,7 +772,7 @@ const closeAllInstances = async () => {
750
772
  await Promise.all(createdInstances.map((instance) => instance.close()));
751
773
  };
752
774
  // Also handle other termination signals
753
- ["SIGINT", "SIGTERM", "SIGQUIT", "exit"].forEach((signal) => {
775
+ ['SIGINT', 'SIGTERM', 'SIGQUIT', 'exit'].forEach((signal) => {
754
776
  process.on(signal, () => {
755
777
  closeAllInstances();
756
778
  });
package/lib/types.d.ts CHANGED
@@ -56,6 +56,13 @@ export interface InstanceOptions {
56
56
  requestInterceptor?: (request: any) => void;
57
57
  /** Cache Google Fonts responses for the process lifetime and serve them to every page (default: true) */
58
58
  useFontCache?: boolean;
59
+ /**
60
+ * Budget for the initial navigation to the client page, in ms (default:
61
+ * 120000). Distinct from `protocolTimeout`, which governs CDP calls and not
62
+ * the navigation lifecycle. Raise it on slow machines; `0` disables it, at
63
+ * the cost of turning a wedged load into a permanent hang.
64
+ */
65
+ navigationTimeout?: number;
59
66
  }
60
67
  export interface PolotnoInstance {
61
68
  close: () => Promise<void>;