polotno-node 3.3.0 → 3.5.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/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
- // Both are resolved below, so neither may also reach the spread — a caller
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
- return puppeteer.launch({
28
- args: resolveBrowserArgs(options),
29
- executablePath: await resolveExecutablePath(options),
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 || (await createBrowser({ browserArgs, executablePath })),
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 used by
73
- // Chromium for the `file://` MIME. Take it ONLY from the response content-type,
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 tmpBase = crypto.randomUUID
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
- // Attach request interceptor if provided
324
- if (useFontCache || typeof requestInterceptor === 'function') {
325
- await page.setRequestInterception(true);
326
- page.on('request', (request) => {
327
- if (useFontCache && fontCache.tryRespond(request)) {
328
- return;
329
- }
330
- if (typeof requestInterceptor === 'function') {
331
- requestInterceptor(request);
332
- }
333
- else {
334
- request.continue().catch(() => { });
335
- }
336
- });
337
- if (useFontCache) {
338
- page.on('response', (response) => fontCache.capture(response));
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
- export const run = async (page, func, args) => {
374
- return await page.evaluate(func, ...args);
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
- return await page.evaluate(recipeToDataURL, json, attrs || {});
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, ...args);
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
@@ -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
@@ -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;