@forgecart/cli 2.202608110054.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,122 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import 'reflect-metadata';
|
|
3
|
+
|
|
4
|
+
import type { INestApplicationContext } from '@nestjs/common';
|
|
5
|
+
import { MetadataScanner, ModulesContainer, NestFactory, Reflector } from '@nestjs/core';
|
|
6
|
+
|
|
7
|
+
import { AppModule } from './app.module';
|
|
8
|
+
import { BACKEND_API_METHOD_NAMES } from './backend-api';
|
|
9
|
+
import { BACKEND_METHOD_KEY } from './backend-method.decorator';
|
|
10
|
+
import type { BackendSession } from './types';
|
|
11
|
+
|
|
12
|
+
/** A discovered method, bound to its service instance. */
|
|
13
|
+
export type RegisteredBackendMethod = (session: BackendSession, input: unknown) => Promise<unknown>;
|
|
14
|
+
|
|
15
|
+
export interface BackendRuntime {
|
|
16
|
+
ctx: INestApplicationContext;
|
|
17
|
+
registry: ReadonlyMap<string, RegisteredBackendMethod>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Boot-once accessor for the embedded NestJS backend.
|
|
22
|
+
*
|
|
23
|
+
* The cache is MODULE-scoped on purpose: under `next dev`, editing anything
|
|
24
|
+
* in the backend graph makes HMR re-execute this module with a fresh scope,
|
|
25
|
+
* so the next invocation boots a NEW context with the new code — that is how
|
|
26
|
+
* agent edits take effect without a server restart. The previous context's
|
|
27
|
+
* handle rides `globalThis` (which HMR does NOT reset) solely so the new
|
|
28
|
+
* boot can close it — retired containers must not leak lifecycle hooks. In
|
|
29
|
+
* production there is no HMR and this degrades to boot-exactly-once.
|
|
30
|
+
*
|
|
31
|
+
* A failed boot (e.g. the api-map drift check) stays cached as a rejection:
|
|
32
|
+
* every invocation reports the same loud error until the FIX edit re-executes
|
|
33
|
+
* this module. Failing closed and visible beats a half-registered backend.
|
|
34
|
+
*/
|
|
35
|
+
let runtimePromise: Promise<BackendRuntime> | undefined;
|
|
36
|
+
const handoff = globalThis as { __forgecartBackendPrev?: BackendRuntime };
|
|
37
|
+
|
|
38
|
+
export function getBackend(): Promise<BackendRuntime> {
|
|
39
|
+
runtimePromise ??= boot();
|
|
40
|
+
return runtimePromise;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function boot(): Promise<BackendRuntime> {
|
|
44
|
+
const ctx = await NestFactory.createApplicationContext(AppModule, {
|
|
45
|
+
logger: ['warn', 'error'],
|
|
46
|
+
});
|
|
47
|
+
const registry = scanForBackendMethods(ctx);
|
|
48
|
+
validateAgainstApi(registry);
|
|
49
|
+
|
|
50
|
+
const previous = handoff.__forgecartBackendPrev;
|
|
51
|
+
if (previous) {
|
|
52
|
+
try {
|
|
53
|
+
await previous.ctx.close();
|
|
54
|
+
} catch {
|
|
55
|
+
// A mid-reload teardown race is fine — the old container is gone either way.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const runtime: BackendRuntime = { ctx, registry };
|
|
59
|
+
handoff.__forgecartBackendPrev = runtime;
|
|
60
|
+
return runtime;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Walk every provider in the booted module graph and collect the methods
|
|
65
|
+
* carrying `@BackendMethod()` — the decorator is the allowlist, so the map
|
|
66
|
+
* built here is the ONLY surface `sdk.backend` can reach. Names are global:
|
|
67
|
+
* a duplicate across services fails the boot rather than shadowing.
|
|
68
|
+
*/
|
|
69
|
+
function scanForBackendMethods(
|
|
70
|
+
ctx: INestApplicationContext,
|
|
71
|
+
): ReadonlyMap<string, RegisteredBackendMethod> {
|
|
72
|
+
const registry = new Map<string, RegisteredBackendMethod>();
|
|
73
|
+
const modules = ctx.get(ModulesContainer);
|
|
74
|
+
const scanner = new MetadataScanner();
|
|
75
|
+
const reflector = new Reflector();
|
|
76
|
+
|
|
77
|
+
for (const moduleRef of modules.values()) {
|
|
78
|
+
for (const wrapper of moduleRef.providers.values()) {
|
|
79
|
+
const instance = wrapper.instance as Record<string, unknown> | null;
|
|
80
|
+
if (!instance || typeof instance !== 'object') continue;
|
|
81
|
+
const prototype = Object.getPrototypeOf(instance) as Record<string, unknown> | null;
|
|
82
|
+
if (!prototype) continue;
|
|
83
|
+
|
|
84
|
+
for (const name of scanner.getAllMethodNames(prototype)) {
|
|
85
|
+
const handler = prototype[name];
|
|
86
|
+
if (typeof handler !== 'function') continue;
|
|
87
|
+
if (reflector.get<boolean | undefined>(BACKEND_METHOD_KEY, handler) !== true) continue;
|
|
88
|
+
if (registry.has(name)) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Duplicate @BackendMethod name "${name}" — backend method names must be unique across all services.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
registry.set(name, (handler as RegisteredBackendMethod).bind(instance));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return registry;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The drift tripwire: the runtime scan and the compile-time surface
|
|
102
|
+
* (`backend-api.ts`) must agree EXACTLY, in both directions, or the typed
|
|
103
|
+
* proxy the browser compiles against would lie. The error names the exact
|
|
104
|
+
* method so the fix is a one-line edit.
|
|
105
|
+
*/
|
|
106
|
+
function validateAgainstApi(registry: ReadonlyMap<string, RegisteredBackendMethod>): void {
|
|
107
|
+
const declared = new Set<string>(BACKEND_API_METHOD_NAMES);
|
|
108
|
+
for (const name of registry.keys()) {
|
|
109
|
+
if (!declared.has(name)) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`@BackendMethod "${name}" is not declared in backend-api.ts — add its signature to BackendApi and its name to BACKEND_API_METHOD_NAMES.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const name of declared) {
|
|
116
|
+
if (!registry.has(name)) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`backend-api.ts declares "${name}" but no @BackendMethod with that name was discovered — decorate the service method (and wire its module into app.module.ts) or remove the declaration.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { Module } from '@nestjs/common';
|
|
4
|
+
|
|
5
|
+
import { CustomerExtrasService } from './service/customer-extras.service';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The worked-example domain module. New backend features follow this shape:
|
|
9
|
+
* one folder per domain, services under `service/`, the module listing them,
|
|
10
|
+
* and one `imports:` line in `app.module.ts`.
|
|
11
|
+
*/
|
|
12
|
+
@Module({ providers: [CustomerExtrasService] })
|
|
13
|
+
export class CustomerExtrasModule {}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
import { Injectable } from '@nestjs/common';
|
|
6
|
+
|
|
7
|
+
import { BackendMethod } from '../../backend-method.decorator';
|
|
8
|
+
import { BackendError } from '../../types';
|
|
9
|
+
import type { BackendSession } from '../../types';
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
AssignCustomerHashInput,
|
|
13
|
+
AssignCustomerHashResult,
|
|
14
|
+
} from '../type/customer-extras.types';
|
|
15
|
+
|
|
16
|
+
/** ACF definition this store keeps its per-customer extras under. */
|
|
17
|
+
const DEFINITION_CODE = 'customer-extras';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The worked example for the `sdk.backend` gate: mint a random hash for the
|
|
21
|
+
* signed-in customer and persist it as a custom field — an ACF entry under
|
|
22
|
+
* the `customer-extras` definition. That is an ADMIN-authority write the
|
|
23
|
+
* shop API has no surface for, which is exactly what the gate exists to
|
|
24
|
+
* unlock.
|
|
25
|
+
*
|
|
26
|
+
* Precondition: the channel has a `customer-extras` ACF definition with a
|
|
27
|
+
* `customerId` id field and a `referralHash` string field (Dashboard →
|
|
28
|
+
* Custom Fields). Without it the createEntry call fails and the typed
|
|
29
|
+
* envelope carries the API's error to the caller — nothing crashes.
|
|
30
|
+
*/
|
|
31
|
+
@Injectable()
|
|
32
|
+
export class CustomerExtrasService {
|
|
33
|
+
@BackendMethod()
|
|
34
|
+
async assignCustomerHash(
|
|
35
|
+
session: BackendSession,
|
|
36
|
+
_input: AssignCustomerHashInput,
|
|
37
|
+
): Promise<AssignCustomerHashResult> {
|
|
38
|
+
const { activeCustomer } = await session.shop.customer.activeCustomer();
|
|
39
|
+
if (!activeCustomer) {
|
|
40
|
+
// Shopper-invocable ≠ anonymous-invocable: this method is per-customer
|
|
41
|
+
// by definition, so an anonymous session is a typed business error.
|
|
42
|
+
throw new BackendError('BACKEND_CUSTOMER_SESSION_REQUIRED');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const referralHash = randomBytes(16).toString('hex');
|
|
46
|
+
await session.admin.acf.entry.acfCreateEntry({
|
|
47
|
+
definitionCode: DEFINITION_CODE,
|
|
48
|
+
input: {
|
|
49
|
+
fields: [
|
|
50
|
+
{ name: 'customerId', idValue: activeCustomer.id },
|
|
51
|
+
{ name: 'referralHash', stringValue: referralHash },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return { customerId: activeCustomer.id, referralHash };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `assignCustomerHash` backend-method contract (the sdk.backend worked
|
|
3
|
+
* example). Input is deliberately empty — the method derives everything from
|
|
4
|
+
* the caller's session; the result echoes the customer and the minted hash.
|
|
5
|
+
*/
|
|
6
|
+
export type AssignCustomerHashInput = Record<string, never>;
|
|
7
|
+
|
|
8
|
+
export interface AssignCustomerHashResult {
|
|
9
|
+
customerId: string;
|
|
10
|
+
referralHash: string;
|
|
11
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { ForgeCartAdminClient, ForgeCartShopClient } from '@forgecart/sdk';
|
|
4
|
+
import { Injectable } from '@nestjs/common';
|
|
5
|
+
|
|
6
|
+
const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
|
|
7
|
+
const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
|
|
8
|
+
const ADMIN_SECRET = process.env.FORGECART_ADMIN_SECRET ?? '';
|
|
9
|
+
/** The admin API is the shop API's sibling endpoint on the same host. */
|
|
10
|
+
const ADMIN_API_URL = SHOP_API_URL.replace(/\/shop-api$/, '/admin-api');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* SDK clients for backend methods. The generated SDK is graphql-ws, so
|
|
14
|
+
* every client is a websocket. Two lifetimes on purpose: the ADMIN client is
|
|
15
|
+
* a process-long singleton (channel-scoped, secret auth); the SESSION shop
|
|
16
|
+
* client is per-invocation (shopper-scoped authentication — see buildShop).
|
|
17
|
+
*/
|
|
18
|
+
@Injectable()
|
|
19
|
+
export class ForgeCartClientFactory {
|
|
20
|
+
private admin: ForgeCartAdminClient | null = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether the backend gate is fully configured: `forgecart init` wrote all
|
|
24
|
+
* three env values AND the admin endpoint is derivable from the shop URL.
|
|
25
|
+
* False during the image-build pre-warm (which renders with no env) — the
|
|
26
|
+
* runner then answers with a typed envelope instead of driving doomed
|
|
27
|
+
* clients, keeping the pre-warm contract (serve, never crash) intact.
|
|
28
|
+
*/
|
|
29
|
+
isConfigured(): boolean {
|
|
30
|
+
return Boolean(SHOP_API_URL && CHANNEL_TOKEN && ADMIN_SECRET && ADMIN_API_URL !== SHOP_API_URL);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The ADMIN socket is a long-lived singleton: admin operations are
|
|
35
|
+
* channel-scoped (secret auth, no shopper session), so one connection
|
|
36
|
+
* serves every invocation for the life of the server process — never
|
|
37
|
+
* disposed per invocation. Session-scoped sockets are the opposite case:
|
|
38
|
+
* they belong to the shopper's browser, and the ONE server-side exception
|
|
39
|
+
* is {@link buildShop} below.
|
|
40
|
+
*/
|
|
41
|
+
getAdmin(): ForgeCartAdminClient {
|
|
42
|
+
if (!this.admin) {
|
|
43
|
+
this.admin = new ForgeCartAdminClient({
|
|
44
|
+
endpoint: ADMIN_API_URL,
|
|
45
|
+
channelToken: CHANNEL_TOKEN,
|
|
46
|
+
adminSecret: ADMIN_SECRET,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return this.admin;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Per-invocation SESSION shop client — the deliberate server-side
|
|
54
|
+
* exception: a backend method must authenticate WHO the shopper is from
|
|
55
|
+
* the httpOnly session cookie before acting with admin authority (the
|
|
56
|
+
* browser cannot be trusted to claim an identity for an admin-gated op).
|
|
57
|
+
* Short-lived by design; the runner disposes it with the invocation.
|
|
58
|
+
*/
|
|
59
|
+
buildShop(sessionToken: string | undefined): ForgeCartShopClient {
|
|
60
|
+
const client = new ForgeCartShopClient({
|
|
61
|
+
endpoint: SHOP_API_URL,
|
|
62
|
+
channelToken: CHANNEL_TOKEN,
|
|
63
|
+
});
|
|
64
|
+
if (sessionToken) {
|
|
65
|
+
client.setAuthToken(sessionToken);
|
|
66
|
+
}
|
|
67
|
+
return client;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { Module } from '@nestjs/common';
|
|
4
|
+
|
|
5
|
+
import { ForgeCartClientFactory } from './forgecart-client.factory';
|
|
6
|
+
|
|
7
|
+
/** Provides the SDK client factory to the backend's module graph. */
|
|
8
|
+
@Module({ providers: [ForgeCartClientFactory], exports: [ForgeCartClientFactory] })
|
|
9
|
+
export class ForgeCartModule {}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { extractError, type ExtractedError } from '@forgecart/sdk';
|
|
4
|
+
import { cookies } from 'next/headers';
|
|
5
|
+
|
|
6
|
+
import { UNREACHABLE_ERROR, type ActionResult } from '../lib/action-result';
|
|
7
|
+
import { getBackend } from './bootstrap';
|
|
8
|
+
import { ForgeCartClientFactory } from './forgecart/forgecart-client.factory';
|
|
9
|
+
import { BackendError } from './types';
|
|
10
|
+
import type { BackendSession } from './types';
|
|
11
|
+
|
|
12
|
+
const SESSION_COOKIE = 'forgecart-session';
|
|
13
|
+
|
|
14
|
+
/** The gate has no env yet (image pre-warm / before `forgecart init`). */
|
|
15
|
+
const NOT_CONFIGURED: ExtractedError = {
|
|
16
|
+
code: 'BACKEND_NOT_CONFIGURED',
|
|
17
|
+
variables: {},
|
|
18
|
+
classification: 'INTERNAL_ERROR',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Dispatch one `sdk.backend` invocation: resolve the booted registry, build
|
|
23
|
+
* the per-invocation SDK clients, run the method, translate every failure
|
|
24
|
+
* into the in-band envelope, and dispose both client websockets with the
|
|
25
|
+
* invocation — the exact contract of `lib/shop-action.ts`, extended with the
|
|
26
|
+
* admin gate.
|
|
27
|
+
*
|
|
28
|
+
* Fail-closed rules: an unknown name (type-cast bypass, or a STALE browser
|
|
29
|
+
* bundle calling a method a newer deploy removed) answers
|
|
30
|
+
* BACKEND_METHOD_NOT_FOUND; a boot failure (the api-map drift check)
|
|
31
|
+
* answers BACKEND_BOOT_FAILED carrying the boot error's message so the
|
|
32
|
+
* agent sees the actionable text in the envelope too, not only in the logs.
|
|
33
|
+
*/
|
|
34
|
+
export async function invokeBackendMethod(
|
|
35
|
+
method: string,
|
|
36
|
+
input: unknown,
|
|
37
|
+
): Promise<ActionResult<unknown>> {
|
|
38
|
+
let registry: ReadonlyMap<string, (session: BackendSession, input: unknown) => Promise<unknown>>;
|
|
39
|
+
let factory: ForgeCartClientFactory;
|
|
40
|
+
try {
|
|
41
|
+
const runtime = await getBackend();
|
|
42
|
+
registry = runtime.registry;
|
|
43
|
+
factory = runtime.ctx.get(ForgeCartClientFactory, { strict: false });
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
error: {
|
|
48
|
+
code: 'BACKEND_BOOT_FAILED',
|
|
49
|
+
variables: { reason: error instanceof Error ? error.message : String(error) },
|
|
50
|
+
classification: 'INTERNAL_ERROR',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const fn = registry.get(method);
|
|
56
|
+
if (!fn) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
error: {
|
|
60
|
+
code: 'BACKEND_METHOD_NOT_FOUND',
|
|
61
|
+
variables: { method },
|
|
62
|
+
classification: 'BAD_USER_INPUT',
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (!factory.isConfigured()) {
|
|
67
|
+
return { ok: false, error: NOT_CONFIGURED };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const store = await cookies();
|
|
71
|
+
const sessionToken = store.get(SESSION_COOKIE)?.value;
|
|
72
|
+
const admin = factory.getAdmin();
|
|
73
|
+
const shop = factory.buildShop(sessionToken);
|
|
74
|
+
const session: BackendSession = { admin, shop, hasSession: Boolean(sessionToken) };
|
|
75
|
+
try {
|
|
76
|
+
const data = await fn(session, input);
|
|
77
|
+
return { ok: true, data };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error instanceof BackendError) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: { code: error.code, variables: error.variables, classification: 'BAD_USER_INPUT' },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { ok: false, error: extractError(error) ?? UNREACHABLE_ERROR };
|
|
86
|
+
} finally {
|
|
87
|
+
// Only the session client dies with the invocation — the admin socket is
|
|
88
|
+
// the factory's process-long singleton.
|
|
89
|
+
shop.dispose();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import type { ForgeCartAdminClient, ForgeCartShopClient } from '@forgecart/sdk';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The per-invocation context every backend method receives as its first
|
|
7
|
+
* argument. Both SDK gates are built fresh for THIS invocation by the runner
|
|
8
|
+
* and disposed with it (they are per-invocation websockets — same discipline
|
|
9
|
+
* as `lib/shop-action.ts`), so methods never construct or dispose clients.
|
|
10
|
+
*
|
|
11
|
+
* `admin` carries FULL channel authority (FORGECART_ADMIN_SECRET). A backend
|
|
12
|
+
* method is shopper-invocable, so nothing upstream gates what the admin
|
|
13
|
+
* client is used for — the method body owns that decision.
|
|
14
|
+
*/
|
|
15
|
+
export interface BackendSession {
|
|
16
|
+
admin: ForgeCartAdminClient;
|
|
17
|
+
shop: ForgeCartShopClient;
|
|
18
|
+
/** True when the request carried a shopper session cookie. */
|
|
19
|
+
hasSession: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Typed failure a backend method throws for its own business rules
|
|
24
|
+
* (missing login, invalid input, unmet precondition). The runner translates
|
|
25
|
+
* it into the standard `ActionResult` envelope exactly like an SDK error,
|
|
26
|
+
* so the client renders `code`/`variables` through the same error spine.
|
|
27
|
+
*/
|
|
28
|
+
export class BackendError extends Error {
|
|
29
|
+
constructor(
|
|
30
|
+
readonly code: string,
|
|
31
|
+
readonly variables: Record<string, string> = {},
|
|
32
|
+
) {
|
|
33
|
+
super(code);
|
|
34
|
+
this.name = 'BackendError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# ForgeCart channel credentials.
|
|
2
|
-
#
|
|
3
|
-
# `forgecart init` writes the real values to `.env` for you. This file
|
|
4
|
-
# documents the variables the storefront reads. Both are required and used
|
|
5
|
-
# server-side only (never exposed to the browser).
|
|
6
|
-
|
|
7
|
-
# The channel token identifies which ForgeCart channel this storefront serves.
|
|
8
|
-
# It is sent as the `forgecart-token` header on every shop API request.
|
|
9
|
-
FORGECART_CHANNEL_TOKEN=
|
|
10
|
-
|
|
11
|
-
# Base URL of the ForgeCart shop GraphQL API (the shop-api endpoint).
|
|
12
|
-
FORGECART_SHOP_API_URL=https://api.forgecart.com/shop-api
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
'use server';
|
|
2
|
-
|
|
3
|
-
import { ForgeCartShopClient } from '@forgecart/sdk';
|
|
4
|
-
import type { ShopAddItemToOrderInput } from '@forgecart/sdk/shop';
|
|
5
|
-
import { cookies } from 'next/headers';
|
|
6
|
-
|
|
7
|
-
import type { Order } from './forgecart';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Server-side cart, backed by the ForgeCart shop **order** API (the real cart),
|
|
11
|
-
* not browser storage. Every export here is a Server Action driving the SDK's
|
|
12
|
-
* generated, typed cart operations and returning the live `Order` (the SDK
|
|
13
|
-
* type) so callers render straight off it.
|
|
14
|
-
*
|
|
15
|
-
* The anonymous active order is tracked by a session token: the shop API
|
|
16
|
-
* returns a fresh token (result extensions) on the first mutation, which the
|
|
17
|
-
* SDK client captures — we persist it into the httpOnly `forgecart-session`
|
|
18
|
-
* cookie and replay it as the client's auth token on every subsequent request.
|
|
19
|
-
* Session state is per shopper, so each action builds a short-lived client
|
|
20
|
-
* around its own request's cookie (queries/mutations ride plain fetch — the
|
|
21
|
-
* client only opens a websocket for subscriptions, which the cart never uses).
|
|
22
|
-
* The channel token and the session never reach the browser — all cart calls
|
|
23
|
-
* run here, on the server.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
|
|
27
|
-
const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
|
|
28
|
-
const SESSION_COOKIE = 'forgecart-session';
|
|
29
|
-
|
|
30
|
-
/** A per-request shop client carrying this shopper's session, when one exists. */
|
|
31
|
-
async function getCartClient(): Promise<ForgeCartShopClient> {
|
|
32
|
-
const store = await cookies();
|
|
33
|
-
const session = store.get(SESSION_COOKIE)?.value;
|
|
34
|
-
const client = new ForgeCartShopClient({
|
|
35
|
-
endpoint: SHOP_API_URL,
|
|
36
|
-
channelToken: CHANNEL_TOKEN,
|
|
37
|
-
});
|
|
38
|
-
if (session) {
|
|
39
|
-
client.setAuthToken(session);
|
|
40
|
-
}
|
|
41
|
-
return client;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Persist the session token the client captured during a mutation.
|
|
46
|
-
*
|
|
47
|
-
* MUST only be called from Server Actions (the mutations): Server Components
|
|
48
|
-
* cannot set cookies, which is why the read path never persists.
|
|
49
|
-
*/
|
|
50
|
-
async function persistSession(client: ForgeCartShopClient): Promise<void> {
|
|
51
|
-
const token = client.getAuthToken();
|
|
52
|
-
if (!token) return;
|
|
53
|
-
const store = await cookies();
|
|
54
|
-
if (store.get(SESSION_COOKIE)?.value === token) return;
|
|
55
|
-
store.set(SESSION_COOKIE, token, {
|
|
56
|
-
httpOnly: true,
|
|
57
|
-
sameSite: 'lax',
|
|
58
|
-
path: '/',
|
|
59
|
-
maxAge: 60 * 60 * 24 * 30,
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Read the active order (cart). Safe to call from a Server Component.
|
|
65
|
-
*
|
|
66
|
-
* Before `forgecart init` writes `.env` (notably the image-build pre-warm,
|
|
67
|
-
* which renders `/` with no env), there is no shop to talk to — return an empty
|
|
68
|
-
* cart immediately instead of driving the client into a doomed fetch. The
|
|
69
|
-
* layout renders on every route, so this read must never stall an unconfigured
|
|
70
|
-
* scaffold.
|
|
71
|
-
*
|
|
72
|
-
* No session cookie ⇒ no cart, WITHOUT calling the API. The shop's activeOrder
|
|
73
|
-
* read auto-creates an order for the session — and a Server Component can't
|
|
74
|
-
* persist a session cookie — so an eager read used to MINT an anonymous
|
|
75
|
-
* session plus an orphan order on every cookie-less page view. The order (and
|
|
76
|
-
* the cookie, via persistSession) is created lazily by the first addToCart.
|
|
77
|
-
*/
|
|
78
|
-
export async function getCart(): Promise<Order | null> {
|
|
79
|
-
if (!SHOP_API_URL || !CHANNEL_TOKEN) return null;
|
|
80
|
-
const store = await cookies();
|
|
81
|
-
if (!store.get(SESSION_COOKIE)?.value) return null;
|
|
82
|
-
const client = await getCartClient();
|
|
83
|
-
const { activeOrder } = await client.order.activeOrder();
|
|
84
|
-
return activeOrder ?? null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Add a variant to the cart (creates the order + session on first call).
|
|
89
|
-
*
|
|
90
|
-
* Pass `sellingPlanId` to add the line as a subscription on the given plan;
|
|
91
|
-
* omit it (the default) for a one-time purchase. The id is only included in the
|
|
92
|
-
* mutation input when present, so the one-time path sends the same shape as
|
|
93
|
-
* before.
|
|
94
|
-
*/
|
|
95
|
-
export async function addToCart(
|
|
96
|
-
variantId: string,
|
|
97
|
-
quantity = 1,
|
|
98
|
-
sellingPlanId?: string,
|
|
99
|
-
): Promise<Order | null> {
|
|
100
|
-
const input: ShopAddItemToOrderInput = { variantId, quantity };
|
|
101
|
-
if (sellingPlanId) {
|
|
102
|
-
input.sellingPlanId = sellingPlanId;
|
|
103
|
-
}
|
|
104
|
-
const client = await getCartClient();
|
|
105
|
-
const { addItemToOrder } = await client.cart.addItemToOrder({ input });
|
|
106
|
-
await persistSession(client);
|
|
107
|
-
return addItemToOrder;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Set (or clear) the subscription plan for the whole order.
|
|
112
|
-
*
|
|
113
|
-
* Pass a channel-wide selling plan id to subscribe the entire cart on that
|
|
114
|
-
* plan, or `null` to clear it back to a one-time order. Returns the live order.
|
|
115
|
-
*/
|
|
116
|
-
export async function setCartSellingPlan(sellingPlanId: string | null): Promise<Order | null> {
|
|
117
|
-
const client = await getCartClient();
|
|
118
|
-
const { setOrderSellingPlan } = await client.cart.setOrderSellingPlan({
|
|
119
|
-
input: { sellingPlanId },
|
|
120
|
-
});
|
|
121
|
-
await persistSession(client);
|
|
122
|
-
return setOrderSellingPlan;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Set the quantity of a cart line (0 removes it). */
|
|
126
|
-
export async function updateLine(lineId: string, quantity: number): Promise<Order | null> {
|
|
127
|
-
const client = await getCartClient();
|
|
128
|
-
const { adjustOrderLine } = await client.cart.adjustOrderLine({ input: { lineId, quantity } });
|
|
129
|
-
await persistSession(client);
|
|
130
|
-
return adjustOrderLine;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Remove a line from the cart. */
|
|
134
|
-
export async function removeLine(lineId: string): Promise<Order | null> {
|
|
135
|
-
const client = await getCartClient();
|
|
136
|
-
const { removeOrderLine } = await client.cart.removeOrderLine({ lineId });
|
|
137
|
-
await persistSession(client);
|
|
138
|
-
return removeOrderLine;
|
|
139
|
-
}
|