@forgecart/cli 2.202608121449.0 → 2.202608200713.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 (63) hide show
  1. package/dist/src/commands/init.d.ts +16 -0
  2. package/dist/src/commands/init.js +18 -7
  3. package/dist/src/commands/init.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/storefront/README.md +145 -61
  6. package/templates/storefront/next.config.js +20 -0
  7. package/templates/storefront/package.json +10 -2
  8. package/templates/storefront/postcss.config.js +1 -2
  9. package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +30 -12
  10. package/templates/storefront/src/app/__forge_beacon/route.ts +1 -2
  11. package/templates/storefront/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  12. package/templates/storefront/src/app/cart/page.tsx +33 -6
  13. package/templates/storefront/src/app/checkout/page.tsx +40 -0
  14. package/templates/storefront/src/app/error.tsx +21 -0
  15. package/templates/storefront/src/app/global-error.tsx +23 -0
  16. package/templates/storefront/src/app/globals.css +105 -8
  17. package/templates/storefront/src/app/layout.tsx +45 -17
  18. package/templates/storefront/src/app/page.tsx +153 -43
  19. package/templates/storefront/src/app/ping/route.ts +1 -2
  20. package/templates/storefront/src/app/products/[slug]/not-found.tsx +3 -8
  21. package/templates/storefront/src/app/products/[slug]/page.tsx +69 -23
  22. package/templates/storefront/src/app/products/page.tsx +52 -9
  23. package/templates/storefront/src/components/CartView.tsx +244 -117
  24. package/templates/storefront/src/components/ForgeTracker.tsx +131 -26
  25. package/templates/storefront/src/components/Header.tsx +8 -10
  26. package/templates/storefront/src/components/ProductCard.tsx +25 -13
  27. package/templates/storefront/src/components/ProductPurchase.tsx +13 -18
  28. package/templates/storefront/src/components/checkout/AddressStep.tsx +288 -0
  29. package/templates/storefront/src/components/checkout/CheckoutFlow.tsx +543 -0
  30. package/templates/storefront/src/components/checkout/CheckoutGate.tsx +45 -0
  31. package/templates/storefront/src/components/checkout/PaymentElementForm.tsx +138 -0
  32. package/templates/storefront/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  33. package/templates/storefront/src/components/checkout/RatesStep.tsx +113 -0
  34. package/templates/storefront/src/instrumentation.ts +34 -0
  35. package/templates/storefront/src/lib/action-result.ts +30 -0
  36. package/templates/storefront/src/lib/backend-actions.ts +20 -0
  37. package/templates/storefront/src/lib/backend-client.ts +47 -0
  38. package/templates/storefront/src/lib/cart-context.tsx +157 -22
  39. package/templates/storefront/src/lib/checkout-session.ts +185 -0
  40. package/templates/storefront/src/lib/error-messages.ts +24 -0
  41. package/templates/storefront/src/lib/experiments.ts +42 -44
  42. package/templates/storefront/src/lib/forgecart.ts +61 -78
  43. package/templates/storefront/src/lib/format.ts +91 -0
  44. package/templates/storefront/src/lib/session-actions.ts +54 -0
  45. package/templates/storefront/src/lib/shop-config.ts +44 -0
  46. package/templates/storefront/src/lib/shop-session.ts +114 -0
  47. package/templates/storefront/src/lib/track-forward.ts +14 -1
  48. package/templates/storefront/src/lib/uuid.ts +19 -0
  49. package/templates/storefront/src/server/app.module.ts +18 -0
  50. package/templates/storefront/src/server/backend-api.ts +26 -0
  51. package/templates/storefront/src/server/backend-method.decorator.ts +23 -0
  52. package/templates/storefront/src/server/bootstrap.ts +122 -0
  53. package/templates/storefront/src/server/customer-extras/customer-extras.module.ts +13 -0
  54. package/templates/storefront/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  55. package/templates/storefront/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  56. package/templates/storefront/src/server/forgecart/forgecart-client.factory.ts +69 -0
  57. package/templates/storefront/src/server/forgecart/forgecart.module.ts +9 -0
  58. package/templates/storefront/src/server/runner.ts +91 -0
  59. package/templates/storefront/src/server/types.ts +36 -0
  60. package/templates/storefront/tsconfig.json +2 -0
  61. package/templates/storefront/.env.example +0 -12
  62. package/templates/storefront/src/lib/cart-actions.ts +0 -139
  63. package/templates/storefront/tailwind.config.js +0 -8
@@ -0,0 +1,138 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ /**
6
+ * In-page Stripe Payment Element — the direct-render adapter for
7
+ * Stripe-model providers (operator decision 2026-08-01: our own payment
8
+ * chrome is never iframed; the srcdoc embed provided no security boundary —
9
+ * it is same-origin, and PCI isolation comes from Stripe's OWN nested
10
+ * input iframes, which apply identically here). The page owns the button
11
+ * and all visible chrome, so the payment step inherits the design system;
12
+ * only the card inputs are Stripe's.
13
+ *
14
+ * Everything this component needs arrives as data from the shop API
15
+ * (`getSessionVariables` + the created session's secret) — no keys and no
16
+ * provider configuration live in the template source. Providers without an
17
+ * in-page adapter keep the hosted-template embed (`PaymentFormEmbed`).
18
+ */
19
+
20
+ /** Minimal structural surface of Stripe.js v3 — the template ships no SDK dep. */
21
+ interface StripeElementsHandle {
22
+ create: (kind: 'payment') => { mount: (node: HTMLElement) => void; unmount: () => void };
23
+ }
24
+ interface StripeHandle {
25
+ elements: (options: { clientSecret: string }) => StripeElementsHandle;
26
+ confirmPayment: (options: {
27
+ elements: StripeElementsHandle;
28
+ redirect: 'if_required';
29
+ }) => Promise<{
30
+ error?: { message?: string };
31
+ paymentIntent?: { id: string; status: string };
32
+ }>;
33
+ }
34
+ declare global {
35
+ interface Window {
36
+ Stripe?: (publishableKey: string) => StripeHandle;
37
+ }
38
+ }
39
+
40
+ /** Load the provider script once; concurrent callers share the same promise. */
41
+ let scriptPromise: Promise<void> | null = null;
42
+ function loadProviderScript(scriptUrl: string): Promise<void> {
43
+ if (window.Stripe) return Promise.resolve();
44
+ scriptPromise ??= new Promise<void>((resolve, reject) => {
45
+ const script = document.createElement('script');
46
+ script.src = scriptUrl;
47
+ script.async = true;
48
+ script.onload = () => resolve();
49
+ script.onerror = () => {
50
+ scriptPromise = null;
51
+ reject(new Error('Payment provider script failed to load'));
52
+ };
53
+ document.head.appendChild(script);
54
+ });
55
+ return scriptPromise;
56
+ }
57
+
58
+ export function PaymentElementForm({
59
+ publishableKey,
60
+ scriptUrl,
61
+ clientSecret,
62
+ payLabel,
63
+ onSuccess,
64
+ onError,
65
+ }: {
66
+ publishableKey: string;
67
+ scriptUrl: string;
68
+ clientSecret: string;
69
+ /** Themed button label, e.g. `Pay €42.00` — the PAGE owns the chrome. */
70
+ payLabel: string;
71
+ onSuccess: (status: string, paymentIntentId: string) => void;
72
+ onError: (message: string) => void;
73
+ }) {
74
+ const mountRef = useRef<HTMLDivElement | null>(null);
75
+ const stripeRef = useRef<StripeHandle | null>(null);
76
+ const elementsRef = useRef<StripeElementsHandle | null>(null);
77
+ const [mounted, setMounted] = useState(false);
78
+ const [paying, setPaying] = useState(false);
79
+ // Callbacks ride a ref so the mount effect binds once per secret.
80
+ const handlersRef = useRef({ onError });
81
+ handlersRef.current = { onError };
82
+
83
+ useEffect(() => {
84
+ let cancelled = false;
85
+ let element: { unmount: () => void } | null = null;
86
+ void loadProviderScript(scriptUrl)
87
+ .then(() => {
88
+ if (cancelled || !window.Stripe || !mountRef.current) return;
89
+ const stripe = window.Stripe(publishableKey);
90
+ const elements = stripe.elements({ clientSecret });
91
+ const paymentElement = elements.create('payment');
92
+ paymentElement.mount(mountRef.current);
93
+ stripeRef.current = stripe;
94
+ elementsRef.current = elements;
95
+ element = paymentElement;
96
+ setMounted(true);
97
+ })
98
+ .catch((error: Error) => {
99
+ if (!cancelled) handlersRef.current.onError(error.message);
100
+ });
101
+ return () => {
102
+ cancelled = true;
103
+ element?.unmount();
104
+ };
105
+ }, [scriptUrl, publishableKey, clientSecret]);
106
+
107
+ async function pay(): Promise<void> {
108
+ const stripe = stripeRef.current;
109
+ const elements = elementsRef.current;
110
+ if (!stripe || !elements || paying) return;
111
+ setPaying(true);
112
+ const result = await stripe.confirmPayment({ elements, redirect: 'if_required' });
113
+ setPaying(false);
114
+ if (result.error) {
115
+ onError(result.error.message ?? 'Payment failed');
116
+ return;
117
+ }
118
+ if (result.paymentIntent) {
119
+ onSuccess(result.paymentIntent.status, result.paymentIntent.id);
120
+ }
121
+ }
122
+
123
+ return (
124
+ <div className="space-y-4">
125
+ {!mounted && <p className="text-sm text-base-content/60">Loading payment form…</p>}
126
+ <div ref={mountRef} />
127
+ <button
128
+ type="button"
129
+ onClick={() => void pay()}
130
+ disabled={!mounted || paying}
131
+ data-fc-track="checkout-pay"
132
+ className="btn btn-primary btn-block"
133
+ >
134
+ {paying ? 'Processing…' : payLabel}
135
+ </button>
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,89 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ /**
6
+ * Provider-agnostic host for the shop API's hosted payment form
7
+ * (`PaymentFormTemplate.html` — Stripe, Mollie, … the template carries all
8
+ * provider code; this component contains none).
9
+ *
10
+ * The handshake is the #865 contract, extended with in-band secret delivery:
11
+ * the hosted form posts `iframe-ready`, `payment-success` and
12
+ * `payment-error` to its embedder, and the embedder answers `iframe-ready`
13
+ * by posting `client-secret` back INTO the frame — the session's secret
14
+ * never rides the rendered HTML (which may be logged or cached
15
+ * server-side); the provider template mounts its payment element only once
16
+ * the secret arrives. Every message in both directions is pinned to the
17
+ * page's own origin: the srcdoc iframe is same-origin with this page (no
18
+ * sandbox), so legitimate traffic carries `window.location.origin` — any
19
+ * other origin (another iframe, an extension, a foreign embed) is ignored.
20
+ */
21
+ export interface PaymentFormMessage {
22
+ type: 'iframe-ready' | 'payment-success' | 'payment-error';
23
+ error?: string;
24
+ paymentIntentId?: string;
25
+ status?: string;
26
+ }
27
+
28
+ export function PaymentFormEmbed({
29
+ html,
30
+ clientSecret,
31
+ onSuccess,
32
+ onError,
33
+ }: {
34
+ html: string;
35
+ /** The payment session's secret, delivered to the frame post-`iframe-ready`. */
36
+ clientSecret?: string;
37
+ onSuccess: (message: PaymentFormMessage) => void;
38
+ onError: (message: string) => void;
39
+ }) {
40
+ const [ready, setReady] = useState(false);
41
+ const frameRef = useRef<HTMLIFrameElement | null>(null);
42
+ // Callbacks ride a ref so the message listener binds once per html.
43
+ const handlersRef = useRef({ onSuccess, onError });
44
+ handlersRef.current = { onSuccess, onError };
45
+
46
+ useEffect(() => {
47
+ function onMessage(event: MessageEvent<PaymentFormMessage>): void {
48
+ if (event.origin !== window.location.origin) return;
49
+ const data = event.data;
50
+ if (!data || typeof data !== 'object') return;
51
+ if (data.type === 'iframe-ready') {
52
+ setReady(true);
53
+ return;
54
+ }
55
+ if (data.type === 'payment-success') {
56
+ handlersRef.current.onSuccess(data);
57
+ return;
58
+ }
59
+ if (data.type === 'payment-error') {
60
+ handlersRef.current.onError(data.error ?? 'Payment failed');
61
+ }
62
+ }
63
+ window.addEventListener('message', onMessage);
64
+ return () => window.removeEventListener('message', onMessage);
65
+ }, [html]);
66
+
67
+ // Deliver the secret once the frame is listening. Embedded-confirm
68
+ // templates (Stripe's Payment Element) mount on receipt; templates with no
69
+ // client-side secret (redirect models) simply never consume the message.
70
+ useEffect(() => {
71
+ if (!ready || !clientSecret) return;
72
+ frameRef.current?.contentWindow?.postMessage(
73
+ { type: 'client-secret', clientSecret },
74
+ window.location.origin,
75
+ );
76
+ }, [ready, clientSecret]);
77
+
78
+ return (
79
+ <div className="space-y-2">
80
+ {!ready && <p className="text-sm text-base-content/60">Loading payment form…</p>}
81
+ <iframe
82
+ ref={frameRef}
83
+ title="Payment form"
84
+ srcDoc={html}
85
+ className="min-h-96 w-full rounded border border-base-300 bg-base-100"
86
+ />
87
+ </div>
88
+ );
89
+ }
@@ -0,0 +1,113 @@
1
+ 'use client';
2
+
3
+ import type { ExtractedError } from '@forgecart/sdk/shop';
4
+
5
+ import type { ShippingRateGroup } from '../../lib/forgecart';
6
+ import { formatPrice } from '../../lib/format';
7
+
8
+ /**
9
+ * Shipping-rate selection: one card group per shipment (rates arrive grouped
10
+ * by stock location), radio-cards per rate, per-group `lastRateErrors`
11
+ * alerts (a refusing carrier never hides its siblings' quotes). The CUSTOMER
12
+ * picks — nothing auto-selects; the ArrangingPayment transition requires a
13
+ * selection in every group (#863's `hasSelectedRates` gate).
14
+ */
15
+ export function RatesStep({
16
+ groups,
17
+ onSelect,
18
+ onContinue,
19
+ pending,
20
+ refreshing,
21
+ error,
22
+ }: {
23
+ groups: ShippingRateGroup[];
24
+ onSelect: (rateGroupId: string, rateId: string) => void;
25
+ onContinue: () => void;
26
+ pending: boolean;
27
+ refreshing: boolean;
28
+ error: ExtractedError | null;
29
+ }) {
30
+ if (refreshing) {
31
+ return <p className="text-sm text-base-content/60">Fetching shipping rates…</p>;
32
+ }
33
+
34
+ const allSelected =
35
+ groups.length > 0 && groups.every((group) => group.rates.some((rate) => rate.selected));
36
+
37
+ return (
38
+ <div className="space-y-4">
39
+ {groups.length === 0 && (
40
+ <div role="alert" className="alert alert-warning text-sm">
41
+ <span>No shipping rates are available for this address yet.</span>
42
+ </div>
43
+ )}
44
+
45
+ {groups.map((group, index) => (
46
+ <div key={group.id} className="card space-y-2 border border-base-300 bg-base-100 p-4">
47
+ <h3 className="text-sm font-medium text-base-content/70">
48
+ {groups.length > 1 ? `Shipment ${index + 1}` : 'Shipping method'}
49
+ {group.stockLocationName ? ` — ships from ${group.stockLocationName}` : ''}
50
+ </h3>
51
+
52
+ {group.lastRateErrors.length > 0 && (
53
+ <div role="alert" className="alert alert-warning text-sm">
54
+ <span>
55
+ {group.lastRateErrors
56
+ .map((failure) => `${failure.providerName}: quotes unavailable`)
57
+ .join(' · ')}
58
+ </span>
59
+ </div>
60
+ )}
61
+
62
+ <div className="flex flex-col gap-2">
63
+ {group.rates.map((rate) => (
64
+ <button
65
+ key={rate.id}
66
+ type="button"
67
+ onClick={() => onSelect(group.id, rate.id)}
68
+ disabled={pending}
69
+ aria-pressed={rate.selected}
70
+ data-fc-track="checkout-select-rate"
71
+ className={`flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-left text-sm transition disabled:opacity-50 ${
72
+ rate.selected
73
+ ? 'border-primary ring-1 ring-primary'
74
+ : 'border-base-300 hover:border-primary'
75
+ }`}
76
+ >
77
+ <span className="min-w-0">
78
+ <span className="block font-medium text-base-content">
79
+ {rate.carrier} {rate.service}
80
+ </span>
81
+ {rate.estimatedDays !== null && rate.estimatedDays !== undefined && (
82
+ <span className="block text-xs text-base-content/60">
83
+ ~{rate.estimatedDays} day{rate.estimatedDays === 1 ? '' : 's'}
84
+ </span>
85
+ )}
86
+ </span>
87
+ <span className="shrink-0 font-medium text-base-content">
88
+ {formatPrice(rate.amount, rate.currency)}
89
+ </span>
90
+ </button>
91
+ ))}
92
+ </div>
93
+ </div>
94
+ ))}
95
+
96
+ {error && (
97
+ <div role="alert" className="alert alert-error text-sm">
98
+ <span>{error.message ?? error.code}</span>
99
+ </div>
100
+ )}
101
+
102
+ <button
103
+ type="button"
104
+ className="btn btn-primary"
105
+ onClick={onContinue}
106
+ disabled={pending || !allSelected}
107
+ data-fc-track="checkout-to-payment"
108
+ >
109
+ {pending ? 'Saving…' : 'Continue to payment'}
110
+ </button>
111
+ </div>
112
+ );
113
+ }
@@ -31,6 +31,40 @@
31
31
 
32
32
  const BEACON_PORT = process.env.FORGE_BEACON_PORT ?? '3002';
33
33
 
34
+ /**
35
+ * Server-start hook: warm-boot the embedded NestJS backend (`src/server/`)
36
+ * so the first `sdk.backend` invocation doesn't pay the context creation.
37
+ *
38
+ * Warm-up ONLY — correctness never depends on it: the dispatch path's lazy
39
+ * `getBackend()` is what re-boots the context after dev-mode (HMR) edits,
40
+ * and a boot failure here is deliberately swallowed so a drifted api-map
41
+ * can never crash the server start — the first invocation (and the dev
42
+ * methods route) reports the same error as a typed envelope instead. The
43
+ * dynamic import keeps the Edge bundle inert (Nest is Node-only), and a
44
+ * no-env boot (image pre-warm) succeeds because clients are only built at
45
+ * invocation time.
46
+ */
47
+ export async function register(): Promise<void> {
48
+ // POSITIVE if-block, never an early return: webpack's parser dead-code-
49
+ // eliminates a statically-false `if` BODY (NEXT_RUNTIME is DefinePlugin-
50
+ // substituted per bundle), but it does NOT track reachability past a
51
+ // `return` — with the guard inverted, the EDGE instrumentation bundle
52
+ // (compiled because middleware.ts exists) chases this import into
53
+ // @nestjs/*'s node-builtin requires and `next build --webpack` dies with
54
+ // five Module-not-found errors ('stream', 'os', 'perf_hooks'; every
55
+ // publish-button CI run since this import landed). Turbopack prunes the
56
+ // dead branch either way — only the webpack publish build sees the
57
+ // difference.
58
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
59
+ try {
60
+ const { getBackend } = await import('./server/bootstrap');
61
+ await getBackend();
62
+ } catch {
63
+ // Surfaced by the first sdk.backend invocation and /api/__backend/methods.
64
+ }
65
+ }
66
+ }
67
+
34
68
  interface OnRequestErrorRequest {
35
69
  path: string;
36
70
  method: string;
@@ -0,0 +1,30 @@
1
+ import type { ExtractedError } from '@forgecart/sdk/shop';
2
+
3
+ /**
4
+ * Typed result envelope for every Server Action in this template.
5
+ *
6
+ * Server Actions NEVER throw across the RSC boundary: Next.js production
7
+ * redacts a thrown action error to an opaque digest, so the client would see
8
+ * a generic failure with no code to render. Instead each action catches,
9
+ * translates the failure with the SDK's `extractError` (the shop API's
10
+ * `{ code, variables, classification, message }` extensions — `message`
11
+ * arrives server-localized), and returns it in-band. Callers switch on
12
+ * `ok`; error UIs render `error.message` and key field targeting off
13
+ * `error.code`.
14
+ *
15
+ * The `import type` above erases, so this module is safe for client
16
+ * components — it is the one shape both sides of the action boundary share.
17
+ */
18
+ export type ActionResult<T> = { ok: true; data: T } | { ok: false; error: ExtractedError };
19
+
20
+ /**
21
+ * Fallback envelope error for failures that carry no GraphQL extensions
22
+ * (network refused, DNS, a non-GraphQL throw). `extractError` returns `null`
23
+ * for those; actions substitute this so the client always receives a code it
24
+ * can key on.
25
+ */
26
+ export const UNREACHABLE_ERROR: ExtractedError = {
27
+ code: 'SHOP_API_UNREACHABLE',
28
+ variables: {},
29
+ classification: 'INTERNAL_ERROR',
30
+ };
@@ -0,0 +1,20 @@
1
+ 'use server';
2
+
3
+ import 'server-only';
4
+
5
+ import { invokeBackendMethod } from '../server/runner';
6
+ import type { ActionResult } from './action-result';
7
+
8
+ /**
9
+ * The ONE Server Action bridging the browser into the embedded NestJS
10
+ * backend (`src/server/`) — Next's native RPC is the transport, the booted
11
+ * registry is the authority. Components never import this directly: they
12
+ * call through `backend` (`lib/backend-client.ts`), which types the surface
13
+ * off `backend-api.ts` and guards the proxy's magic keys.
14
+ */
15
+ export async function invokeBackend(
16
+ method: string,
17
+ input: unknown,
18
+ ): Promise<ActionResult<unknown>> {
19
+ return invokeBackendMethod(method, input);
20
+ }
@@ -0,0 +1,47 @@
1
+ import type { BackendApi } from '../server/backend-api';
2
+ import type { ActionResult } from './action-result';
3
+ import { invokeBackend } from './backend-actions';
4
+
5
+ /**
6
+ * `sdk.backend` — the typed browser gate onto the storefront's embedded
7
+ * NestJS backend.
8
+ *
9
+ * Compile time: the mapped type below projects `backend-api.ts` onto the
10
+ * proxy, so editors autocomplete exactly the registered methods and an
11
+ * unknown name fails the build. (The import is type-only — it erases, so no
12
+ * server code reaches the client bundle; the `server-only` guards enforce
13
+ * it.)
14
+ *
15
+ * Runtime: the proxy fabricates an invoker for ANY string key — the
16
+ * server-side registry is the real authority, answering
17
+ * BACKEND_METHOD_NOT_FOUND in the normal envelope for unknown or
18
+ * stale-bundle names. Magic keys are excluded so the object never
19
+ * masquerades as a thenable (`await backend` must not fire a wire call
20
+ * named "then").
21
+ *
22
+ * Usage, any client component:
23
+ * const result = await backend.assignCustomerHash({});
24
+ * if (!result.ok) return renderError(result.error);
25
+ */
26
+ type BackendGate = {
27
+ [K in keyof BackendApi]: (
28
+ input: Parameters<BackendApi[K]>[0],
29
+ ) => Promise<ActionResult<Awaited<ReturnType<BackendApi[K]>>>>;
30
+ };
31
+
32
+ const RESERVED_KEYS = new Set([
33
+ 'then',
34
+ 'catch',
35
+ 'finally',
36
+ 'toJSON',
37
+ 'valueOf',
38
+ 'constructor',
39
+ '$$typeof',
40
+ ]);
41
+
42
+ export const backend: BackendGate = new Proxy({} as BackendGate, {
43
+ get(_target, key) {
44
+ if (typeof key !== 'string' || RESERVED_KEYS.has(key)) return undefined;
45
+ return (input: unknown) => invokeBackend(key, input);
46
+ },
47
+ });