polotno-node 3.4.0 → 3.6.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/README.md +12 -1
- package/dist/assets/id3-Brg-Xwuw.js +16 -0
- package/dist/assets/index-DJaU6riF.js +646 -0
- package/dist/assets/{lib-C8V7BvzS.js → lib-BEzl6SFD.js} +1 -1
- package/dist/index.html +2 -2
- package/lib/index.js +62 -10
- package/lib/instance.d.ts +21 -0
- package/lib/instance.js +198 -39
- package/lib/launch-options.d.ts +21 -0
- package/lib/launch-options.js +27 -0
- package/lib/local-media.d.ts +18 -0
- package/lib/local-media.js +169 -0
- package/lib/types.d.ts +10 -2
- package/package.json +8 -7
- package/dist/assets/id3-CBTtHQkE.js +0 -16
- package/dist/assets/index-B2B7pzVl.js +0 -656
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import puppeteerCore from 'puppeteer-core';
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import { createInstance as createInstanceImpl } from './instance.js';
|
|
4
|
-
import { BUNDLED_CHROMIUM_PLATFORM, resolveBrowserArgs, resolveExecutablePath, } from './launch-options.js';
|
|
4
|
+
import { BUNDLED_CHROMIUM_PLATFORM, resolveBrowserArgs, resolveExecutablePath, resolveHeadless, } from './launch-options.js';
|
|
5
5
|
export { args } from './launch-options.js';
|
|
6
6
|
// Off the bundled chromium's platform, use full puppeteer — it brings its own
|
|
7
7
|
// Chromium. ESM imports are immutable, so resolve the variant via a dynamic
|
|
@@ -12,32 +12,84 @@ if (os.platform() !== BUNDLED_CHROMIUM_PLATFORM) {
|
|
|
12
12
|
puppeteer = (await import('puppeteer')).default;
|
|
13
13
|
}
|
|
14
14
|
const browserProps = {
|
|
15
|
-
headless: true,
|
|
16
15
|
// make large timeout, because some renders may take a lot of time
|
|
17
16
|
// like large PDF or video render
|
|
18
17
|
protocolTimeout: 60 * 60 * 1000,
|
|
19
18
|
ignoreHTTPSErrors: true,
|
|
20
19
|
};
|
|
20
|
+
let warnedAboutShellFallback = false;
|
|
21
|
+
/**
|
|
22
|
+
* The browser puppeteer itself is configured to launch, from
|
|
23
|
+
* `PUPPETEER_EXECUTABLE_PATH` or a `.puppeteerrc`. It wins over every binary we
|
|
24
|
+
* would resolve — including the headless shell — so it has to count as a
|
|
25
|
+
* browser we did not pick, exactly like an `executablePath` argument. Asking
|
|
26
|
+
* this instance is what makes that true on both entry points: only full
|
|
27
|
+
* `puppeteer` reads that configuration, and the Linux path runs
|
|
28
|
+
* `puppeteer-core`, which does not.
|
|
29
|
+
*/
|
|
30
|
+
async function configuredExecutablePath() {
|
|
31
|
+
try {
|
|
32
|
+
return (await puppeteer.configuration())?.executablePath;
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
21
38
|
export async function createBrowser(options = {}) {
|
|
22
|
-
//
|
|
39
|
+
// All three are resolved below, so none may also reach the spread — a caller
|
|
23
40
|
// forwarding an unset env var (`executablePath:
|
|
24
41
|
// process.env.PUPPETEER_EXECUTABLE_PATH`) would overwrite the resolved path
|
|
25
42
|
// with `undefined` and leave puppeteer-core with no browser at all.
|
|
26
|
-
const { browserArgs, executablePath, ...rest } = options;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
executablePath: await
|
|
43
|
+
const { browserArgs, executablePath, headless, ...rest } = options;
|
|
44
|
+
const resolved = {
|
|
45
|
+
...options,
|
|
46
|
+
executablePath: executablePath || (await configuredExecutablePath()),
|
|
47
|
+
};
|
|
48
|
+
const launchOptions = {
|
|
49
|
+
args: resolveBrowserArgs(resolved),
|
|
50
|
+
executablePath: await resolveExecutablePath(resolved),
|
|
51
|
+
headless: resolveHeadless(resolved),
|
|
30
52
|
...browserProps,
|
|
31
53
|
...rest,
|
|
32
|
-
}
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
return await puppeteer.launch(launchOptions);
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
// The headless shell is ours to pick, so its absence is ours to survive:
|
|
60
|
+
// puppeteer installs it next to `chrome` by default, but a caller can skip
|
|
61
|
+
// it (`PUPPETEER_SKIP_CHROME_HEADLESS_SHELL_DOWNLOAD`, or an image that
|
|
62
|
+
// copies only the `chrome` folder out of the cache). Try the other binary
|
|
63
|
+
// before giving up — but only when WE chose the shell, never when the
|
|
64
|
+
// caller asked for it by name, and never at the cost of the real error.
|
|
65
|
+
if (launchOptions.headless !== 'shell' || headless !== undefined)
|
|
66
|
+
throw e;
|
|
67
|
+
let browser;
|
|
68
|
+
try {
|
|
69
|
+
browser = await puppeteer.launch({ ...launchOptions, headless: true });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Full Chrome is no better off, so the shell was never the problem: the
|
|
73
|
+
// first error is the one worth reading.
|
|
74
|
+
throw e;
|
|
75
|
+
}
|
|
76
|
+
if (!warnedAboutShellFallback) {
|
|
77
|
+
warnedAboutShellFallback = true;
|
|
78
|
+
console.warn('polotno-node: could not launch chrome-headless-shell, falling back to full Chrome. ' +
|
|
79
|
+
'Install it with `npx puppeteer browsers install chrome-headless-shell`; on macOS it ' +
|
|
80
|
+
'also avoids a crash dialog when rendering without a desktop session.');
|
|
81
|
+
}
|
|
82
|
+
return browser;
|
|
83
|
+
}
|
|
33
84
|
}
|
|
34
85
|
export const createInstance = async (options = {}) => {
|
|
35
86
|
// Named here only because they shape the browser; everything else is passed
|
|
36
87
|
// straight through. Relisting each option by name is how `navigationTimeout`
|
|
37
88
|
// came to be declared, honoured downstream, and silently dropped in between.
|
|
38
|
-
const { browser, executablePath, browserArgs, ...rest } = options;
|
|
89
|
+
const { browser, executablePath, browserArgs, headless, ...rest } = options;
|
|
39
90
|
return await createInstanceImpl({
|
|
40
91
|
...rest,
|
|
41
|
-
browser: browser ||
|
|
92
|
+
browser: browser ||
|
|
93
|
+
(await createBrowser({ browserArgs, executablePath, headless })),
|
|
42
94
|
});
|
|
43
95
|
};
|
package/lib/instance.d.ts
CHANGED
|
@@ -10,6 +10,27 @@ declare global {
|
|
|
10
10
|
__polotnoWriteChunk?: (chunk: string) => void;
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
export declare const URL_FIELDS: string[];
|
|
14
|
+
/**
|
|
15
|
+
* Visit every URL-holding field in a design: `visit(node, key, value)` for each
|
|
16
|
+
* string under one of `URL_FIELDS`, `custom` skipped whole.
|
|
17
|
+
*
|
|
18
|
+
* Exported because `@polotno/fidelity` inlines a fixture's local assets over
|
|
19
|
+
* the same fields — which subtrees a rewrite may enter is one rule, and two
|
|
20
|
+
* copies of it drift. Changing either one needs that package's `test:unit`.
|
|
21
|
+
*/
|
|
22
|
+
export declare const forEveryUrlField: (node: any, visit: any) => void;
|
|
23
|
+
/**
|
|
24
|
+
* Rewrite every `file:` source in a design to a URL the client can load with
|
|
25
|
+
* any API. Designs without one — the common case — come back untouched, so this
|
|
26
|
+
* costs a walk and nothing else. `release()` drops the registrations; hold them
|
|
27
|
+
* until the render is finished.
|
|
28
|
+
*/
|
|
29
|
+
export declare const serveLocalFiles: (json: any) => {
|
|
30
|
+
json: any;
|
|
31
|
+
serving: boolean;
|
|
32
|
+
release: () => void;
|
|
33
|
+
};
|
|
13
34
|
export declare const guessExtension: (contentType: any, kind: any) => any;
|
|
14
35
|
export declare const prepareLocalMediaForVideoExport: (json: any, policy?: FetchPolicy) => Promise<{
|
|
15
36
|
json: any;
|
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 * as localMedia from './local-media.js';
|
|
11
12
|
import { polotnoError, rehydrateError } from './errors.js';
|
|
12
13
|
import { assertUrlAllowed } from './ssrf-guard.js';
|
|
13
14
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -51,6 +52,97 @@ const collectMediaSourcePointers = (json) => {
|
|
|
51
52
|
}
|
|
52
53
|
return pointers;
|
|
53
54
|
};
|
|
55
|
+
// Design fields that carry a URL the renderer loads. A `file:` value in any of
|
|
56
|
+
// them is served over the local-media channel instead — see `local-media.ts`
|
|
57
|
+
// for why the browser must never see that scheme. Every other value is passed
|
|
58
|
+
// through exactly as the caller wrote it.
|
|
59
|
+
//
|
|
60
|
+
// A list, rather than "rewrite every `file:` string anywhere", because a design
|
|
61
|
+
// carries text: a text element whose content reads `file:///notes.txt` is
|
|
62
|
+
// content, and rewriting it corrupts the render. `custom` is arbitrary caller
|
|
63
|
+
// data with the same problem, so the walk does not enter it at all. And
|
|
64
|
+
// `test/unit/local-media.test.mjs` fails when the schema grows a field named
|
|
65
|
+
// like one that is missing here.
|
|
66
|
+
export const URL_FIELDS = ['src', 'clipSrc', 'maskSrc', 'background', 'url'];
|
|
67
|
+
const URL_FIELD_SET = new Set(URL_FIELDS);
|
|
68
|
+
/**
|
|
69
|
+
* Visit every URL-holding field in a design: `visit(node, key, value)` for each
|
|
70
|
+
* string under one of `URL_FIELDS`, `custom` skipped whole.
|
|
71
|
+
*
|
|
72
|
+
* Exported because `@polotno/fidelity` inlines a fixture's local assets over
|
|
73
|
+
* the same fields — which subtrees a rewrite may enter is one rule, and two
|
|
74
|
+
* copies of it drift. Changing either one needs that package's `test:unit`.
|
|
75
|
+
*/
|
|
76
|
+
export const forEveryUrlField = (node, visit) => {
|
|
77
|
+
if (!node || typeof node !== 'object')
|
|
78
|
+
return;
|
|
79
|
+
if (Array.isArray(node)) {
|
|
80
|
+
for (const item of node)
|
|
81
|
+
forEveryUrlField(item, visit);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
for (const key of Object.keys(node)) {
|
|
85
|
+
if (key === 'custom')
|
|
86
|
+
continue;
|
|
87
|
+
const value = node[key];
|
|
88
|
+
if (typeof value === 'string') {
|
|
89
|
+
if (URL_FIELD_SET.has(key))
|
|
90
|
+
visit(node, key, value);
|
|
91
|
+
}
|
|
92
|
+
else if (value && typeof value === 'object') {
|
|
93
|
+
forEveryUrlField(value, visit);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
// Not `toLowerCase().startsWith`: a `src` is routinely a data URL of several
|
|
98
|
+
// MB, and that would copy every one of them on every render.
|
|
99
|
+
const isFileUrl = (value) => /^file:/i.test(value);
|
|
100
|
+
// The design goes to the browser as the caller wrote it, and there is nothing
|
|
101
|
+
// to release or to switch on.
|
|
102
|
+
const nothingToServe = (json) => ({ json, serving: false, release: () => { } });
|
|
103
|
+
/**
|
|
104
|
+
* Rewrite every `file:` source in a design to a URL the client can load with
|
|
105
|
+
* any API. Designs without one — the common case — come back untouched, so this
|
|
106
|
+
* costs a walk and nothing else. `release()` drops the registrations; hold them
|
|
107
|
+
* until the render is finished.
|
|
108
|
+
*/
|
|
109
|
+
export const serveLocalFiles = (json) => {
|
|
110
|
+
let found = false;
|
|
111
|
+
forEveryUrlField(json, (_node, _key, value) => {
|
|
112
|
+
found || (found = isFileUrl(value));
|
|
113
|
+
});
|
|
114
|
+
if (!found)
|
|
115
|
+
return nothingToServe(json);
|
|
116
|
+
const cloned = JSON.parse(JSON.stringify(json));
|
|
117
|
+
// One URL per file, however many elements name it — the same URL twice is a
|
|
118
|
+
// request the browser answers from its own cache.
|
|
119
|
+
const byPath = new Map();
|
|
120
|
+
forEveryUrlField(cloned, (target, key, value) => {
|
|
121
|
+
if (!isFileUrl(value))
|
|
122
|
+
return;
|
|
123
|
+
let filePath;
|
|
124
|
+
try {
|
|
125
|
+
filePath = fileURLToPath(value);
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
// Not a file URL we can resolve to a path. Leave it alone: the client
|
|
129
|
+
// reports it as a failed asset, exactly as it does today.
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!byPath.has(filePath))
|
|
133
|
+
byPath.set(filePath, localMedia.register(filePath));
|
|
134
|
+
target[key] = byPath.get(filePath);
|
|
135
|
+
});
|
|
136
|
+
const urls = [...byPath.values()];
|
|
137
|
+
// `serving` is per render, and the page-level switch is gated on it: a
|
|
138
|
+
// process-wide "is anything registered" would let one render turn
|
|
139
|
+
// interception on for another's page.
|
|
140
|
+
return {
|
|
141
|
+
json: cloned,
|
|
142
|
+
serving: urls.length > 0,
|
|
143
|
+
release: () => localMedia.release(urls),
|
|
144
|
+
};
|
|
145
|
+
};
|
|
54
146
|
const isDataOrFileSrc = (src) => {
|
|
55
147
|
if (typeof src !== 'string')
|
|
56
148
|
return true;
|
|
@@ -69,8 +161,8 @@ const CONTENT_TYPE_EXTENSION = {
|
|
|
69
161
|
'audio/ogg': '.ogg',
|
|
70
162
|
'audio/webm': '.webm',
|
|
71
163
|
};
|
|
72
|
-
// The temp file's extension is later passed to ffmpeg/ffprobe and
|
|
73
|
-
//
|
|
164
|
+
// The temp file's extension is later passed to ffmpeg/ffprobe, and backs the
|
|
165
|
+
// content type we serve it with. Take it ONLY from the response content-type,
|
|
74
166
|
// never the URL — a URL-derived extension like `x.$(touch pwned)` is how the
|
|
75
167
|
// original RCE injected a shell command (CWE-78). The name is otherwise a UUID.
|
|
76
168
|
export const guessExtension = (contentType, kind) => {
|
|
@@ -265,10 +357,7 @@ export const prepareLocalMediaForVideoExport = async (json, policy = {}) => {
|
|
|
265
357
|
};
|
|
266
358
|
try {
|
|
267
359
|
await Promise.all(urlsToDownload.map(async (src) => {
|
|
268
|
-
const
|
|
269
|
-
? crypto.randomUUID()
|
|
270
|
-
: crypto.randomBytes(16).toString('hex');
|
|
271
|
-
const tmpPath = path.join(tempDir, tmpBase);
|
|
360
|
+
const tmpPath = path.join(tempDir, crypto.randomUUID());
|
|
272
361
|
const contentType = await downloadUrlToFile(src, tmpPath, policy);
|
|
273
362
|
const kind = videoUrls.has(src) ? 'video' : 'audio';
|
|
274
363
|
const ext = guessExtension(contentType, kind);
|
|
@@ -295,6 +384,9 @@ export const prepareLocalMediaForVideoExport = async (json, policy = {}) => {
|
|
|
295
384
|
finalPath = normalizedPath;
|
|
296
385
|
}
|
|
297
386
|
}
|
|
387
|
+
// A `file:` URL, which `serveLocalFiles` turns into one the browser can
|
|
388
|
+
// read — the same path a caller's own local file takes. One place makes
|
|
389
|
+
// that decision, and the extension we just derived carries the type.
|
|
298
390
|
rewriteMap.set(src, pathToFileURL(finalPath).toString());
|
|
299
391
|
}));
|
|
300
392
|
for (const p of pointers) {
|
|
@@ -318,25 +410,68 @@ export const prepareLocalMediaForVideoExport = async (json, policy = {}) => {
|
|
|
318
410
|
// calls, not the navigation lifecycle. 2 minutes is generous but still bounded,
|
|
319
411
|
// unlike `timeout: 0` ("disabled"), which turns a wedged load into a hang.
|
|
320
412
|
const DEFAULT_NAVIGATION_TIMEOUT = 120 * 1000;
|
|
413
|
+
// Pages that have request interception on. It is a page-level switch, and the
|
|
414
|
+
// one handler each page carries must stay out of the way while it is off.
|
|
415
|
+
const intercepting = new WeakSet();
|
|
416
|
+
// Pages `createPage` opened, and so the only ones carrying the handler that
|
|
417
|
+
// answers a local-media URL. A caller who brings their own page keeps their own
|
|
418
|
+
// request handling, and their design keeps the URLs they wrote.
|
|
419
|
+
const ourPages = new WeakSet();
|
|
420
|
+
/**
|
|
421
|
+
* Turn request interception on for a page, once. Interception is on already for
|
|
422
|
+
* everyone who keeps the font cache; this covers the caller who turned it off
|
|
423
|
+
* and then renders a design that carries local files.
|
|
424
|
+
*/
|
|
425
|
+
const ensureServing = async (page) => {
|
|
426
|
+
if (intercepting.has(page))
|
|
427
|
+
return;
|
|
428
|
+
await page.setRequestInterception(true);
|
|
429
|
+
// After the await, never before: a page marked while the switch failed to
|
|
430
|
+
// flip would let the handler respond on a page that is not intercepting, and
|
|
431
|
+
// the asset would fail silently. Nothing can slip in between — the statement
|
|
432
|
+
// runs in the same microtask the await resolves on.
|
|
433
|
+
intercepting.add(page);
|
|
434
|
+
};
|
|
321
435
|
export const createPage = async (browser, url, requestInterceptor, useFontCache = true, navigationTimeout = DEFAULT_NAVIGATION_TIMEOUT) => {
|
|
322
436
|
const page = await browser.newPage();
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
437
|
+
ourPages.add(page);
|
|
438
|
+
// `goto` was raised to 120s because 30s could not load this client bundle on
|
|
439
|
+
// a loaded box. Every OTHER wait on the page was left at puppeteer's same 30s
|
|
440
|
+
// default — acquiring an execution context for `evaluate`, waiting for a
|
|
441
|
+
// target — and those wait on that same bundle, in the same conditions. One
|
|
442
|
+
// budget raised and its siblings left behind is the same defect in a
|
|
443
|
+
// different place, so give them the one budget.
|
|
444
|
+
//
|
|
445
|
+
// Not observed failing: three forced whole-repo runs and six at 74 concurrent
|
|
446
|
+
// instances (load 25 on 16 cores) stayed green. This closes the gap the
|
|
447
|
+
// reasoning above implies, rather than a failure anyone has seen.
|
|
448
|
+
//
|
|
449
|
+
// 0 passes through as puppeteer's "no timeout", exactly as it does for
|
|
450
|
+
// `goto`: a caller who disabled the budget meant it.
|
|
451
|
+
page.setDefaultTimeout(navigationTimeout);
|
|
452
|
+
// One handler per page, always attached, because `ensureServing` may turn
|
|
453
|
+
// interception on later. It stays inert until then.
|
|
454
|
+
page.on('request', (request) => {
|
|
455
|
+
if (!intercepting.has(page))
|
|
456
|
+
return;
|
|
457
|
+
if (localMedia.tryRespond(request)) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (useFontCache && fontCache.tryRespond(request)) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (typeof requestInterceptor === 'function') {
|
|
464
|
+
requestInterceptor(request);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
request.continue().catch(() => { });
|
|
339
468
|
}
|
|
469
|
+
});
|
|
470
|
+
if (useFontCache) {
|
|
471
|
+
page.on('response', (response) => fontCache.capture(response));
|
|
472
|
+
}
|
|
473
|
+
if (useFontCache || typeof requestInterceptor === 'function') {
|
|
474
|
+
await ensureServing(page);
|
|
340
475
|
}
|
|
341
476
|
// we need to make sure that headless is inside user agent
|
|
342
477
|
// because we use it for key validation check
|
|
@@ -370,9 +505,26 @@ export const createPage = async (browser, url, requestInterceptor, useFontCache
|
|
|
370
505
|
await page.goto(url, { timeout: navigationTimeout });
|
|
371
506
|
return page;
|
|
372
507
|
};
|
|
373
|
-
|
|
374
|
-
|
|
508
|
+
/**
|
|
509
|
+
* Evaluate on a page the caller owns, with the design's local files served
|
|
510
|
+
* first — and only when the page came from `createPage`, the one that carries
|
|
511
|
+
* the handler answering a local-media URL. Rewriting for any other page would
|
|
512
|
+
* trade a URL the browser might load for one nothing answers.
|
|
513
|
+
*/
|
|
514
|
+
const evaluateServed = async (page, func, json, ...rest) => {
|
|
515
|
+
const served = ourPages.has(page)
|
|
516
|
+
? serveLocalFiles(json)
|
|
517
|
+
: nothingToServe(json);
|
|
518
|
+
try {
|
|
519
|
+
if (served.serving)
|
|
520
|
+
await ensureServing(page);
|
|
521
|
+
return await page.evaluate(func, served.json, ...rest);
|
|
522
|
+
}
|
|
523
|
+
finally {
|
|
524
|
+
served.release();
|
|
525
|
+
}
|
|
375
526
|
};
|
|
527
|
+
export const run = async (page, func, args) => evaluateServed(page, func, args[0], ...args.slice(1));
|
|
376
528
|
// In-page export recipes. These run inside the browser (via page.evaluate / run),
|
|
377
529
|
// so they may only reference page globals (`store`, `window`) — not module scope.
|
|
378
530
|
// Defining them once lets the instance methods and the module-level page-taking
|
|
@@ -405,15 +557,9 @@ const recipeToGIFDataURL = async (json, attrs) => {
|
|
|
405
557
|
store.loadJSON(json);
|
|
406
558
|
return await store.toGIFDataURL(attrs);
|
|
407
559
|
};
|
|
408
|
-
export const jsonToDataURL = async (page, json, attrs) => {
|
|
409
|
-
|
|
410
|
-
};
|
|
411
|
-
export const jsonToPDFDataURL = async (page, json, attrs) => {
|
|
412
|
-
return page.evaluate(recipeToPDFDataURL, json, attrs || {});
|
|
413
|
-
};
|
|
414
|
-
export const jsonToBlob = async (page, json, attrs) => {
|
|
415
|
-
return page.evaluate(recipeToBlob, json, attrs || {});
|
|
416
|
-
};
|
|
560
|
+
export const jsonToDataURL = async (page, json, attrs) => evaluateServed(page, recipeToDataURL, json, attrs || {});
|
|
561
|
+
export const jsonToPDFDataURL = async (page, json, attrs) => evaluateServed(page, recipeToPDFDataURL, json, attrs || {});
|
|
562
|
+
export const jsonToBlob = async (page, json, attrs) => evaluateServed(page, recipeToBlob, json, attrs || {});
|
|
417
563
|
// Render options that map directly to a `window.config.*` setter on the client.
|
|
418
564
|
// `arg: "value"` passes the option value through whenever it is explicitly set
|
|
419
565
|
// (including `false`, so a client-side default can be turned off). `arg: true`
|
|
@@ -534,16 +680,26 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
534
680
|
if (busyPages.indexOf(page) >= 0) {
|
|
535
681
|
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' });
|
|
536
682
|
}
|
|
683
|
+
// Every export and every caller recipe passes the design here, so this is
|
|
684
|
+
// the one place that serves its local files. Before the page is marked
|
|
685
|
+
// busy, because a design this cannot walk — a cyclic object reaches `run`
|
|
686
|
+
// unvalidated — would throw and leave the page busy for the rest of the
|
|
687
|
+
// instance's life.
|
|
688
|
+
const served = serveLocalFiles(args[0]);
|
|
689
|
+
const runArgs = [served.json, ...args.slice(1)];
|
|
537
690
|
busyPages.push(page);
|
|
538
691
|
// Setup profiling if profilePath is provided
|
|
539
692
|
let cdpSession = null;
|
|
540
693
|
const profilePath = args[1]?.profilePath;
|
|
541
|
-
if (profilePath) {
|
|
542
|
-
cdpSession = await page.target().createCDPSession();
|
|
543
|
-
await cdpSession.send('Profiler.enable');
|
|
544
|
-
await cdpSession.send('Profiler.start');
|
|
545
|
-
}
|
|
546
694
|
try {
|
|
695
|
+
if (served.serving) {
|
|
696
|
+
await ensureServing(page);
|
|
697
|
+
}
|
|
698
|
+
if (profilePath) {
|
|
699
|
+
cdpSession = await page.target().createCDPSession();
|
|
700
|
+
await cdpSession.send('Profiler.enable');
|
|
701
|
+
await cdpSession.send('Profiler.start');
|
|
702
|
+
}
|
|
547
703
|
// All direct `window.config.*` setters are applied from one table.
|
|
548
704
|
await applyConfigOptions(page, args[1]);
|
|
549
705
|
if (args[1]?.onProgress) {
|
|
@@ -579,7 +735,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
579
735
|
}
|
|
580
736
|
});
|
|
581
737
|
await ensureAssetErrorHelpers(page);
|
|
582
|
-
const result = await page.evaluate(func, ...
|
|
738
|
+
const result = await page.evaluate(func, ...runArgs);
|
|
583
739
|
// Stop profiler and save profile if enabled
|
|
584
740
|
if (cdpSession && profilePath) {
|
|
585
741
|
const { profile } = await cdpSession.send('Profiler.stop');
|
|
@@ -623,6 +779,9 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
623
779
|
}
|
|
624
780
|
throw e;
|
|
625
781
|
}
|
|
782
|
+
finally {
|
|
783
|
+
served.release();
|
|
784
|
+
}
|
|
626
785
|
};
|
|
627
786
|
// Pre-flight schema check: catch malformed JSON before launching the browser
|
|
628
787
|
// and throw `Invalid Polotno design: …`. `assertValidDesign` mirrors the core
|
package/lib/launch-options.d.ts
CHANGED
|
@@ -22,6 +22,27 @@ export declare function usesBundledChromium(options?: BrowserOptions, platform?:
|
|
|
22
22
|
* `browserArgs` win over both.
|
|
23
23
|
*/
|
|
24
24
|
export declare function resolveBrowserArgs(options?: BrowserOptions, platform?: string): string[];
|
|
25
|
+
/**
|
|
26
|
+
* The headless mode to launch with.
|
|
27
|
+
*
|
|
28
|
+
* `'shell'` is puppeteer's separate `chrome-headless-shell` binary, and we
|
|
29
|
+
* prefer it wherever puppeteer resolves the browser for us. Full Chrome in
|
|
30
|
+
* headless mode calls `TransformProcessType` on macOS, only to hide a Dock icon
|
|
31
|
+
* it does not have. That call needs the window server, so in a sandbox without
|
|
32
|
+
* a GUI session — an agent's shell, ssh, a launchd daemon — the browser aborts
|
|
33
|
+
* at launch and macOS shows a crash dialog. The shell binary never makes the
|
|
34
|
+
* call. It is also about 3x faster to open a page and renders identically; the
|
|
35
|
+
* only measured difference is the antialiasing of a fallback font.
|
|
36
|
+
*
|
|
37
|
+
* Two cases keep full Chrome:
|
|
38
|
+
* - an `executablePath`: `'shell'` does not switch binaries then, it only
|
|
39
|
+
* passes `--headless` to the one named, which Chrome 132+ reads as new
|
|
40
|
+
* headless anyway. A silent no-op is worse than saying `true`.
|
|
41
|
+
* - `@sparticuz/chromium`: it ships one binary and no shell build.
|
|
42
|
+
*
|
|
43
|
+
* A caller's own `headless` always wins.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveHeadless(options?: BrowserOptions, platform?: string): boolean | 'shell';
|
|
25
46
|
/**
|
|
26
47
|
* The browser to launch: the caller's, else the bundled chromium where we use
|
|
27
48
|
* it, else puppeteer's own. Resolving the bundled one is not free — it inflates
|
package/lib/launch-options.js
CHANGED
|
@@ -109,6 +109,33 @@ export function resolveBrowserArgs(options = {}, platform = os.platform()) {
|
|
|
109
109
|
? mergeArgs(chrome.args, args, browserArgs)
|
|
110
110
|
: mergeArgs(args, browserArgs);
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* The headless mode to launch with.
|
|
114
|
+
*
|
|
115
|
+
* `'shell'` is puppeteer's separate `chrome-headless-shell` binary, and we
|
|
116
|
+
* prefer it wherever puppeteer resolves the browser for us. Full Chrome in
|
|
117
|
+
* headless mode calls `TransformProcessType` on macOS, only to hide a Dock icon
|
|
118
|
+
* it does not have. That call needs the window server, so in a sandbox without
|
|
119
|
+
* a GUI session — an agent's shell, ssh, a launchd daemon — the browser aborts
|
|
120
|
+
* at launch and macOS shows a crash dialog. The shell binary never makes the
|
|
121
|
+
* call. It is also about 3x faster to open a page and renders identically; the
|
|
122
|
+
* only measured difference is the antialiasing of a fallback font.
|
|
123
|
+
*
|
|
124
|
+
* Two cases keep full Chrome:
|
|
125
|
+
* - an `executablePath`: `'shell'` does not switch binaries then, it only
|
|
126
|
+
* passes `--headless` to the one named, which Chrome 132+ reads as new
|
|
127
|
+
* headless anyway. A silent no-op is worse than saying `true`.
|
|
128
|
+
* - `@sparticuz/chromium`: it ships one binary and no shell build.
|
|
129
|
+
*
|
|
130
|
+
* A caller's own `headless` always wins.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveHeadless(options = {}, platform = os.platform()) {
|
|
133
|
+
if (options.headless !== undefined)
|
|
134
|
+
return options.headless;
|
|
135
|
+
if (options.executablePath)
|
|
136
|
+
return true;
|
|
137
|
+
return usesBundledChromium(options, platform) ? true : 'shell';
|
|
138
|
+
}
|
|
112
139
|
/**
|
|
113
140
|
* The browser to launch: the caller's, else the bundled chromium where we use
|
|
114
141
|
* it, else puppeteer's own. Resolving the bundled one is not free — it inflates
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const ORIGIN = "https://polotno-local-media.invalid/";
|
|
2
|
+
/**
|
|
3
|
+
* Content type to serve a path with. From the extension, which is what Chromium
|
|
4
|
+
* itself read a `file:` URL as, so no design renders differently for it. A video,
|
|
5
|
+
* an audio track and a font are decoded off the declared type, so a wrong guess
|
|
6
|
+
* here is an asset that never loads.
|
|
7
|
+
*/
|
|
8
|
+
export declare const contentTypeForPath: (filePath: string) => string;
|
|
9
|
+
/** Register a local file and return the URL the client should load it from. */
|
|
10
|
+
export declare const register: (filePath: string) => string;
|
|
11
|
+
/** Drop registrations once the render that needed them is over. */
|
|
12
|
+
export declare const release: (urls: string[]) => void;
|
|
13
|
+
export declare const MAX_SLICE: number;
|
|
14
|
+
/**
|
|
15
|
+
* Answer a request for a registered file and return true, or return false so
|
|
16
|
+
* the caller keeps handling the request itself.
|
|
17
|
+
*/
|
|
18
|
+
export declare const tryRespond: (request: any) => boolean;
|