mnfst-render 0.5.38 → 0.5.39

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.
Files changed (2) hide show
  1. package/manifest.render.mjs +204 -118
  2. package/package.json +1 -1
@@ -189,6 +189,96 @@ async function waitForManifestRenderReady(page, { allLocales, currentLocale, tim
189
189
  // intentional and benign — the DOM is still captured.
190
190
  }
191
191
 
192
+ // --- Browser recycle gate ----------------------------------------------------
193
+
194
+ /** Bound a promise: resolves true if it settles first, false on timeout. */
195
+ function withDeadline(promise, ms) {
196
+ if (!(ms > 0)) return Promise.resolve(promise).then(() => true);
197
+ let timer;
198
+ const settled = Promise.resolve(promise).then(
199
+ () => { clearTimeout(timer); return true; },
200
+ (err) => { clearTimeout(timer); throw err; }
201
+ );
202
+ return Promise.race([
203
+ settled,
204
+ new Promise((resolve) => {
205
+ timer = setTimeout(() => resolve(false), ms);
206
+ if (typeof timer.unref === 'function') timer.unref();
207
+ }),
208
+ ]);
209
+ }
210
+
211
+ /**
212
+ * Coordinates N render workers around a shared browser that is swapped out
213
+ * every `every` pages. Workers hold a slot (acquire/release) for exactly as
214
+ * long as they touch the browser; a recycle pauses new slots first, then
215
+ * drains the in-flight ones, so no worker can ever be blocked on the gate
216
+ * while it is still counted as active. Every wait is bounded: a stuck page
217
+ * or a stuck browser swap logs and continues instead of hanging the run.
218
+ * Exported for unit tests.
219
+ */
220
+ export function createRecycleGate({
221
+ every = 40,
222
+ recycle = async () => {},
223
+ drainTimeoutMs = 60000,
224
+ recycleTimeoutMs = 180000,
225
+ onLog = () => {},
226
+ } = {}) {
227
+ let active = 0;
228
+ let pages = 0;
229
+ let paused = false;
230
+ const zeroWaiters = [];
231
+ const resumeWaiters = [];
232
+ const wake = (list) => { for (const resolve of list.splice(0)) resolve(); };
233
+
234
+ return {
235
+ get active() { return active; },
236
+ get pages() { return pages; },
237
+ get paused() { return paused; },
238
+ /** Claim a browser slot; blocks while a recycle is pending or running. */
239
+ async acquire() {
240
+ while (paused) await new Promise((resolve) => { resumeWaiters.push(resolve); });
241
+ active++;
242
+ },
243
+ release() {
244
+ active = Math.max(0, active - 1);
245
+ if (active === 0) wake(zeroWaiters);
246
+ },
247
+ /** Count a page against the recycle threshold. */
248
+ countPage() { pages++; },
249
+ /** Make the next maybeRecycle() fire (unstable browser after retries). */
250
+ requestRecycle() { if (every > 0) pages = Math.max(pages, every); },
251
+ /** Swap the browser if the threshold is reached. Never throws. */
252
+ async maybeRecycle() {
253
+ if (every <= 0 || paused || pages < every) return false;
254
+ paused = true; // no new slots from here on — active can only fall
255
+ const processed = pages;
256
+ try {
257
+ if (active > 0) {
258
+ const drained = await withDeadline(
259
+ new Promise((resolve) => { zeroWaiters.push(resolve); }),
260
+ drainTimeoutMs
261
+ );
262
+ if (!drained) {
263
+ onLog(`recycle: ${active} page(s) still in flight after ${drainTimeoutMs}ms — recycling anyway`);
264
+ }
265
+ }
266
+ const swapped = await withDeadline(recycle(processed), recycleTimeoutMs);
267
+ if (!swapped) onLog(`recycle: browser swap exceeded ${recycleTimeoutMs}ms — continuing`);
268
+ pages = 0;
269
+ return swapped;
270
+ } catch (err) {
271
+ onLog(`recycle: failed (${err && err.message ? err.message : err}) — continuing`);
272
+ pages = 0;
273
+ return false;
274
+ } finally {
275
+ paused = false;
276
+ wake(resumeWaiters);
277
+ }
278
+ },
279
+ };
280
+ }
281
+
192
282
  // --- Config ------------------------------------------------------------------
193
283
 
194
284
  function parseArgs() {
@@ -1010,15 +1100,16 @@ function buildRootAssetPath(routerBasePath, filename) {
1010
1100
  return '/' + path.replace(/\/{2,}/g, '/');
1011
1101
  }
1012
1102
 
1013
- /** Inject stylesheet link with root-absolute href (avoids ../ resolving under locale segments like /en/page/). */
1014
- function postProcessInjectStylesheetLink(outputDir, filename, routerBasePath) {
1103
+ /** Inject stylesheet link with root-absolute href (avoids ../ resolving under locale segments like /en/page/).
1104
+ * `attrs` (e.g. `data-mnfst-utilities`) lets the runtime utilities plugin find and skip classes this sheet covers. */
1105
+ function postProcessInjectStylesheetLink(outputDir, filename, routerBasePath, attrs = '') {
1015
1106
  const cssPath = join(outputDir, filename);
1016
1107
  if (!existsSync(cssPath)) return;
1017
1108
  const stat = statSync(cssPath);
1018
1109
  if (stat.size === 0) return;
1019
1110
 
1020
1111
  const href = buildRootAssetPath(routerBasePath, filename);
1021
- const tag = `<link rel="stylesheet" href="${href}">`;
1112
+ const tag = `<link rel="stylesheet" href="${href}"${attrs ? ` ${attrs}` : ''}>`;
1022
1113
  const files = walkHtmlFiles(outputDir);
1023
1114
  for (const file of files) {
1024
1115
  let html = readFileSync(file, 'utf8');
@@ -2344,8 +2435,10 @@ async function injectMetaInDom(page, ctx) {
2344
2435
  if (existingTypes.has(type)) continue;
2345
2436
  if (def === false) continue;
2346
2437
  if (type === 'BreadcrumbList' && def === true) {
2347
- // Auto-derive from URL path segments.
2438
+ // Auto-derive from URL path segments; skip crumbs whose URL has no
2439
+ // rendered page (avoids linking soft-404 intermediate paths).
2348
2440
  const parts = location.pathname.split('/').filter(Boolean);
2441
+ const rendered = Array.isArray(ctx.renderedPaths) ? new Set(ctx.renderedPaths) : null;
2349
2442
  const items = [{
2350
2443
  '@type': 'ListItem',
2351
2444
  position: 1,
@@ -2353,11 +2446,13 @@ async function injectMetaInDom(page, ctx) {
2353
2446
  item: liveBase ? liveBase + '/' : '/',
2354
2447
  }];
2355
2448
  parts.forEach((seg, i) => {
2449
+ const crumbPath = parts.slice(0, i + 1).join('/');
2450
+ if (rendered && !rendered.has(crumbPath)) return;
2356
2451
  items.push({
2357
2452
  '@type': 'ListItem',
2358
- position: i + 2,
2453
+ position: items.length + 1,
2359
2454
  name: seg.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
2360
- item: liveBase ? `${liveBase}/${parts.slice(0, i + 1).join('/')}` : '/' + parts.slice(0, i + 1).join('/'),
2455
+ item: liveBase ? `${liveBase}/${crumbPath}` : '/' + crumbPath,
2361
2456
  });
2362
2457
  });
2363
2458
  blocks.push({ '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items });
@@ -3259,13 +3354,49 @@ async function runPrerender(config) {
3259
3354
  // Recycle the browser every N processed pages to bound resource growth.
3260
3355
  // Configurable via manifest.prerender.browserRecycleEvery.
3261
3356
  const browserRecycleEvery = Math.max(0, pre.browserRecycleEvery ?? 40);
3262
- let pagesSinceRecycle = 0;
3263
- const recycleLock = { busy: false };
3264
- // Workers block on this promise before touching `browser`. While a recycle
3265
- // is in progress it's a pending promise; once the new browser is up it
3266
- // resolves and workers can proceed. This prevents "browser not ready"
3267
- // errors from racing retries during recycle.
3268
- let browserReadyPromise = Promise.resolve();
3357
+ // Bounds on the recycle handshake and on a single page render. A stuck
3358
+ // page must never pin the whole run: each is capped, logged and stepped over.
3359
+ const drainTimeoutMs = Math.max(0, pre.recycleDrainTimeout ?? 60000);
3360
+ const recycleTimeoutMs = Math.max(0, pre.recycleTimeout ?? 180000);
3361
+ const pageTimeoutMs = Math.max(0, pre.pageTimeout ?? Math.max(120000, (config.wait ?? 30000) * 4));
3362
+
3363
+ // Close the browser without hanging: SIGKILL the process if close stalls.
3364
+ async function closeBrowser(target) {
3365
+ if (!target) return;
3366
+ const proc = typeof target.process === 'function' ? target.process() : null;
3367
+ const closed = await withDeadline(Promise.resolve(target.close()).catch(() => {}), 15000);
3368
+ if (!closed) {
3369
+ process.stderr.write('prerender: browser close timed out — killing the process\n');
3370
+ try { proc?.kill('SIGKILL'); } catch (_) {}
3371
+ }
3372
+ }
3373
+
3374
+ // Launch with a deadline and one retry; a hung launch would otherwise stall
3375
+ // every worker behind the recycle gate.
3376
+ async function launchBrowserBounded() {
3377
+ for (let attempt = 0; attempt < 2; attempt++) {
3378
+ let launched = null;
3379
+ const ok = await withDeadline(
3380
+ launchBrowser().then((b) => { launched = b; }),
3381
+ 60000
3382
+ );
3383
+ if (ok && launched) return launched;
3384
+ process.stderr.write('prerender: browser launch timed out — retrying\n');
3385
+ }
3386
+ throw new Error('browser launch timed out');
3387
+ }
3388
+
3389
+ const recycleGate = createRecycleGate({
3390
+ every: browserRecycleEvery,
3391
+ drainTimeoutMs,
3392
+ recycleTimeoutMs,
3393
+ onLog: (message) => process.stderr.write(`prerender: ${message}\n`),
3394
+ recycle: async (processed) => {
3395
+ process.stdout.write(`prerender: recycling browser (processed ${processed} pages)\n`);
3396
+ await closeBrowser(browser);
3397
+ browser = await launchBrowserBounded();
3398
+ },
3399
+ });
3269
3400
  const pathTotal = pathList.length;
3270
3401
  const failedPaths = [];
3271
3402
  const debugRows = [];
@@ -3354,7 +3485,7 @@ async function runPrerender(config) {
3354
3485
  debugRows.push(row);
3355
3486
  }
3356
3487
 
3357
- async function processPath(pathSeg, pathIndex, { onRawHtml } = {}) {
3488
+ async function processPath(pathSeg, pathIndex, { onRawHtml, attempt } = {}) {
3358
3489
  const is404 = pathSeg === NOT_FOUND_PATH;
3359
3490
  const pathname = is404 ? `/${NOT_FOUND_PATH}` : (pathSeg ? `/${pathSeg}` : '/');
3360
3491
  const displayPath = pathSeg === '' ? '/' : pathname;
@@ -3370,11 +3501,8 @@ async function runPrerender(config) {
3370
3501
  : defaultLocale || 'en'
3371
3502
  : defaultLocale || 'en';
3372
3503
 
3373
- // Wait for any in-progress browser recycle to complete before touching
3374
- // `browser`. This transparently handles the window between the old
3375
- // browser being closed and the new one being launched — workers block
3376
- // here instead of throwing "browser not ready".
3377
- await browserReadyPromise;
3504
+ // No recycle can run while the caller holds a gate slot, so `browser` is
3505
+ // live for the whole call.
3378
3506
  const page = await browser.newPage();
3379
3507
  // Render at a typical desktop viewport so layouts dependent on viewport
3380
3508
  // width (responsive flex/grid, container queries, media queries) settle
@@ -3388,6 +3516,9 @@ async function runPrerender(config) {
3388
3516
  // initializeDataSourcesPlugin picks locale from document.documentElement.lang first; a mismatch
3389
3517
  // (e.g. headless default vs /en/...) leaves $x.* empty while x-route sections still render.
3390
3518
  await page.evaluateOnNewDocument((locale) => {
3519
+ // Render pass: the defer plugin keeps closed containers eager so the
3520
+ // snapshot carries their markup (hydration stashes it again).
3521
+ window.__manifestRender = true;
3391
3522
  const apply = () => {
3392
3523
  try {
3393
3524
  if (locale && typeof locale === 'string') document.documentElement.lang = locale;
@@ -4532,6 +4663,7 @@ async function runPrerender(config) {
4532
4663
  liveUrl: (config.liveUrl || '').replace(/\/$/, ''),
4533
4664
  pathSeg: is404 ? '__404__' : pathSeg,
4534
4665
  snapshotUrl: earlySnapshotUrl,
4666
+ renderedPaths: pathList.filter((p) => p !== NOT_FOUND_PATH),
4535
4667
  });
4536
4668
 
4537
4669
  let html = await page.evaluate(() => document.documentElement.outerHTML);
@@ -4623,6 +4755,7 @@ async function runPrerender(config) {
4623
4755
  // (Hydration contract was already injected into the raw HTML before
4624
4756
  // the Node.js post-processing pipeline ran, so it's already present.)
4625
4757
  html = ensureDoctype(html);
4758
+ if (attempt?.abandoned) return; // a retry owns the output now
4626
4759
  mkdirSync(outDir, { recursive: true });
4627
4760
  writeFileSync(outFile, html, 'utf8');
4628
4761
  pushDebug({
@@ -4633,13 +4766,13 @@ async function runPrerender(config) {
4633
4766
  hasXForTemplate: html.includes('template x-for') || html.includes('template[x-for]'),
4634
4767
  });
4635
4768
  } catch (err) {
4636
- failedPaths.push({
4637
- path: displayPath,
4638
- message: err && err.message ? err.message : String(err)
4639
- });
4640
- if (failedPaths.length <= 10) {
4641
- process.stderr.write(`prerender: failed ${displayPath}: ${failedPaths[failedPaths.length - 1].message}\n`);
4642
- }
4769
+ if (attempt?.abandoned) return;
4770
+ const message = err && err.message ? err.message : String(err);
4771
+ // The worker owns retries and reporting; a shared array can't be popped
4772
+ // safely while other workers push to it.
4773
+ if (attempt) { attempt.failure = { path: displayPath, message }; return; }
4774
+ failedPaths.push({ path: displayPath, message });
4775
+ process.stderr.write(`prerender: failed ${displayPath}: ${message}\n`);
4643
4776
  } finally {
4644
4777
  try { await page.close(); } catch (_) { /* page may be gone if browser died */ }
4645
4778
  }
@@ -4649,131 +4782,84 @@ async function runPrerender(config) {
4649
4782
  // Any failures (e.g. transient navigation timeouts) are retried up to
4650
4783
  // `maxRetries` times with a short backoff before being reported as fatal.
4651
4784
  //
4652
- // Browser recycling: after every `browserRecycleEvery` successful pages,
4653
- // all workers pause, one worker closes the browser and launches a fresh
4654
- // one, then all resume. This bounds Chromium's memory + handle growth.
4785
+ // Browser recycling: after every `browserRecycleEvery` successful pages the
4786
+ // gate pauses new work, drains the in-flight pages, swaps the browser and
4787
+ // resumes. Workers hold a gate slot only while they touch the browser.
4655
4788
  try {
4656
4789
  let index = 0;
4657
- let activeWorkers = 0;
4658
- const recycleGate = { resume: null, waitForZero: null };
4659
-
4660
- const waitUntilZero = () => new Promise((resolve) => {
4661
- if (activeWorkers === 0) return resolve();
4662
- recycleGate.waitForZero = resolve;
4663
- });
4664
- const waitForResume = () => new Promise((resolve) => {
4665
- if (!recycleLock.busy) return resolve();
4666
- const prev = recycleGate.resume;
4667
- recycleGate.resume = () => { if (prev) prev(); resolve(); };
4668
- });
4669
-
4670
- const maybeRecycleBrowser = async () => {
4671
- if (browserRecycleEvery <= 0) return;
4672
- if (pagesSinceRecycle < browserRecycleEvery) return;
4673
- if (recycleLock.busy) return;
4674
- recycleLock.busy = true;
4675
- // Wait for all in-flight workers to finish their current page BEFORE
4676
- // we gate `browserReadyPromise`, so workers already mid-processPath
4677
- // don't deadlock awaiting a promise we haven't yet started.
4678
- await waitUntilZero();
4679
- // Now gate newPage() calls from any worker that enters processPath
4680
- // after this point.
4681
- let resolveReady;
4682
- browserReadyPromise = new Promise((r) => { resolveReady = r; });
4683
- try {
4684
- process.stdout.write(`prerender: recycling browser (processed ${pagesSinceRecycle} pages)\n`);
4685
- try { await browser.close(); } catch (_) {}
4686
- browser = await launchBrowser();
4687
- pagesSinceRecycle = 0;
4688
- } finally {
4689
- // Release the gate first so any waiting workers can proceed, then
4690
- // clear the recycle lock so the outer while loop stops pausing.
4691
- try { resolveReady(); } catch (_) {}
4692
- recycleLock.busy = false;
4693
- const r = recycleGate.resume;
4694
- recycleGate.resume = null;
4695
- if (r) r();
4696
- }
4697
- };
4698
4790
 
4699
4791
  async function worker() {
4700
4792
  while (true) {
4701
- // Pause if a recycle is underway.
4702
- if (recycleLock.busy) await waitForResume();
4703
- // Also wait for any pending browser readiness (e.g. another worker
4704
- // started a recycle while we were processing).
4705
- await browserReadyPromise;
4706
-
4707
4793
  const i = index++;
4708
4794
  if (i >= puppeteerPaths.length) return;
4709
4795
  const pathSeg = puppeteerPaths[i];
4796
+ const displayPath = pathSeg === '' ? '/' : (pathSeg === NOT_FOUND_PATH ? '/__prerender_404__' : '/' + pathSeg);
4710
4797
  let attempt = 0;
4711
4798
  while (true) {
4712
- // Re-check recycle state at the start of every retry iteration.
4713
- if (recycleLock.busy) await waitForResume();
4714
- await browserReadyPromise;
4715
-
4716
- const failureCountBefore = failedPaths.length;
4717
- activeWorkers++;
4799
+ // Per-attempt result: `failedPaths` is shared, so a retry must never
4800
+ // read or pop entries another worker pushed.
4801
+ const attemptToken = { abandoned: false, failure: null };
4802
+ await recycleGate.acquire();
4718
4803
  try {
4719
- await processPath(pathSeg, i, {
4720
- onRawHtml: (seg, html) => {
4721
- if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
4722
- },
4723
- });
4804
+ // Watchdog: a wedged renderer never returns, so cap the attempt.
4805
+ const finished = await withDeadline(
4806
+ processPath(pathSeg, i, {
4807
+ attempt: attemptToken,
4808
+ onRawHtml: (seg, html) => {
4809
+ if (seg !== NOT_FOUND_PATH) baseHtmlCache.set(seg || '', html);
4810
+ },
4811
+ }),
4812
+ pageTimeoutMs
4813
+ );
4814
+ if (!finished) {
4815
+ attemptToken.abandoned = true;
4816
+ attemptToken.failure = { path: displayPath, message: `page render exceeded ${pageTimeoutMs}ms` };
4817
+ }
4724
4818
  } catch (err) {
4725
- // Unexpected exception escaped processPath (e.g. browser died
4726
- // mid-call). Record as a failure so the retry logic can handle
4727
- // it gracefully instead of tearing down the whole worker.
4728
- failedPaths.push({
4729
- path: pathSeg === '' ? '/' : '/' + pathSeg,
4819
+ // Unexpected exception escaped processPath (e.g. browser died mid-call).
4820
+ attemptToken.failure = {
4821
+ path: displayPath,
4730
4822
  message: err && err.message ? err.message : String(err),
4731
- });
4732
- if (failedPaths.length <= 10) {
4733
- process.stderr.write(`prerender: worker exception on ${pathSeg || '/'}: ${failedPaths[failedPaths.length - 1].message}\n`);
4734
- }
4823
+ };
4735
4824
  } finally {
4736
- activeWorkers--;
4737
- if (activeWorkers === 0 && recycleGate.waitForZero) {
4738
- const z = recycleGate.waitForZero;
4739
- recycleGate.waitForZero = null;
4740
- z();
4741
- }
4825
+ recycleGate.release();
4742
4826
  }
4743
- if (failedPaths.length === failureCountBefore) {
4744
- pagesSinceRecycle++;
4827
+ if (!attemptToken.failure) {
4828
+ recycleGate.countPage();
4745
4829
  break; // success
4746
4830
  }
4747
4831
  if (attempt >= maxRetries) {
4748
4832
  // Exhausted retries — likely an unstable browser (e.g. cascading
4749
- // "detached Frame" errors). Force a recycle counter past the
4750
- // threshold so the next path triggers a fresh browser.
4751
- pagesSinceRecycle = Math.max(pagesSinceRecycle + 1, browserRecycleEvery);
4833
+ // "detached Frame" errors). Force the next path onto a fresh one.
4834
+ failedPaths.push(attemptToken.failure);
4835
+ if (failedPaths.length <= 10) {
4836
+ process.stderr.write(`prerender: failed ${displayPath}: ${attemptToken.failure.message}\n`);
4837
+ }
4838
+ recycleGate.countPage();
4839
+ recycleGate.requestRecycle();
4752
4840
  break;
4753
4841
  }
4754
4842
  // Halfway through retries with no success → preemptively recycle the
4755
4843
  // browser before the next attempt. This unblocks cascading frame
4756
4844
  // failures where the browser process needs a fresh start.
4757
- if (attempt + 1 >= Math.ceil(maxRetries / 2) && pagesSinceRecycle > 0) {
4758
- pagesSinceRecycle = Math.max(pagesSinceRecycle, browserRecycleEvery);
4759
- await maybeRecycleBrowser();
4845
+ if (attempt + 1 >= Math.ceil(maxRetries / 2) && recycleGate.pages > 0) {
4846
+ recycleGate.requestRecycle();
4847
+ await recycleGate.maybeRecycle();
4760
4848
  }
4761
- failedPaths.pop();
4762
4849
  attempt++;
4763
- const displayPath = pathSeg === '' ? '/' : (pathSeg === NOT_FOUND_PATH ? '/__prerender_404__' : '/' + pathSeg);
4764
- process.stderr.write(`prerender: retrying ${displayPath} (attempt ${attempt + 1}/${maxRetries + 1})\n`);
4850
+ process.stderr.write(`prerender: retrying ${displayPath} (${attemptToken.failure.message}) (attempt ${attempt + 1}/${maxRetries + 1})\n`);
4765
4851
  await new Promise((r) => setTimeout(r, 500 * attempt));
4766
4852
  }
4767
- // Attempt recycle after each completed path (only one worker will
4768
- // actually perform the recycle; others will be gated by recycleLock).
4769
- await maybeRecycleBrowser();
4853
+ // Attempt recycle after each completed path (only one worker performs
4854
+ // it; the rest block in acquire()).
4855
+ await recycleGate.maybeRecycle();
4770
4856
  }
4771
4857
  }
4772
4858
  await Promise.all(
4773
4859
  Array.from({ length: Math.min(concurrency, puppeteerPaths.length || 1) }, () => worker())
4774
4860
  );
4775
4861
  } finally {
4776
- try { await browser.close(); } catch (_) {}
4862
+ await closeBrowser(browser);
4777
4863
  }
4778
4864
 
4779
4865
  // Phase 2: Node.js — generate locale variants via text substitution
@@ -4842,7 +4928,7 @@ async function runPrerender(config) {
4842
4928
  } else {
4843
4929
  writeFileSync(join(outputResolved, 'prerender.utilities.css'), `${utilMerged}\n`, 'utf8');
4844
4930
  process.stdout.write('prerender: wrote prerender.utilities.css (Manifest custom utilities)\n');
4845
- postProcessInjectStylesheetLink(outputResolved, 'prerender.utilities.css', routerBasePath || '');
4931
+ postProcessInjectStylesheetLink(outputResolved, 'prerender.utilities.css', routerBasePath || '', 'data-mnfst-utilities');
4846
4932
  }
4847
4933
  }
4848
4934
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.38",
3
+ "version": "0.5.39",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {