@forgecart/cli 2.202608121449.0 → 2.202608160103.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/dist/src/commands/init.d.ts +16 -0
- package/dist/src/commands/init.js +18 -7
- package/dist/src/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/README.md +145 -61
- package/templates/storefront/next.config.js +20 -0
- package/templates/storefront/package.json +10 -2
- package/templates/storefront/postcss.config.js +1 -2
- package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +23 -11
- package/templates/storefront/src/app/__forge_beacon/route.ts +1 -2
- package/templates/storefront/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront/src/app/cart/page.tsx +33 -6
- package/templates/storefront/src/app/checkout/page.tsx +40 -0
- package/templates/storefront/src/app/error.tsx +21 -0
- package/templates/storefront/src/app/global-error.tsx +23 -0
- package/templates/storefront/src/app/globals.css +105 -8
- package/templates/storefront/src/app/layout.tsx +45 -17
- package/templates/storefront/src/app/page.tsx +153 -43
- package/templates/storefront/src/app/ping/route.ts +1 -2
- package/templates/storefront/src/app/products/[slug]/not-found.tsx +3 -8
- package/templates/storefront/src/app/products/[slug]/page.tsx +69 -23
- package/templates/storefront/src/app/products/page.tsx +52 -9
- package/templates/storefront/src/components/CartView.tsx +244 -117
- package/templates/storefront/src/components/ForgeTracker.tsx +40 -26
- package/templates/storefront/src/components/Header.tsx +8 -10
- package/templates/storefront/src/components/ProductCard.tsx +25 -13
- package/templates/storefront/src/components/ProductPurchase.tsx +13 -18
- package/templates/storefront/src/components/checkout/AddressStep.tsx +288 -0
- package/templates/storefront/src/components/checkout/CheckoutFlow.tsx +543 -0
- package/templates/storefront/src/components/checkout/CheckoutGate.tsx +45 -0
- package/templates/storefront/src/components/checkout/PaymentElementForm.tsx +138 -0
- package/templates/storefront/src/components/checkout/PaymentFormEmbed.tsx +89 -0
- package/templates/storefront/src/components/checkout/RatesStep.tsx +113 -0
- package/templates/storefront/src/instrumentation.ts +34 -0
- package/templates/storefront/src/lib/action-result.ts +30 -0
- package/templates/storefront/src/lib/backend-actions.ts +20 -0
- package/templates/storefront/src/lib/backend-client.ts +47 -0
- package/templates/storefront/src/lib/cart-context.tsx +157 -22
- package/templates/storefront/src/lib/checkout-session.ts +185 -0
- package/templates/storefront/src/lib/error-messages.ts +24 -0
- package/templates/storefront/src/lib/experiments.ts +42 -44
- package/templates/storefront/src/lib/forgecart.ts +61 -78
- package/templates/storefront/src/lib/format.ts +91 -0
- package/templates/storefront/src/lib/session-actions.ts +54 -0
- package/templates/storefront/src/lib/shop-config.ts +44 -0
- package/templates/storefront/src/lib/shop-session.ts +114 -0
- package/templates/storefront/src/lib/uuid.ts +19 -0
- package/templates/storefront/src/server/app.module.ts +18 -0
- package/templates/storefront/src/server/backend-api.ts +26 -0
- package/templates/storefront/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront/src/server/bootstrap.ts +122 -0
- package/templates/storefront/src/server/customer-extras/customer-extras.module.ts +13 -0
- package/templates/storefront/src/server/customer-extras/service/customer-extras.service.ts +58 -0
- package/templates/storefront/src/server/customer-extras/type/customer-extras.types.ts +11 -0
- package/templates/storefront/src/server/forgecart/forgecart-client.factory.ts +69 -0
- package/templates/storefront/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront/src/server/runner.ts +91 -0
- package/templates/storefront/src/server/types.ts +36 -0
- package/templates/storefront/tsconfig.json +2 -0
- package/templates/storefront/.env.example +0 -12
- package/templates/storefront/src/lib/cart-actions.ts +0 -139
- 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
|
+
});
|