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/LICENSE.md +20 -6
- package/README.md +35 -19
- package/dist/assets/html2canvas-DCcDvdvP.js +5 -0
- package/dist/assets/id3-CBTtHQkE.js +16 -0
- package/dist/assets/index-Brevyr6F.js +653 -0
- package/dist/assets/index.es-5v7ubLSO.js +5 -0
- package/dist/assets/jspdf.es.min-87BhH7b6.js +79 -0
- package/dist/assets/lib-C8V7BvzS.js +1 -0
- package/dist/assets/preload-helper-HclGiUj8.js +1 -0
- package/dist/assets/purify.es-JEAr64Sr.js +3 -0
- package/dist/assets/{rolldown-runtime-QTnfLwEv.js → rolldown-runtime-hePW80VL.js} +1 -1
- package/dist/index.html +4 -2
- package/lib/errors.d.ts +4 -4
- package/lib/errors.js +2 -2
- package/lib/font-cache.js +12 -12
- package/lib/index.d.ts +3 -3
- package/lib/index.js +23 -101
- package/lib/instance.d.ts +9 -3
- package/lib/instance.js +220 -137
- package/lib/launch-options.d.ts +31 -0
- package/lib/launch-options.js +124 -0
- package/lib/ssrf-guard.d.ts +15 -0
- package/lib/ssrf-guard.js +132 -0
- package/lib/types.d.ts +30 -1
- package/package.json +15 -15
- package/dist/assets/html2canvas-BfZ04V3R.js +0 -5
- package/dist/assets/index-BtUscQGT.js +0 -669
- package/dist/assets/index.es-DYERYDqN.js +0 -5
- package/dist/assets/jspdf.es.min-BIRHrXiC.js +0 -79
- package/dist/assets/lib-B8RASP1f.js +0 -1
- package/dist/assets/purify.es-BT_A-1xG.js +0 -3
package/lib/instance.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
import path from
|
|
2
|
-
import fs from
|
|
3
|
-
import os from
|
|
4
|
-
import crypto from
|
|
5
|
-
import {
|
|
6
|
-
import { pathToFileURL, fileURLToPath } from
|
|
7
|
-
import { Readable } from
|
|
8
|
-
import { pipeline } from
|
|
9
|
-
import { assertValidDesign } from
|
|
10
|
-
import * as fontCache from
|
|
11
|
-
import { polotnoError, rehydrateError } from
|
|
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,
|
|
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 ===
|
|
36
|
-
pointers.push({ kind:
|
|
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 ===
|
|
40
|
-
pointers.push({ kind:
|
|
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 ===
|
|
48
|
-
pointers.push({ kind:
|
|
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 !==
|
|
55
|
+
if (typeof src !== 'string')
|
|
55
56
|
return true;
|
|
56
|
-
return (src.startsWith(
|
|
57
|
-
src.startsWith(
|
|
58
|
-
src.startsWith(
|
|
57
|
+
return (src.startsWith('data:') ||
|
|
58
|
+
src.startsWith('file:') ||
|
|
59
|
+
src.startsWith('blob:'));
|
|
59
60
|
};
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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 ||
|
|
149
|
-
const tag = (vs.codec_tag_string ||
|
|
150
|
-
if ([
|
|
151
|
-
return
|
|
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 ||
|
|
154
|
-
if (pix && pix !==
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 !==
|
|
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 ===
|
|
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(),
|
|
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(
|
|
270
|
+
: crypto.randomBytes(16).toString('hex');
|
|
227
271
|
const tmpPath = path.join(tempDir, tmpBase);
|
|
228
|
-
const contentType = await downloadUrlToFile(src, tmpPath);
|
|
229
|
-
const
|
|
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 +
|
|
236
|
-
|
|
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
|
-
|
|
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 ===
|
|
324
|
+
if (useFontCache || typeof requestInterceptor === 'function') {
|
|
260
325
|
await page.setRequestInterception(true);
|
|
261
|
-
page.on(
|
|
326
|
+
page.on('request', (request) => {
|
|
262
327
|
if (useFontCache && fontCache.tryRespond(request)) {
|
|
263
328
|
return;
|
|
264
329
|
}
|
|
265
|
-
if (typeof requestInterceptor ===
|
|
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(
|
|
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(
|
|
282
|
-
await page.setUserAgent(userAgent +
|
|
346
|
+
if (userAgent.indexOf('Headless') === -1) {
|
|
347
|
+
await page.setUserAgent(userAgent + ' Chrome Headless');
|
|
283
348
|
}
|
|
284
|
-
page.on(
|
|
349
|
+
page.on('console', (msg) => {
|
|
285
350
|
msg.args().forEach(async (message) => {
|
|
286
351
|
// skip style information
|
|
287
|
-
if (message.toString().indexOf(
|
|
352
|
+
if (message.toString().indexOf('margin') >= 0) {
|
|
288
353
|
return;
|
|
289
354
|
}
|
|
290
355
|
const { subtype, description } = message.remoteObject();
|
|
291
|
-
if (subtype ===
|
|
356
|
+
if (subtype === 'error') {
|
|
292
357
|
console.error(description);
|
|
293
358
|
return;
|
|
294
359
|
}
|
|
295
360
|
const text = message
|
|
296
361
|
.toString()
|
|
297
|
-
.replace(
|
|
298
|
-
.replace(
|
|
362
|
+
.replace('JSHandle:', '')
|
|
363
|
+
.replace('%c', '');
|
|
299
364
|
console.log(text);
|
|
300
365
|
});
|
|
301
366
|
});
|
|
302
|
-
page.on(
|
|
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:
|
|
359
|
-
{ option:
|
|
360
|
-
{
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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 ===
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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 ===
|
|
415
|
-
if (code ===
|
|
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 ===
|
|
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 ===
|
|
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(
|
|
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(
|
|
468
|
-
await cdpSession.send(
|
|
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(
|
|
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
|
-
? {
|
|
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(
|
|
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(
|
|
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(
|
|
603
|
+
console.log('saving profile');
|
|
524
604
|
try {
|
|
525
|
-
const { profile } = await cdpSession.send(
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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(
|
|
695
|
+
window.config.setTextOverflow('resize');
|
|
613
696
|
if (!window.__polotnoLoadVideoExport) {
|
|
614
|
-
throw new Error(
|
|
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,
|
|
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(
|
|
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 !==
|
|
692
|
-
const tempPath = outputPath +
|
|
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 ||
|
|
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(
|
|
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:
|
|
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
|
-
[
|
|
836
|
+
['SIGINT', 'SIGTERM', 'SIGQUIT', 'exit'].forEach((signal) => {
|
|
754
837
|
process.on(signal, () => {
|
|
755
838
|
closeAllInstances();
|
|
756
839
|
});
|