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.
@@ -0,0 +1,31 @@
1
+ import type { BrowserOptions } from './types.js';
2
+ export declare const args: string[];
3
+ /** The one platform `@sparticuz/chromium` builds its binary for. */
4
+ export declare const BUNDLED_CHROMIUM_PLATFORM = "linux";
5
+ /**
6
+ * Do we launch the chromium binary that `@sparticuz/chromium` ships?
7
+ *
8
+ * Only on its platform, and only when the caller has not named a browser of
9
+ * their own. macOS and Windows run puppeteer's bundled Chromium; a caller who
10
+ * passes `executablePath` — a container's google-chrome-stable, playwright's
11
+ * build — runs that.
12
+ */
13
+ export declare function usesBundledChromium(options?: BrowserOptions, platform?: string): boolean;
14
+ /**
15
+ * The flags to launch with. `@sparticuz/chromium`'s args are tuned for the
16
+ * binary it ships (`--single-process`, `--in-process-gpu`, swiftshader ANGLE)
17
+ * and belong to it alone: given to a system Chrome they range from wasteful to
18
+ * fatal — `--single-process` in particular can leave Chrome dying mid-handshake,
19
+ * which surfaces only as "Protocol error (Target.setDiscoverTargets): Target
20
+ * closed". So they travel with the binary. Our own args, which keep rendering
21
+ * identical to the editor's canvas, always apply, and the caller's
22
+ * `browserArgs` win over both.
23
+ */
24
+ export declare function resolveBrowserArgs(options?: BrowserOptions, platform?: string): string[];
25
+ /**
26
+ * The browser to launch: the caller's, else the bundled chromium where we use
27
+ * it, else puppeteer's own. Resolving the bundled one is not free — it inflates
28
+ * chromium, the fonts and swiftshader into /tmp — so we only ask for it when it
29
+ * is the one we launch.
30
+ */
31
+ export declare function resolveExecutablePath(options?: BrowserOptions, platform?: string): Promise<string | undefined>;
@@ -0,0 +1,124 @@
1
+ import os from 'os';
2
+ import sparticuz from '@sparticuz/chromium';
3
+ // TODO: that is a weird hack, but it works
4
+ const chrome = sparticuz.default || sparticuz;
5
+ export const args = [
6
+ 'about:blank',
7
+ '--disable-web-security',
8
+ '--allow-file-access-from-files',
9
+ '--allow-pre-commit-input',
10
+ '--autoplay-policy=user-gesture-required',
11
+ '--disable-background-networking',
12
+ '--disable-background-timer-throttling',
13
+ '--disable-backgrounding-occluded-windows',
14
+ '--disable-breakpad',
15
+ '--disable-component-extensions-with-background-pages',
16
+ '--disable-client-side-phishing-detection',
17
+ '--disable-component-update',
18
+ '--disable-default-apps',
19
+ '--disable-dev-shm-usage',
20
+ '--proxy-server=direct://',
21
+ '--proxy-bypass-list=*',
22
+ '--disable-domain-reliability',
23
+ '--disable-features=AudioServiceOutOfProcess,IsolateOrigins,site-per-process,Translate,BackForwardCache,AvoidUnnecessaryBeforeUnloadCheckSync,IntensiveWakeUpThrottling',
24
+ '--disable-extensions',
25
+ '--disable-hang-monitor',
26
+ '--disable-ipc-flooding-protection',
27
+ '--disable-notifications',
28
+ '--disable-offer-store-unmasked-wallet-cards',
29
+ '--disable-popup-blocking',
30
+ '--disable-print-preview',
31
+ '--disable-site-isolation-trials',
32
+ '--disable-prompt-on-repost',
33
+ '--disable-renderer-backgrounding',
34
+ '--disable-setuid-sandbox',
35
+ '--disable-speech-api',
36
+ '--disable-sync',
37
+ // Use srgb for identical output across Linux/macOS/Windows and to match how CSS colors/images are defined/assumed on the web.
38
+ '--force-color-profile=srgb',
39
+ '--hide-scrollbars',
40
+ '--metrics-recording-only',
41
+ '--mute-audio',
42
+ '--no-default-browser-check',
43
+ '--no-first-run',
44
+ '--enable-automation',
45
+ '--no-pings',
46
+ '--no-sandbox',
47
+ '--no-zygote',
48
+ '--password-store=basic',
49
+ '--allow-running-insecure-content',
50
+ // Disable accessibility features to reduce resource usage and improve performance.
51
+ '--disable-renderer-accessibility',
52
+ // stable locale → font fallback/hyphenation
53
+ '--lang=en-US',
54
+ // this line breaks the rendering on most of OS
55
+ // it doesn't allow creating of pages
56
+ //'--use-gl=swiftshader',
57
+ '--disable-gpu',
58
+ '--use-mock-keychain',
59
+ '--enable-blink-features=IdleDetection',
60
+ '--intensive-wake-up-throttling-policy=0',
61
+ '--font-render-hinting=none',
62
+ '--ignore-gpu-blocklist',
63
+ // don't use this flag, because it makes renderign less accurate
64
+ // see https://community.polotno.com/c/ask/the-ability-to-exclude-args-from-minimal_args-in-polotno-node
65
+ // '--disable-font-subpixel-positioning',
66
+ '--text-rendering=geometricprecision',
67
+ ];
68
+ /**
69
+ * Merge browser argument arrays, deduping by flag name (the part before `=`).
70
+ * Later arrays take precedence on conflict.
71
+ */
72
+ function mergeArgs(...argArrays) {
73
+ const argMap = new Map();
74
+ for (const argArray of argArrays) {
75
+ for (const arg of argArray) {
76
+ const flagMatch = arg.match(/^(--[^=]+)/);
77
+ const flagName = flagMatch ? flagMatch[1] : arg;
78
+ argMap.set(flagName, arg);
79
+ }
80
+ }
81
+ return Array.from(argMap.values());
82
+ }
83
+ /** The one platform `@sparticuz/chromium` builds its binary for. */
84
+ export const BUNDLED_CHROMIUM_PLATFORM = 'linux';
85
+ /**
86
+ * Do we launch the chromium binary that `@sparticuz/chromium` ships?
87
+ *
88
+ * Only on its platform, and only when the caller has not named a browser of
89
+ * their own. macOS and Windows run puppeteer's bundled Chromium; a caller who
90
+ * passes `executablePath` — a container's google-chrome-stable, playwright's
91
+ * build — runs that.
92
+ */
93
+ export function usesBundledChromium(options = {}, platform = os.platform()) {
94
+ return platform === BUNDLED_CHROMIUM_PLATFORM && !options.executablePath;
95
+ }
96
+ /**
97
+ * The flags to launch with. `@sparticuz/chromium`'s args are tuned for the
98
+ * binary it ships (`--single-process`, `--in-process-gpu`, swiftshader ANGLE)
99
+ * and belong to it alone: given to a system Chrome they range from wasteful to
100
+ * fatal — `--single-process` in particular can leave Chrome dying mid-handshake,
101
+ * which surfaces only as "Protocol error (Target.setDiscoverTargets): Target
102
+ * closed". So they travel with the binary. Our own args, which keep rendering
103
+ * identical to the editor's canvas, always apply, and the caller's
104
+ * `browserArgs` win over both.
105
+ */
106
+ export function resolveBrowserArgs(options = {}, platform = os.platform()) {
107
+ const { browserArgs = [] } = options;
108
+ return usesBundledChromium(options, platform)
109
+ ? mergeArgs(chrome.args, args, browserArgs)
110
+ : mergeArgs(args, browserArgs);
111
+ }
112
+ /**
113
+ * The browser to launch: the caller's, else the bundled chromium where we use
114
+ * it, else puppeteer's own. Resolving the bundled one is not free — it inflates
115
+ * chromium, the fonts and swiftshader into /tmp — so we only ask for it when it
116
+ * is the one we launch.
117
+ */
118
+ export async function resolveExecutablePath(options = {}, platform = os.platform()) {
119
+ if (options.executablePath)
120
+ return options.executablePath;
121
+ return usesBundledChromium(options, platform)
122
+ ? await chrome.executablePath()
123
+ : undefined;
124
+ }
@@ -0,0 +1,15 @@
1
+ export interface FetchPolicy {
2
+ /** Also block loopback, link-local and private (RFC1918 / ULA) addresses. */
3
+ blockPrivateNetwork?: boolean;
4
+ /**
5
+ * Full override. Return `false` to reject the URL. When provided, the
6
+ * built-in IP checks are skipped and this is the sole authority.
7
+ */
8
+ fetchGuard?: (url: string) => boolean | Promise<boolean>;
9
+ }
10
+ /**
11
+ * Throw a branded FETCH_FAILED error if `urlString` must not be fetched under
12
+ * `policy`. Resolves DNS for hostnames and checks every returned address.
13
+ * No-throw = allowed.
14
+ */
15
+ export declare const assertUrlAllowed: (urlString: string, policy?: FetchPolicy) => Promise<void>;
@@ -0,0 +1,132 @@
1
+ import dns from 'dns/promises';
2
+ import net from 'net';
3
+ import { polotnoError } from './errors.js';
4
+ // Cloud metadata endpoints — AWS/GCP/Azure (169.254.169.254), ECS task role
5
+ // (169.254.170.2), and the IPv6 form. Never a legitimate render target.
6
+ const METADATA_IPS = new Set([
7
+ '169.254.169.254',
8
+ '169.254.170.2',
9
+ 'fd00:ec2::254',
10
+ ]);
11
+ // Metadata hostnames (GCP) — caught by name even where they don't resolve.
12
+ const METADATA_HOSTS = new Set(['metadata.google.internal', 'metadata.goog']);
13
+ // A real metadata A-record resolves in ms; a slow resolver fails open promptly.
14
+ const DNS_TIMEOUT_MS = 1500;
15
+ // Resolve to addresses; [] on error/timeout (fail open). Never rejects.
16
+ const resolveHost = async (host) => {
17
+ let timer;
18
+ try {
19
+ const addresses = await Promise.race([
20
+ dns.lookup(host, { all: true }),
21
+ new Promise((_, reject) => {
22
+ timer = setTimeout(() => reject(new Error('dns-timeout')), DNS_TIMEOUT_MS);
23
+ }),
24
+ ]);
25
+ return addresses.map((a) => a.address);
26
+ }
27
+ catch {
28
+ return [];
29
+ }
30
+ finally {
31
+ clearTimeout(timer);
32
+ }
33
+ };
34
+ const blocked = (url, why) => {
35
+ const err = polotnoError('FETCH_FAILED', `Blocked request to ${url}: ${why} (SSRF protection). ` +
36
+ `Pass a fetchGuard to override, or blockPrivateNetwork:false to relax.`, { url });
37
+ // A deterministic policy rejection — the download loop must not retry it.
38
+ err.ssrfBlocked = true;
39
+ return err;
40
+ };
41
+ const ipv4IsPrivate = (ip) => {
42
+ const [a, b] = ip.split('.').map(Number);
43
+ if (a === 127)
44
+ return true; // loopback 127.0.0.0/8
45
+ if (a === 10)
46
+ return true; // 10.0.0.0/8
47
+ if (a === 172 && b >= 16 && b <= 31)
48
+ return true; // 172.16.0.0/12
49
+ if (a === 192 && b === 168)
50
+ return true; // 192.168.0.0/16
51
+ if (a === 169 && b === 254)
52
+ return true; // link-local 169.254.0.0/16
53
+ if (a === 100 && b >= 64 && b <= 127)
54
+ return true; // CGNAT 100.64.0.0/10
55
+ if (a === 0)
56
+ return true; // 0.0.0.0/8
57
+ return false;
58
+ };
59
+ const ipv6IsPrivate = (ip) => {
60
+ const v = ip.toLowerCase();
61
+ if (v === '::1' || v === '::')
62
+ return true; // loopback / unspecified
63
+ if (v.startsWith('fe80'))
64
+ return true; // link-local
65
+ if (v.startsWith('fc') || v.startsWith('fd'))
66
+ return true; // unique local
67
+ return false;
68
+ };
69
+ // Unwrap an IPv4-mapped IPv6 address to its dotted-quad form, or null. Handles
70
+ // both the dotted (`::ffff:169.254.169.254`) and hex (`::ffff:a9fe:a9fe`)
71
+ // spellings — Node's `URL` normalizes the former into the latter, so a guard
72
+ // that only knew the dotted form could be bypassed with a bracketed literal.
73
+ const unwrapMappedV4 = (addr) => {
74
+ const dotted = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
75
+ if (dotted)
76
+ return dotted[1];
77
+ const hex = addr.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
78
+ if (hex) {
79
+ const hi = parseInt(hex[1], 16);
80
+ const lo = parseInt(hex[2], 16);
81
+ return `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`;
82
+ }
83
+ return null;
84
+ };
85
+ // Returns a reason string if the address must be blocked, else null.
86
+ const addressBlockReason = (ip, blockPrivate) => {
87
+ let addr = ip.toLowerCase();
88
+ addr = unwrapMappedV4(addr) ?? addr;
89
+ if (METADATA_IPS.has(addr))
90
+ return 'cloud-metadata endpoint';
91
+ if (!blockPrivate)
92
+ return null;
93
+ if (net.isIPv4(addr) && ipv4IsPrivate(addr))
94
+ return 'private/loopback address';
95
+ if (net.isIPv6(addr) && ipv6IsPrivate(addr))
96
+ return 'private/loopback address';
97
+ return null;
98
+ };
99
+ /**
100
+ * Throw a branded FETCH_FAILED error if `urlString` must not be fetched under
101
+ * `policy`. Resolves DNS for hostnames and checks every returned address.
102
+ * No-throw = allowed.
103
+ */
104
+ export const assertUrlAllowed = async (urlString, policy = {}) => {
105
+ if (policy.fetchGuard) {
106
+ const ok = await policy.fetchGuard(urlString);
107
+ if (!ok)
108
+ throw blocked(urlString, 'rejected by fetchGuard');
109
+ return;
110
+ }
111
+ let parsed;
112
+ try {
113
+ parsed = new URL(urlString);
114
+ }
115
+ catch {
116
+ // Malformed URL — let the caller's fetch surface the real error.
117
+ return;
118
+ }
119
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
120
+ return;
121
+ // Strip IPv6 brackets: new URL('http://[::1]/').hostname === '[::1]'.
122
+ const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
123
+ if (METADATA_HOSTS.has(host))
124
+ throw blocked(urlString, 'metadata hostname');
125
+ const addresses = net.isIP(host) ? [host] : await resolveHost(host);
126
+ const blockPrivate = !!policy.blockPrivateNetwork;
127
+ for (const ip of addresses) {
128
+ const reason = addressBlockReason(ip, blockPrivate);
129
+ if (reason)
130
+ throw blocked(urlString, reason);
131
+ }
132
+ };
package/lib/types.d.ts CHANGED
@@ -37,11 +37,28 @@ export interface VideoRenderOptions extends RenderOptions {
37
37
  * URLs for more reliable and faster loading. Set `skipDownloads: true` to disable that.
38
38
  */
39
39
  skipDownloads?: boolean;
40
+ /**
41
+ * SSRF hardening for the server-side media downloads above. Cloud-metadata
42
+ * endpoints (e.g. 169.254.169.254) are blocked by default — whether named by
43
+ * literal IP, a known metadata hostname, or a hostname that resolves to one
44
+ * (best-effort; a resolve-then-fetch guard leaves a small DNS-rebinding
45
+ * window). Set this to ALSO reject loopback, link-local and private (RFC1918 /
46
+ * ULA) addresses — enable it when the design JSON is untrusted and the
47
+ * renderer has no legitimate reason to reach internal hosts. Default: `false`
48
+ * (localhost and private hosts allowed).
49
+ */
50
+ blockPrivateNetwork?: boolean;
51
+ /**
52
+ * Full override for the download SSRF check: return `false` to reject a URL.
53
+ * When provided it is the sole authority — the built-in IP checks are skipped.
54
+ * Redirects are validated per-hop with the same rule.
55
+ */
56
+ fetchGuard?: (url: string) => boolean | Promise<boolean>;
40
57
  }
41
58
  export type RenderJSON = DesignInput;
42
59
  export interface BrowserOptions {
43
60
  browserArgs?: string[];
44
- headless?: 'new' | boolean;
61
+ headless?: boolean | 'shell';
45
62
  protocolTimeout?: number;
46
63
  ignoreHTTPSErrors?: boolean;
47
64
  executablePath?: string;
@@ -52,10 +69,22 @@ export interface InstanceOptions {
52
69
  useParallelPages?: boolean;
53
70
  browserArgs?: string[];
54
71
  browser?: Browser;
72
+ /**
73
+ * Launch this browser binary instead of the one we would pick — a container's
74
+ * google-chrome-stable, say. Ignored when you pass your own `browser`.
75
+ */
76
+ executablePath?: string;
55
77
  /** Intercept every page request (e.g. to block or rewrite asset loads). */
56
78
  requestInterceptor?: (request: any) => void;
57
79
  /** Cache Google Fonts responses for the process lifetime and serve them to every page (default: true) */
58
80
  useFontCache?: boolean;
81
+ /**
82
+ * Budget for the initial navigation to the client page, in ms (default:
83
+ * 120000). Distinct from `protocolTimeout`, which governs CDP calls and not
84
+ * the navigation lifecycle. Raise it on slow machines; `0` disables it, at
85
+ * the cost of turning a wedged load into a permanent hang.
86
+ */
87
+ navigationTimeout?: number;
59
88
  }
60
89
  export interface PolotnoInstance {
61
90
  close: () => Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polotno-node",
3
- "version": "3.1.3",
3
+ "version": "3.3.0",
4
4
  "description": "Polotno workflow from NodeJS",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "type": "module",
@@ -35,24 +35,24 @@
35
35
  "@polotno/video-export"
36
36
  ],
37
37
  "devDependencies": {
38
- "@types/node": "^26.1.1",
39
- "mediabunny": "^1.50.8",
40
- "axios": "^1.18.1",
38
+ "@types/node": "^26.1.2",
39
+ "mediabunny": "^1.52.3",
40
+ "axios": "^1.19.0",
41
41
  "dotenv": "^17.4.2",
42
42
  "fluent-ffmpeg": "^2.1.3",
43
43
  "jspdf": "^4.2.1",
44
44
  "konva": "^10.3.0",
45
45
  "pixelmatch": "^7.2.0",
46
46
  "pngjs": "^7.0.0",
47
- "react": "^19.2.7",
48
- "react-dom": "^19.2.7",
47
+ "react": "^19.2.8",
48
+ "react-dom": "^19.2.8",
49
49
  "tmp": "^0.2.7",
50
50
  "typescript": "~6.0.3",
51
- "vite": "^8.1.4",
51
+ "vite": "^8.2.1",
52
52
  "vitest": "^4.1.10",
53
53
  "web-audio-api": "^1.5.3",
54
- "polotno": "4.5.2",
55
- "@polotno/video-export": "0.2.1"
54
+ "polotno": "4.7.0",
55
+ "@polotno/video-export": "0.2.3"
56
56
  },
57
57
  "overrides": {
58
58
  "@polotno/video-export": {
@@ -63,9 +63,9 @@
63
63
  "author": "Anton Lavrenov",
64
64
  "dependencies": {
65
65
  "@sparticuz/chromium": "^149.0.0",
66
- "puppeteer": "25.3.0",
67
- "puppeteer-core": "25.3.0",
68
- "@polotno/schema": "0.3.0"
66
+ "puppeteer": "25.5.0",
67
+ "puppeteer-core": "25.5.0",
68
+ "@polotno/schema": "0.5.0"
69
69
  },
70
70
  "bugs": {
71
71
  "url": "https://github.com/polotno-project/polotno-node/issues"
@@ -75,9 +75,9 @@
75
75
  "scripts": {
76
76
  "build": "rm -rf ./lib ./dist && tsc -p tsconfig.json && vite build ./client/ --base=./ --outDir ../dist --emptyOutDir --logLevel error",
77
77
  "typecheck": "tsc --noEmit -p tsconfig.json",
78
- "test": "pnpm run build && vitest run test/unit test/static/static.test.mjs",
79
- "test:video": "pnpm run build && vitest run test/video/video.test.mjs",
80
- "test:all": "pnpm run build && vitest run test/unit test/static/static.test.mjs test/video/video.test.mjs",
78
+ "test": "pnpm exec turbo run build --filter=polotno-node --output-logs=new-only --log-order=stream && vitest run test/unit test/static/static.test.mjs",
79
+ "test:video": "pnpm exec turbo run build --filter=polotno-node && vitest run test/video/video.test.mjs",
80
+ "test:all": "pnpm exec turbo run build --filter=polotno-node && vitest run test/unit test/static/static.test.mjs test/video/video.test.mjs",
81
81
  "dev": "vite ./client.html"
82
82
  }
83
83
  }