@k2b/cloud 0.21.1 → 0.23.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.
Files changed (93) hide show
  1. package/package.json +4 -3
  2. package/scripts/README.md +1 -1
  3. package/scripts/build-canvas-workers.ts +58 -0
  4. package/scripts/build.ts +4 -5
  5. package/src/_internal/canvas-worker.ts +24 -0
  6. package/src/_internal/define-app.ts +81 -7
  7. package/src/_internal/page-responses.ts +19 -3
  8. package/src/_internal/registry-validation.ts +6 -0
  9. package/src/_internal/runtime-context.ts +1 -0
  10. package/src/_internal/static-assets.ts +18 -3
  11. package/src/access/ResourceApiKeys.tsx +30 -30
  12. package/src/ai/capability-execution.ts +5 -0
  13. package/src/ai/client/controller.ts +17 -3
  14. package/src/ai/client/transport.ts +47 -3
  15. package/src/ai/live-routes.ts +2 -2
  16. package/src/ai/pdf-render.ts +2 -22
  17. package/src/ai/routes.ts +5 -4
  18. package/src/ai/stream.ts +89 -65
  19. package/src/api/app-approval.ts +5 -1
  20. package/src/api/index.ts +3 -0
  21. package/src/api/me.ts +19 -5
  22. package/src/api/pwa-phone.ts +161 -0
  23. package/src/api/pwa.ts +179 -0
  24. package/src/browser/app-session.ts +69 -0
  25. package/src/browser/live-connection.ts +211 -0
  26. package/src/browser/live-websocket.ts +31 -11
  27. package/src/browser/live.ts +2 -0
  28. package/src/contracts/app.ts +10 -0
  29. package/src/contracts/index.ts +1 -0
  30. package/src/contracts/pwa-paths.ts +6 -0
  31. package/src/contracts/pwa.ts +156 -0
  32. package/src/contracts/registry.ts +7 -0
  33. package/src/contracts/shared.ts +7 -1
  34. package/src/events/index.ts +2 -0
  35. package/src/events/live-engine.ts +826 -0
  36. package/src/events/live-protocol.ts +31 -0
  37. package/src/events/live.ts +298 -0
  38. package/src/server/actor.ts +7 -0
  39. package/src/server/api-client.ts +23 -2
  40. package/src/server/index.ts +3 -1
  41. package/src/server/middleware/auth.ts +80 -8
  42. package/src/server/middleware/openapi.ts +2 -1
  43. package/src/server/services/access.ts +49 -0
  44. package/src/server/services/index.ts +1 -0
  45. package/src/services/app-approval.ts +6 -25
  46. package/src/services/audit/index.ts +2 -0
  47. package/src/services/branding/app-icon-source.ts +40 -0
  48. package/src/services/branding/app-icons.ts +112 -0
  49. package/src/services/branding/icon-render-worker.ts +69 -0
  50. package/src/services/branding/icon-svg.ts +22 -0
  51. package/src/services/identity/invocation-actor.ts +8 -5
  52. package/src/services/identity/invocation-authority.ts +1 -0
  53. package/src/services/identity/invocation-token.ts +6 -0
  54. package/src/services/index.ts +1 -0
  55. package/src/services/outbox.ts +150 -30
  56. package/src/services/pairing-secret.ts +16 -0
  57. package/src/services/pwa-devices.ts +574 -0
  58. package/src/services/session/index.ts +212 -57
  59. package/src/services/session/recent.ts +42 -0
  60. package/src/services/session/user.ts +15 -5
  61. package/src/shared/index.ts +1 -0
  62. package/src/{browser → shared}/locale-preference.ts +1 -1
  63. package/src/shared/markdown/extensions/info-blocks.ts +18 -47
  64. package/src/shared/markdown/index.ts +5 -14
  65. package/src/ssr/AppLaunchpad.island.tsx +8 -1
  66. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  67. package/src/ssr/GlobalAnnouncements.island.tsx +9 -34
  68. package/src/ssr/Layout.tsx +10 -12
  69. package/src/ssr/LayoutHeader.tsx +14 -2
  70. package/src/ssr/LayoutHelp.tsx +2 -4
  71. package/src/ssr/LayoutRail.tsx +13 -4
  72. package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
  73. package/src/ssr/MobileProfileActions.tsx +19 -13
  74. package/src/ssr/PageError.tsx +23 -1
  75. package/src/ssr/ProfilePreferences.island.tsx +9 -1
  76. package/src/ssr/PwaLayout.tsx +69 -0
  77. package/src/ssr/PwaRuntime.island.tsx +105 -0
  78. package/src/ssr/TimezoneCookie.island.tsx +7 -1
  79. package/src/ssr/app-navigation.ts +25 -10
  80. package/src/ssr/index.ts +8 -1
  81. package/src/ssr/layout-context.ts +1 -1
  82. package/src/ssr/preference-controller.ts +2 -2
  83. package/src/ssr/profile-actions.ts +3 -1
  84. package/src/ssr/profile-preferences-messages.ts +2 -0
  85. package/src/ssr/pwa-messages.ts +24 -0
  86. package/src/styles/effects.css +6 -30
  87. package/src/styles/global.css +11 -0
  88. package/src/styles/tokens.css +5 -1
  89. package/src/styles/utilities-feedback.css +15 -5
  90. package/src/styles/utilities-layout.css +4 -0
  91. package/src/styles/utilities-markdown-editor.css +20 -2
  92. package/src/styles/utilities-markdown-table.css +35 -17
  93. package/scripts/build-pdf-renderer.ts +0 -39
@@ -6,7 +6,7 @@ import type { RuntimeContext } from "./runtime";
6
6
  export type LayoutContext = {
7
7
  get(key: "user"): User | undefined;
8
8
  get(key: "railPreferences"): RailSnapshot | undefined;
9
- get(key: "page"): { theme?: "light" | "dark" };
9
+ get(key: "page"): { theme?: "light" | "dark"; title?: string; pwa?: { name: string } };
10
10
  get(key: "runtime"): RuntimeContext;
11
11
  get(key: "announcements"): LayoutAnnouncementsState | undefined;
12
12
  /** Every application settings snapshot includes Core's app metadata. */
@@ -1,6 +1,6 @@
1
1
  import type { DropdownItem } from "@k2b/ui";
2
2
  import { createMemo, createSignal, onCleanup, onMount } from "solid-js";
3
- import { profilePreferenceLocale, setLocalePreference } from "../browser/locale-preference";
3
+ import { profilePreferenceLocale, setLocalePreference } from "../shared/locale-preference";
4
4
  import { type CloudTheme, getCurrentThemePreference, setThemePreference } from "../shared/theme";
5
5
  import { profilePreferencesMessages } from "./profile-preferences-messages";
6
6
 
@@ -40,5 +40,5 @@ export const createPreferenceController = (initialTheme: CloudTheme, locale: ()
40
40
  },
41
41
  ]);
42
42
 
43
- return { items, languageLabel, messages, theme, themeLabel, toggleLanguage, toggleTheme };
43
+ return { items, language, languageLabel, messages, theme, themeLabel, toggleLanguage, toggleTheme };
44
44
  };
@@ -5,7 +5,8 @@ import { apiClient } from "../clients/core";
5
5
  import type { CloudTheme } from "../shared/theme";
6
6
  import { createPreferenceController } from "./preference-controller";
7
7
 
8
- export function createProfileActions(initialTheme: CloudTheme) {
8
+ /** `appHref` is set while the installation offers the mobile app; the item then leads to `/me/app`. */
9
+ export function createProfileActions(initialTheme: CloudTheme, appHref?: string) {
9
10
  const locale = useLocale();
10
11
  const preferences = createPreferenceController(initialTheme, locale);
11
12
  const [signingOut, setSigningOut] = createSignal(false);
@@ -30,6 +31,7 @@ export function createProfileActions(initialTheme: CloudTheme) {
30
31
  icon: "ti ti-user-circle",
31
32
  label: preferences.messages().profileSettings,
32
33
  },
34
+ ...(appHref ? [{ href: appHref, icon: "ti ti-device-mobile", label: preferences.messages().app }] : []),
33
35
  {
34
36
  action: signOut,
35
37
  icon: "ti ti-logout",
@@ -9,6 +9,7 @@ export const profilePreferencesMessages = i18n.define({
9
9
  language: "Language",
10
10
  legalLinks: "Legal",
11
11
  profileSettings: "Profile settings",
12
+ app: "App",
12
13
  signOut: "Sign out",
13
14
  signOutFailed: "Sign out failed. Please try again.",
14
15
  switchToDark: "Dark mode",
@@ -22,6 +23,7 @@ export const profilePreferencesMessages = i18n.define({
22
23
  language: "Sprache",
23
24
  legalLinks: "Rechtliches",
24
25
  profileSettings: "Profileinstellungen",
26
+ app: "App",
25
27
  signOut: "Abmelden",
26
28
  signOutFailed: "Abmelden fehlgeschlagen. Bitte versuche es erneut.",
27
29
  switchToDark: "Dunkler Modus",
@@ -0,0 +1,24 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+
3
+ /** Strings of the mobile app's frame: the tab bar, the offline notice and the account notice. */
4
+ export const pwaMessages = i18n.define({
5
+ baseLocale: "en",
6
+ messages: {
7
+ en: {
8
+ tabs: "App",
9
+ start: "Start",
10
+ offline: "You're offline",
11
+ otherAccount: ({ cloud, name }: { cloud: string; name: string }) =>
12
+ `Chrome on this phone is signed in to ${cloud} as ${name}. Changes in the app may be saved to that account. Sign out of ${cloud} in Chrome.`,
13
+ errorAction: "Back to Start",
14
+ },
15
+ de: {
16
+ tabs: "App",
17
+ start: "Start",
18
+ offline: "Du bist offline",
19
+ otherAccount: ({ cloud, name }: { cloud: string; name: string }) =>
20
+ `Chrome auf diesem Telefon ist bei ${cloud} als ${name} angemeldet. Änderungen in der App werden womöglich in diesem Konto gespeichert. Melde dich in Chrome von ${cloud} ab.`,
21
+ errorAction: "Zurück zum Start",
22
+ },
23
+ },
24
+ });
@@ -514,12 +514,10 @@
514
514
  display: none;
515
515
  }
516
516
 
517
+ /* The fill without a frame comes from the shared `.md-code-block pre` rule;
518
+ Help keeps its own spacing and syntax colours. */
517
519
  .help-document .md-code-block pre {
518
520
  margin: 0;
519
- border: 0 !important;
520
- border-radius: var(--ui-radius-surface) !important;
521
- background: var(--ui-surface-subtle) !important;
522
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ui-border) 72%, transparent);
523
521
  padding: 0.875rem 1rem;
524
522
  }
525
523
 
@@ -602,15 +600,6 @@
602
600
  padding: 0;
603
601
  }
604
602
 
605
- .help-document :where(blockquote) {
606
- margin: 1.25rem 0;
607
- border: 0;
608
- border-radius: var(--ui-radius-surface);
609
- background: var(--ui-surface-subtle);
610
- color: var(--color-zinc-600);
611
- padding: 0.875rem 1rem;
612
- }
613
-
614
603
  .help-document .md-table-wrap {
615
604
  overflow: hidden;
616
605
  margin-block: 0.875rem 1.5rem;
@@ -623,13 +612,11 @@
623
612
  border-spacing: 0;
624
613
  }
625
614
 
626
- /* `MarkdownView` contributes its generic cell border with element specificity.
627
- Keep the Help frame as the only edge owner so those cell borders cannot show
628
- through the wrapper's rounded clipping corners. */
615
+ /* Help renders inside `MarkdownView`, which draws the table's row lines. The
616
+ inner `.md-table-cell` owns the padding. */
629
617
  .help-document .md-table :is(th, td) {
630
618
  min-width: 0;
631
619
  height: auto;
632
- border: 0;
633
620
  padding: 0;
634
621
  }
635
622
 
@@ -651,10 +638,6 @@
651
638
  word-break: normal;
652
639
  }
653
640
 
654
- .help-document .md-table thead {
655
- background: var(--ui-surface-subtle);
656
- }
657
-
658
641
  .help-document .md-table thead .md-table-cell {
659
642
  color: var(--color-zinc-700);
660
643
  font-size: 0.75rem;
@@ -672,7 +655,8 @@
672
655
  font-weight: 400;
673
656
  }
674
657
 
675
- .help-document .md-table-cell :where(code) {
658
+ /* Element specificity, so it outranks MarkdownView's inline-code fill. */
659
+ .help-document .md-table-cell code {
676
660
  border-radius: 0;
677
661
  background: transparent;
678
662
  color: inherit;
@@ -681,18 +665,10 @@
681
665
  padding: 0;
682
666
  }
683
667
 
684
- .dark .help-document .md-table thead {
685
- background: var(--ui-surface-subtle);
686
- }
687
-
688
668
  .dark .help-document .md-table thead .md-table-cell {
689
669
  color: var(--color-zinc-200);
690
670
  }
691
671
 
692
- .dark .help-document :where(blockquote) {
693
- color: var(--color-zinc-400);
694
- }
695
-
696
672
  .help-document :where(hr) {
697
673
  height: 0;
698
674
  margin-block: 2rem;
@@ -239,6 +239,12 @@ body {
239
239
  color: rgb(244 244 245); /* zinc-100 */
240
240
  }
241
241
 
242
+ /* Pages of the mobile app paint the shell canvas, which their theme-color and
243
+ html background repeat for the status bar. */
244
+ html:has(.k2b-mobile-shell) body {
245
+ background-color: var(--k2b-surface-canvas);
246
+ }
247
+
242
248
  .dark {
243
249
  color-scheme: dark;
244
250
  --color-selection-bg: rgb(59 130 246 / 0.5);
@@ -294,6 +300,11 @@ solid-island {
294
300
  border-radius: 0; border: 0; border-bottom: 1px solid var(--ui-border);
295
301
  box-shadow: none !important;
296
302
  }
303
+ /* Toasts at the top edge of a phone start below the header instead of
304
+ covering its menu and search buttons. */
305
+ :root:has(.cloud-app-canvas .layout-header) {
306
+ --k2b-toast-offset-top: 2.875rem;
307
+ }
297
308
 
298
309
  }
299
310
 
@@ -7,6 +7,9 @@
7
7
  --ui-surface-subtle: rgb(248 248 248 / 0.96);
8
8
  --ui-surface-muted: rgb(243 244 243 / 0.96);
9
9
  --ui-surface-raised: rgb(255 255 255 / 0.99);
10
+ /* Dimmed text stays at WCAG AA on the muted surfaces it often sits on, such
11
+ as dialog section groups, not only on the plain surface. */
12
+ --ui-text-dimmed: color-mix(in srgb, var(--color-zinc-500) 80%, var(--color-zinc-600));
10
13
  --ui-border: rgb(218 220 219 / 0.68);
11
14
  --ui-border-strong: rgb(207 210 208 / 0.9);
12
15
  --ui-hover: rgb(239 240 239 / 0.9);
@@ -135,6 +138,7 @@
135
138
  --ui-surface-subtle: color-mix(in srgb, var(--ui-surface) 68%, var(--ui-surface-muted));
136
139
  --ui-surface-muted: #20242a;
137
140
  --ui-surface-raised: #181d24;
141
+ --ui-text-dimmed: color-mix(in srgb, var(--color-zinc-500) 40%, var(--color-zinc-400));
138
142
  --ui-border: #29313a;
139
143
  --ui-border-strong: #3a4552;
140
144
  --ui-hover: #252c35;
@@ -222,7 +226,7 @@
222
226
  }
223
227
 
224
228
  @utility text-dimmed {
225
- @apply text-zinc-500 dark:text-zinc-500;
229
+ color: var(--ui-text-dimmed);
226
230
  }
227
231
 
228
232
  @utility text-label {
@@ -224,11 +224,21 @@
224
224
  }
225
225
  }
226
226
 
227
- #ui-toast-container > div {
228
- border: 1px solid var(--ui-border-strong);
229
- border-radius: var(--ui-radius-surface);
230
- background: var(--ui-surface-raised);
231
- box-shadow: var(--ui-shadow-float) !important;
227
+ /* Operator banners: a NoticeCard with its close button in the top corner. */
228
+ .k2b-ui .cloud-announcement {
229
+ position: relative;
230
+ }
231
+ .k2b-ui .cloud-announcement .k2b-notice-card__content {
232
+ padding-right: 1.75rem;
233
+ }
234
+ .k2b-ui .cloud-announcement .k2b-content-markdown {
235
+ font-size: inherit;
236
+ line-height: inherit;
237
+ }
238
+ .k2b-ui .cloud-announcement > .k2b-icon-button {
239
+ position: absolute;
240
+ top: 0.875rem;
241
+ right: 0.875rem;
232
242
  }
233
243
 
234
244
  /* Approval prose follows the surrounding review fields, not document typography. */
@@ -22,6 +22,10 @@
22
22
  background: var(--ui-surface);
23
23
  /* Shared surface depth stays consistent with the Layout header. */
24
24
  box-shadow: var(--ui-shadow-surface);
25
+ /* A paper card inside a dialog section group paints its own surface, so its
26
+ fields return to the muted well instead of the group's white one. */
27
+ --k2b-field-surface: initial;
28
+ --k2b-field-hover: initial;
25
29
  }
26
30
 
27
31
  @utility dialog-panel {
@@ -431,6 +431,12 @@
431
431
  color: var(--ui-app-accent-text);
432
432
  }
433
433
 
434
+ /* The brackets, weight and icon already mark the widget as a link, so it
435
+ keeps no underline inside MarkdownView, the same as in Assistant chat. */
436
+ .k2b-ui .k2b-content-markdown a.md-link-widget {
437
+ text-decoration: none;
438
+ }
439
+
434
440
  .md-link-widget:focus-visible {
435
441
  border-radius: 0.125rem;
436
442
  outline: var(--k2b-focus-width) solid var(--ui-app-accent-border);
@@ -464,7 +470,20 @@
464
470
  @apply bg-zinc-200/40 dark:bg-zinc-800;
465
471
  }
466
472
 
467
- .md-code-block pre,
473
+ /* Rendered code blocks match MarkdownView: a fill without a frame that
474
+ follows `--k2b-field-surface`, so it stays visible on a tinted group. The
475
+ transparent border keeps the edge visible in forced colours. The rendered
476
+ wrapper shares the editor's line class, so it drops the line fill. */
477
+ .md-code-block:has(> pre) {
478
+ background: transparent;
479
+ }
480
+
481
+ .md-code-block pre {
482
+ border: 1px solid transparent !important;
483
+ border-radius: var(--ui-radius-control) !important;
484
+ background: var(--k2b-field-surface, var(--ui-surface-muted)) !important;
485
+ }
486
+
468
487
  .md-mermaid-block,
469
488
  .md-katex-block {
470
489
  border: 1px solid rgb(228 228 231 / 0.72) !important;
@@ -479,7 +498,6 @@
479
498
  padding: 0.75rem;
480
499
  }
481
500
 
482
- .dark .md-code-block pre,
483
501
  .dark .md-mermaid-block,
484
502
  .dark .md-katex-block {
485
503
  border-color: rgb(63 63 70 / 0.72) !important;
@@ -1,7 +1,8 @@
1
- /* Markdown tables use the same quiet visual language as DataTable: one
2
- contained surface, one header band, and hairline row separators. Used by
3
- both the server-side `marked` renderer and the CodeMirror table widget so
4
- read-mode and edit-mode look identical.
1
+ /* Markdown tables use the same hairline language as @k2b/ui MarkdownView:
2
+ no frame or header fill, a stronger line under the header, hairline row
3
+ separators, and the outer columns flush with the prose. Used by both the
4
+ server-side `marked` renderer and the CodeMirror table widget so read-mode
5
+ and edit-mode look identical.
5
6
 
6
7
  Three-layer markup:
7
8
  <div class="md-table-wrap"> ← horizontal scroll owner
@@ -13,10 +14,13 @@
13
14
  overflow: clip;
14
15
  margin-top: 0.5rem;
15
16
  margin-bottom: 0.5rem;
16
- border: 1px solid var(--ui-border);
17
- border-radius: var(--ui-radius-surface);
18
- background: var(--ui-surface);
19
- box-shadow: var(--ui-shadow-surface);
17
+ }
18
+
19
+ /* Inside MarkdownView a table takes the same block spacing as its own
20
+ tables, so two tables in a row do not read as one. Hosts such as Help keep
21
+ their own spacing. */
22
+ :where(.k2b-ui .k2b-content-markdown) .md-table-wrap {
23
+ margin-block: 1em;
20
24
  }
21
25
 
22
26
  .md-table {
@@ -39,12 +43,8 @@
39
43
  border: 0;
40
44
  }
41
45
 
42
- .md-table thead {
43
- background: var(--ui-data-header);
44
- }
45
-
46
46
  .md-table thead tr {
47
- border-bottom: 1px solid var(--ui-data-divider);
47
+ border-bottom: 1px solid var(--ui-border-strong);
48
48
  }
49
49
 
50
50
  .md-table tbody tr:not(:last-child) {
@@ -87,12 +87,29 @@
87
87
  color: inherit;
88
88
  }
89
89
 
90
- /* Headers inherit the single shared band instead of drawing per-cell fills. */
91
90
  .md-table thead .md-table-cell {
91
+ padding-top: 0;
92
92
  background: transparent;
93
93
  font-weight: 600;
94
94
  }
95
95
 
96
+ .md-table th:first-child > .md-table-cell,
97
+ .md-table td:first-child > .md-table-cell {
98
+ padding-left: 0;
99
+ }
100
+
101
+ .md-table th:last-child > .md-table-cell,
102
+ .md-table td:last-child > .md-table-cell {
103
+ padding-right: 0;
104
+ }
105
+
106
+ /* Inside MarkdownView the span stays the only padding owner. */
107
+ .k2b-ui .k2b-content-markdown .md-table th,
108
+ .k2b-ui .k2b-content-markdown .md-table td {
109
+ padding: 0;
110
+ font-size: inherit;
111
+ }
112
+
96
113
  .dark .md-table thead .md-table-cell {
97
114
  background: transparent;
98
115
  }
@@ -109,9 +126,10 @@
109
126
  justify-content: center;
110
127
  }
111
128
 
112
- /* Summary rows use the same quiet band as a DataTable footer. */
113
- .md-table tbody tr.md-table-total-row {
114
- background: var(--ui-data-header);
129
+ /* A summary row sits under the same stronger line as the header. The line is
130
+ on the cells, so it also wins over a row separator or MarkdownView's. */
131
+ .k2b-ui .md-table tbody tr.md-table-total-row > td {
132
+ border-top: 1px solid var(--ui-border-strong);
115
133
  }
116
134
 
117
135
  .md-table tbody tr.md-table-total-row .md-table-cell {
@@ -1,39 +0,0 @@
1
- import { cp, mkdir } from "node:fs/promises";
2
- import { dirname, resolve } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import { z } from "zod";
5
-
6
- /** Package the decoder and native assets only for bundles importing PDF vision. */
7
- export async function buildPdfRenderer(dist: string) {
8
- const framework = resolve(dirname(fileURLToPath(import.meta.url)), "..");
9
- const out = resolve(dist, "pdf-render");
10
- await mkdir(out, { recursive: true });
11
- const worker = await Bun.build({
12
- entrypoints: [resolve(framework, "src/ai/pdf-render-worker.ts")],
13
- target: "bun",
14
- outdir: out,
15
- naming: "worker.js",
16
- external: ["pdfjs-dist", "@napi-rs/canvas"],
17
- });
18
- if (!worker.success) throw new Error(`PDF decoder build failed: ${worker.logs.join("\n")}`);
19
- for (const name of ["pdfjs-dist", "@napi-rs/canvas"]) {
20
- const source = dirname(Bun.resolveSync(`${name}/package.json`, framework));
21
- await cp(source, resolve(out, "node_modules", name), { recursive: true, dereference: true });
22
- if (name !== "@napi-rs/canvas") continue;
23
- const manifest = z
24
- .object({ optionalDependencies: z.record(z.string(), z.string()) })
25
- .parse(await Bun.file(resolve(source, "package.json")).json());
26
- let nativePackages = 0;
27
- for (const dependency of Object.keys(manifest.optionalDependencies)) {
28
- let location: string;
29
- try {
30
- location = dirname(Bun.resolveSync(`${dependency}/package.json`, source));
31
- } catch {
32
- continue;
33
- } // Only the installed target-platform binary is needed.
34
- await cp(location, resolve(out, "node_modules", dependency), { recursive: true, dereference: true });
35
- nativePackages++;
36
- }
37
- if (!nativePackages) throw new Error("PDF decoder needs an installed native canvas binary for the build platform.");
38
- }
39
- }