polotno-node 3.0.3 → 3.1.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
@@ -8,6 +8,7 @@ import { Readable } from "stream";
8
8
  import { pipeline } from "stream/promises";
9
9
  import { assertValidDesign } from "@polotno/schema";
10
10
  import * as fontCache from "./font-cache.js";
11
+ import { polotnoError, rehydrateError } from "./errors.js";
11
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
13
  // Compiled to lib/instance.js; the client bundle is published to ../dist.
13
14
  const DEFAULT_CLIENT = `file:${path.join(__dirname, "..", "dist", "index.html")}`;
@@ -85,7 +86,7 @@ const guessExtension = (urlString, contentType) => {
85
86
  };
86
87
  const downloadUrlToFile = async (urlString, destinationPath) => {
87
88
  if (typeof fetch !== "function") {
88
- throw new Error("Global fetch API is not available in this Node.js runtime. Please use Node 18+.");
89
+ throw polotnoError("FETCH_FAILED", "Global fetch API is not available in this Node.js runtime. Please use Node 18+.", { url: urlString });
89
90
  }
90
91
  const MAX_ATTEMPTS = 3;
91
92
  const BASE_DELAY_MS = 300;
@@ -113,10 +114,12 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
113
114
  ]
114
115
  .filter(Boolean)
115
116
  .join(", ");
116
- throw new Error(`HTTP error while downloading: ${details}`);
117
+ throw polotnoError("FETCH_FAILED", `HTTP error while downloading: ${details}`, { url: urlString, status: res.status });
117
118
  }
118
119
  if (!res.body) {
119
- throw new Error("Empty response body");
120
+ throw polotnoError("FETCH_FAILED", "Empty response body", {
121
+ url: urlString,
122
+ });
120
123
  }
121
124
  await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(destinationPath));
122
125
  return res.headers.get("content-type") || "";
@@ -131,7 +134,7 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
131
134
  }
132
135
  }
133
136
  const message = lastError && lastError.message ? lastError.message : String(lastError);
134
- throw new Error(`Failed to download media after ${MAX_ATTEMPTS} attempts: ${urlString}\nLast error: ${message}`);
137
+ throw polotnoError("FETCH_FAILED", `Failed to download media after ${MAX_ATTEMPTS} attempts: ${urlString}\nLast error: ${message}`, { url: urlString, attempts: MAX_ATTEMPTS }, lastError);
135
138
  };
136
139
  // Returns a human-readable reason string if the video file needs transcoding
137
140
  // for Chromium headless compatibility, or null if it is already render-safe.
@@ -400,37 +403,41 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
400
403
  // across all exports (dataURL/image/pdf/video).
401
404
  if (!window.__polotnoConsumeAssetError) {
402
405
  window.__polotnoConsumeAssetError = (attrs) => {
403
- const error = window._polotnoError;
404
- if (!error)
406
+ const payload = window._polotnoError;
407
+ if (!payload)
405
408
  return null;
406
409
  // Clear immediately to avoid double-reporting.
407
410
  window._polotnoError = null;
408
- const message = String(error);
409
- // Cross-package contract: every font-load error from polotno core
410
- // references the family as `font "<name>"` (e.g. `Timeout for
411
- // loading font "X"`, `Font "X" not found: ...`). Match that stable
412
- // marker rather than any one full sentence.
413
- const isFontError = /font\s+"/i.test(message);
414
- const skipFontError = isFontError && attrs && attrs.skipFontError;
415
- const isImageError = message.indexOf("image ") >= 0;
416
- const skipImageError = isImageError && attrs && attrs.skipImageError;
417
- if (skipFontError || skipImageError) {
411
+ // The client stashes a structured `{code, message, details}`
412
+ // payload (image timeouts arrive as IMAGE_FAILED/reason:'timeout',
413
+ // so skip flags cover them too).
414
+ const code = typeof payload === "object" ? payload.code : null;
415
+ if (code === "FONT_FAILED" && attrs && attrs.skipFontError) {
416
+ return null;
417
+ }
418
+ if (code === "IMAGE_FAILED" && attrs && attrs.skipImageError) {
418
419
  return null;
419
420
  }
420
- return message;
421
+ // Return the payload as-is (object, or string from a legacy client);
422
+ // the Node side rehydrates it into an Error with code + details.
423
+ return payload;
421
424
  };
422
425
  }
423
426
  if (!window.__polotnoThrowAssetErrorIfAny) {
424
427
  window.__polotnoThrowAssetErrorIfAny = (attrs) => {
425
- const message = window.__polotnoConsumeAssetError(attrs);
426
- if (message) {
427
- throw new Error(message);
428
+ const payload = window.__polotnoConsumeAssetError(attrs);
429
+ if (payload) {
430
+ // Re-stash so the Node side can pull the structured payload after
431
+ // this throw is flattened by page.evaluate (thrown Errors cross
432
+ // the boundary as message-only).
433
+ window._polotnoError = payload;
434
+ throw new Error(typeof payload === "string" ? payload : String(payload.message));
428
435
  }
429
436
  };
430
437
  }
431
438
  });
432
439
  }
433
- async function consumeAssetLoadingErrorMessage(page, attrs) {
440
+ async function consumeAssetLoadingError(page, attrs) {
434
441
  try {
435
442
  return await page.evaluate((attrs) => {
436
443
  if (window.__polotnoConsumeAssetError) {
@@ -449,7 +456,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
449
456
  ? await createPage(browser, visitPage, requestInterceptor, useFontCache)
450
457
  : firstPage;
451
458
  if (busyPages.indexOf(page) >= 0) {
452
- throw new Error("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.");
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" });
453
460
  }
454
461
  busyPages.push(page);
455
462
  // Setup profiling if profilePath is provided
@@ -478,7 +485,13 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
478
485
  await page.evaluate(() => {
479
486
  if (window.config?.onLoadError) {
480
487
  window.config.onLoadError((error) => {
481
- window._polotnoError = error;
488
+ // New clients pass an Error with `code`/`details` — stash a JSON
489
+ // form so both survive the trip to Node. Old clients pass a string.
490
+ const e = error;
491
+ window._polotnoError =
492
+ e && e.code
493
+ ? { code: e.code, message: String(e.message), details: e.details }
494
+ : String(error);
482
495
  });
483
496
  }
484
497
  else {
@@ -493,9 +506,9 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
493
506
  fs.writeFileSync(profilePath, JSON.stringify(profile));
494
507
  console.log(`CPU profile saved to: ${profilePath}`);
495
508
  }
496
- const assetErrorMessage = await consumeAssetLoadingErrorMessage(page, args[1]);
497
- if (assetErrorMessage) {
498
- throw new Error(assetErrorMessage);
509
+ const assetError = await consumeAssetLoadingError(page, args[1]);
510
+ if (assetError) {
511
+ throw rehydrateError(assetError);
499
512
  }
500
513
  // remove busy page
501
514
  busyPages.splice(busyPages.indexOf(page), 1);
@@ -519,14 +532,14 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
519
532
  }
520
533
  // If `page.evaluate()` failed (e.g. thrown during onProgress), still prefer
521
534
  // a captured Polotno asset-loading error when available.
522
- const assetErrorMessage = await consumeAssetLoadingErrorMessage(page, args[1]);
535
+ const assetError = await consumeAssetLoadingError(page, args[1]);
523
536
  // remove busy page
524
537
  busyPages.splice(busyPages.indexOf(page), 1);
525
538
  if (useParallelPages) {
526
539
  await page.close();
527
540
  }
528
- if (assetErrorMessage) {
529
- throw new Error(assetErrorMessage);
541
+ if (assetError) {
542
+ throw rehydrateError(assetError);
530
543
  }
531
544
  throw e;
532
545
  }
@@ -608,9 +621,9 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
608
621
  // even if user didn't provide onProgress
609
622
  const progressCallback = (progress, frameTime) => {
610
623
  // Check for asset-loading errors during render (without throwing)
611
- const errorMessage = window.__polotnoConsumeAssetError(attrs);
612
- if (errorMessage) {
613
- capturedError = errorMessage;
624
+ const assetError = window.__polotnoConsumeAssetError(attrs);
625
+ if (assetError) {
626
+ capturedError = assetError;
614
627
  abortController.abort();
615
628
  return;
616
629
  }
@@ -630,15 +643,19 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
630
643
  });
631
644
  }
632
645
  catch (e) {
633
- // If we aborted due to asset error, throw that instead
646
+ // If we aborted due to asset error, throw that instead. Re-stash
647
+ // it and let the shared helper own the flatten/re-stash protocol
648
+ // (capturedError already passed the skip flags in consume).
634
649
  if (capturedError) {
635
- throw new Error(capturedError);
650
+ window._polotnoError = capturedError;
651
+ window.__polotnoThrowAssetErrorIfAny(attrs);
636
652
  }
637
653
  throw e;
638
654
  }
639
655
  // Final check for any errors that occurred
640
656
  if (capturedError) {
641
- throw new Error(capturedError);
657
+ window._polotnoError = capturedError;
658
+ window.__polotnoThrowAssetErrorIfAny(attrs);
642
659
  }
643
660
  // Stream blob in chunks via exposed function to avoid large payload issues.
644
661
  // CRITICAL (!!!): Chunk size MUST be a multiple of 3 bytes because base64 encodes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polotno-node",
3
- "version": "3.0.3",
3
+ "version": "3.1.0",
4
4
  "description": "Polotno workflow from NodeJS",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "type": "module",
@@ -16,6 +16,11 @@
16
16
  "types": "./lib/instance.d.ts",
17
17
  "import": "./lib/instance.js",
18
18
  "default": "./lib/instance.js"
19
+ },
20
+ "./errors": {
21
+ "types": "./lib/errors.d.ts",
22
+ "import": "./lib/errors.js",
23
+ "default": "./lib/errors.js"
19
24
  }
20
25
  },
21
26
  "files": [
@@ -25,10 +30,14 @@
25
30
  "LICENSE.md"
26
31
  ],
27
32
  "browserslist": "> 0.5%, last 2 versions, not dead",
33
+ "bundledWorkspaceDeps": [
34
+ "polotno",
35
+ "@polotno/video-export"
36
+ ],
28
37
  "devDependencies": {
29
- "@types/node": "^25.9.2",
30
- "mediabunny": "^1.47.0",
31
- "axios": "^1.18.0",
38
+ "@types/node": "^26.1.1",
39
+ "mediabunny": "^1.50.8",
40
+ "axios": "^1.18.1",
32
41
  "dotenv": "^17.4.2",
33
42
  "fluent-ffmpeg": "^2.1.3",
34
43
  "jspdf": "^4.2.1",
@@ -39,11 +48,11 @@
39
48
  "react-dom": "^19.2.7",
40
49
  "tmp": "^0.2.7",
41
50
  "typescript": "~6.0.3",
42
- "vite": "^8.0.16",
43
- "vitest": "^4.1.9",
44
- "web-audio-api": "^1.5.1",
45
- "@polotno/video-export": "0.1.0",
46
- "polotno": "4.2.2"
51
+ "vite": "^8.1.4",
52
+ "vitest": "^4.1.10",
53
+ "web-audio-api": "^1.5.3",
54
+ "@polotno/video-export": "0.2.1",
55
+ "polotno": "4.4.0"
47
56
  },
48
57
  "overrides": {
49
58
  "@polotno/video-export": {
@@ -54,19 +63,15 @@
54
63
  "author": "Anton Lavrenov",
55
64
  "dependencies": {
56
65
  "@sparticuz/chromium": "^149.0.0",
57
- "puppeteer": "25.1.0",
58
- "puppeteer-core": "25.1.0",
59
- "@polotno/schema": "0.1.6"
66
+ "puppeteer": "25.3.0",
67
+ "puppeteer-core": "25.3.0",
68
+ "@polotno/schema": "0.3.0"
60
69
  },
61
70
  "bugs": {
62
71
  "url": "https://github.com/polotno-project/polotno-node/issues"
63
72
  },
64
73
  "homepage": "https://github.com/polotno-project/polotno-node",
65
74
  "readmeFilename": "README.md",
66
- "repository": {
67
- "type": "git",
68
- "url": "git@github.com:polotno-project/polotno-node.git"
69
- },
70
75
  "scripts": {
71
76
  "build": "rm -rf ./lib ./dist && tsc -p tsconfig.json && vite build ./client/ --base=./ --outDir ../dist --emptyOutDir --logLevel error",
72
77
  "typecheck": "tsc --noEmit -p tsconfig.json",