create-magic-storefront 0.4.0 → 0.5.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/package.json +3 -3
- package/template/.env.example +5 -0
- package/template/AGENTS.md +5 -2
- package/template/PAGES.md +9 -0
- package/template/app/layout.tsx +2 -1
- package/template/app/providers.tsx +15 -6
- package/template/app/storefront-api/[...path]/route.ts +11 -66
- package/template/lib/upstream.ts +21 -0
- package/template/llms.txt +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-magic-storefront",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Scaffold a Next.js storefront on the MagicStore Storefront API v2",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"esbuild": "^0.27.0"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@magicstoreai/hydrogen": "0.
|
|
22
|
-
"@magicstoreai/storefront-client": "0.
|
|
21
|
+
"@magicstoreai/hydrogen": "0.5.0",
|
|
22
|
+
"@magicstoreai/storefront-client": "0.5.0",
|
|
23
23
|
"@types/node": "^26.6.2",
|
|
24
24
|
"react": "^19.3.0",
|
|
25
25
|
"tsup": "^8.5.1",
|
package/template/.env.example
CHANGED
|
@@ -10,6 +10,11 @@ MAGICSTORE_API_URL=
|
|
|
10
10
|
# browser calls go through this app's /storefront-api proxy (app/storefront-api/[...path]).
|
|
11
11
|
NEXT_PUBLIC_MAGICSTORE_STOREFRONT_TOKEN=
|
|
12
12
|
|
|
13
|
+
# Optional. Who holds the buyer's sign-in tokens and cart id: "server" (default) keeps them in httpOnly
|
|
14
|
+
# cookies behind app/storefront-api, out of reach of page JavaScript; "browser" keeps them in
|
|
15
|
+
# localStorage. A public storefront token (below) always means "browser".
|
|
16
|
+
MAGICSTORE_CREDENTIALS=
|
|
17
|
+
|
|
13
18
|
# Optional. The storefront access token (msf_…) the server sends with its reads: the API then serves
|
|
14
19
|
# this storefront's own theme, and theme previews work (they need it). Server-only; when empty, the
|
|
15
20
|
# public token below is used, else the shop is found by its domain.
|
package/template/AGENTS.md
CHANGED
|
@@ -25,7 +25,10 @@ each error code means — then `PAGES.md` for how each page type is built, and
|
|
|
25
25
|
`/seo`, `/theme` or `@magicstoreai/storefront-client`. `npx create-magic-storefront check` must pass.
|
|
26
26
|
- Never compute money: render `Money` values with `<Money>` / `formatMoney`, totals from the cart
|
|
27
27
|
and the checkout.
|
|
28
|
-
- Cart id, checkout id, tokens, OTP codes, phone numbers: never logged, never in a URL.
|
|
28
|
+
- Cart id, checkout id, tokens, OTP codes, phone numbers: never logged, never in a URL. By default
|
|
29
|
+
the browser never holds them at all: `app/storefront-api` keeps them in httpOnly cookies and
|
|
30
|
+
pages see `"current"` (`MAGICSTORE_CREDENTIALS`, `lib/upstream.ts`). Never read or parse a cart
|
|
31
|
+
or checkout id — pass back what the API returned.
|
|
29
32
|
- Public reads render on the server through `lib/api.ts` (cached by tag, revalidated by the
|
|
30
33
|
webhook). Anything personal — cart, customer, wishlist — lives in client components under
|
|
31
34
|
`MagicStoreProvider` (`app/providers.tsx`).
|
|
@@ -68,7 +71,7 @@ screenshot every page type at 390 and 1280px, fix what you see. Interactive UI f
|
|
|
68
71
|
| `components/telegram-shell.tsx` | Telegram Mini App: theme marks, back arrow, the one automatic Telegram sign-in (`PAGES.md`) |
|
|
69
72
|
| `lib/api.ts` | The server-side client (`nextCacheFetch`), `orNotFound` |
|
|
70
73
|
| `app/providers.tsx` | `MagicStoreProvider`: customer, cart, wishlist, analytics in the browser |
|
|
71
|
-
| `app/storefront-api/[...path]` | Same-origin proxy
|
|
74
|
+
| `app/storefront-api/[...path]` | Same-origin proxy (`createStorefrontProxy`); holds tokens and cart / checkout ids in httpOnly cookies |
|
|
72
75
|
| `app/api/magicstore/webhook` | Signed webhook → `revalidateTag` |
|
|
73
76
|
| `app/api/magicstore/preview` | Theme editor preview: signed token → draft mode; pages pass `themePreview()` (`lib/preview.ts`) as `preview` |
|
|
74
77
|
|
package/template/PAGES.md
CHANGED
|
@@ -30,6 +30,15 @@ errors).
|
|
|
30
30
|
Every page renders per request (`dynamic = 'force-dynamic'` in `app/layout.tsx`): the shop is the
|
|
31
31
|
API's, not the build's. The fetch cache still spares the API.
|
|
32
32
|
|
|
33
|
+
### Who holds the buyer's credentials
|
|
34
|
+
|
|
35
|
+
By default (`MAGICSTORE_CREDENTIALS` unset or `server`, `lib/upstream.ts`) every browser call goes
|
|
36
|
+
through `app/storefront-api`, which keeps the sign-in tokens and the cart and checkout ids in
|
|
37
|
+
httpOnly cookies. Page JavaScript only ever sees `"current"` in their place — `cart.id`,
|
|
38
|
+
`checkout.id`, the session's tokens — and passes it back as is. Choose `browser` only when a public
|
|
39
|
+
storefront token sends the browser straight to the API; then the tokens and the cart id live in
|
|
40
|
+
`localStorage`.
|
|
41
|
+
|
|
33
42
|
## Layout — `app/layout.tsx`
|
|
34
43
|
|
|
35
44
|
- Reads `api.shop()` and the navigation (`api.collectionsIndex` or `api.menusShow({ path: { handle: 'main' } })`).
|
package/template/app/layout.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { TelegramShell } from '@/components/telegram-shell';
|
|
|
11
11
|
import { api, locale } from '@/lib/api';
|
|
12
12
|
import { themePreview } from '@/lib/preview';
|
|
13
13
|
import { brandStyle } from '@/lib/brand';
|
|
14
|
+
import { credentialsMode } from '@/lib/upstream';
|
|
14
15
|
import { messages } from '@/lib/i18n';
|
|
15
16
|
import theme from '@/theme';
|
|
16
17
|
import { Providers } from './providers';
|
|
@@ -51,7 +52,7 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
|
|
51
52
|
{shop.features.telegramBot && (
|
|
52
53
|
<Script src={TELEGRAM_WEB_APP_SCRIPT} strategy="beforeInteractive" />
|
|
53
54
|
)}
|
|
54
|
-
<Providers shop={shop}>
|
|
55
|
+
<Providers shop={shop} credentials={credentialsMode()}>
|
|
55
56
|
<TelegramShell />
|
|
56
57
|
{announcement && <p className="site-announcement">{announcement}</p>}
|
|
57
58
|
<header className="site">
|
|
@@ -9,13 +9,22 @@ const locale = process.env.NEXT_PUBLIC_MAGICSTORE_LOCALE || 'ru';
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* With a public storefront token the browser calls the shop's API directly (the token lists this
|
|
12
|
-
* origin); without one, it goes through this app's same-origin proxy (app/storefront-api)
|
|
12
|
+
* origin); without one, it goes through this app's same-origin proxy (app/storefront-api), which by
|
|
13
|
+
* default also holds the buyer's credentials. `credentials` comes from the server
|
|
14
|
+
* (`credentialsMode()` in the layout), so the proxy and the provider always agree.
|
|
13
15
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
export function Providers({
|
|
17
|
+
shop,
|
|
18
|
+
credentials,
|
|
19
|
+
children,
|
|
20
|
+
}: {
|
|
21
|
+
shop: Shop;
|
|
22
|
+
credentials: 'browser' | 'server';
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
}) {
|
|
25
|
+
const connection = token
|
|
26
|
+
? { shopDomain: process.env.NEXT_PUBLIC_MAGICSTORE_SHOP_DOMAIN!, storefrontToken: token }
|
|
27
|
+
: { baseUrl: '/storefront-api', credentials };
|
|
19
28
|
return (
|
|
20
29
|
<MagicStoreProvider {...connection} shop={shop} locale={locale}>
|
|
21
30
|
<PageViews />
|
|
@@ -3,81 +3,26 @@
|
|
|
3
3
|
* identifies the shop by the request's host and CORS admits only origins a token lists, so the
|
|
4
4
|
* browser talks to this route and this route talks to the shop — with the shop's host, and the
|
|
5
5
|
* buyer's address in X-Forwarded-For. Only the API's own headers pass, both ways.
|
|
6
|
+
*
|
|
7
|
+
* By default it also holds the buyer's credentials (`credentialsMode()`): session tokens and the
|
|
8
|
+
* cart and checkout ids live in httpOnly cookies, and the browser only ever sees "current".
|
|
6
9
|
*/
|
|
7
|
-
import {
|
|
10
|
+
import { createStorefrontProxy, type StorefrontProxy } from '@magicstoreai/hydrogen/server';
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
import { credentialsMode, upstreamBaseUrl } from '@/lib/upstream';
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
'accept',
|
|
13
|
-
'accept-language',
|
|
14
|
-
'authorization',
|
|
15
|
-
'content-type',
|
|
16
|
-
'idempotency-key',
|
|
17
|
-
'if-none-match',
|
|
18
|
-
'x-anonymous-id',
|
|
19
|
-
'x-checkout-id',
|
|
20
|
-
'x-request-id',
|
|
21
|
-
'x-session-id',
|
|
22
|
-
];
|
|
14
|
+
let proxy: StorefrontProxy | null = null;
|
|
23
15
|
|
|
24
|
-
|
|
25
|
-
'cache-control',
|
|
26
|
-
'content-language',
|
|
27
|
-
'content-type',
|
|
28
|
-
'etag',
|
|
29
|
-
'location',
|
|
30
|
-
'ratelimit-limit',
|
|
31
|
-
'ratelimit-remaining',
|
|
32
|
-
'ratelimit-reset',
|
|
33
|
-
'retry-after',
|
|
34
|
-
'x-request-id',
|
|
35
|
-
];
|
|
36
|
-
|
|
37
|
-
async function proxy(
|
|
16
|
+
async function handle(
|
|
38
17
|
request: Request,
|
|
39
18
|
{ params }: { params: Promise<{ path: string[] }> },
|
|
40
19
|
): Promise<Response> {
|
|
20
|
+
const upstream = upstreamBaseUrl();
|
|
41
21
|
if (upstream === null) {
|
|
42
22
|
return new Response('MAGICSTORE_SHOP_DOMAIN is not set', { status: 500 });
|
|
43
23
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const target = new URL(`${upstream}/${path.map(encodeURIComponent).join('/')}`);
|
|
47
|
-
target.search = incoming.search;
|
|
48
|
-
|
|
49
|
-
const headers = new Headers();
|
|
50
|
-
for (const name of REQUEST_HEADERS) {
|
|
51
|
-
const value = request.headers.get(name);
|
|
52
|
-
if (value !== null) {
|
|
53
|
-
headers.set(name, value);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
const client = request.headers.get('x-forwarded-for');
|
|
57
|
-
if (client) {
|
|
58
|
-
headers.set('x-forwarded-for', client);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
|
|
62
|
-
const answer = await fetch(target, {
|
|
63
|
-
method: request.method,
|
|
64
|
-
headers,
|
|
65
|
-
body: hasBody ? await request.arrayBuffer() : undefined,
|
|
66
|
-
cache: 'no-store',
|
|
67
|
-
redirect: 'manual',
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
const out = new Headers();
|
|
71
|
-
for (const name of RESPONSE_HEADERS) {
|
|
72
|
-
const value = answer.headers.get(name);
|
|
73
|
-
if (value !== null) {
|
|
74
|
-
out.set(name, value);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return new Response(answer.status === 204 || answer.status === 304 ? null : answer.body, {
|
|
78
|
-
status: answer.status,
|
|
79
|
-
headers: out,
|
|
80
|
-
});
|
|
24
|
+
proxy ??= createStorefrontProxy({ upstream, credentials: credentialsMode() });
|
|
25
|
+
return proxy(request, (await params).path);
|
|
81
26
|
}
|
|
82
27
|
|
|
83
|
-
export {
|
|
28
|
+
export { handle as DELETE, handle as GET, handle as PATCH, handle as POST, handle as PUT };
|
package/template/lib/upstream.ts
CHANGED
|
@@ -10,3 +10,24 @@ export function upstreamBaseUrl(): string | null {
|
|
|
10
10
|
const domain = process.env.MAGICSTORE_SHOP_DOMAIN;
|
|
11
11
|
return domain ? `https://${domain}/api/v2/storefront` : null;
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Who holds the buyer's credentials. `'server'` (the default): `app/storefront-api` keeps the session
|
|
16
|
+
* tokens and the cart and checkout ids in httpOnly cookies, and the browser only sees "current".
|
|
17
|
+
* `MAGICSTORE_CREDENTIALS=browser` opts out. A public storefront token sends the browser straight to
|
|
18
|
+
* the API, past the proxy, so it means `'browser'` — and asking for `'server'` with one is an error.
|
|
19
|
+
*/
|
|
20
|
+
export function credentialsMode(): 'browser' | 'server' {
|
|
21
|
+
const explicit = process.env.MAGICSTORE_CREDENTIALS || null;
|
|
22
|
+
const publicToken = Boolean(process.env.NEXT_PUBLIC_MAGICSTORE_STOREFRONT_TOKEN);
|
|
23
|
+
if (explicit !== null && explicit !== 'server' && explicit !== 'browser') {
|
|
24
|
+
throw new Error(`MAGICSTORE_CREDENTIALS must be "server" or "browser", not "${explicit}".`);
|
|
25
|
+
}
|
|
26
|
+
if (explicit === 'server' && publicToken) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'MAGICSTORE_CREDENTIALS=server needs every browser call to go through app/storefront-api: ' +
|
|
29
|
+
'unset NEXT_PUBLIC_MAGICSTORE_STOREFRONT_TOKEN, or set MAGICSTORE_CREDENTIALS=browser.',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return explicit === 'browser' || publicToken ? 'browser' : 'server';
|
|
33
|
+
}
|
package/template/llms.txt
CHANGED
|
@@ -101,6 +101,18 @@ The cart id and the checkout id are credentials. Never log them and never put th
|
|
|
101
101
|
query string. A guest proves an order with the `X-Checkout-Id` header. The same goes for tokens,
|
|
102
102
|
OTP codes and full phone numbers.
|
|
103
103
|
|
|
104
|
+
Who holds them is a choice (`MagicStoreProvider credentials`):
|
|
105
|
+
|
|
106
|
+
- `'server'` — the starter's default. Every browser call goes through a same-origin
|
|
107
|
+
`createStorefrontProxy({ credentials: 'server' })` (`/server`; the starter's
|
|
108
|
+
`app/storefront-api`). It keeps the access and refresh tokens and the cart and checkout ids in
|
|
109
|
+
httpOnly cookies; JavaScript, `localStorage` and every body it sees hold the literal `"current"`
|
|
110
|
+
instead (`cart.id === 'current'`, `checkoutsStore({ body: { cartId: cart.id } })` just works). A
|
|
111
|
+
401 the proxy flags with `x-session-expired` refreshes once and retries once; the proxy never
|
|
112
|
+
refreshes on its own.
|
|
113
|
+
- `'browser'` — the tokens and the cart id live in `localStorage`. Required when a public
|
|
114
|
+
storefront token sends the browser straight to the API (the proxy is not in the path).
|
|
115
|
+
|
|
104
116
|
## Cart → checkout → payment
|
|
105
117
|
|
|
106
118
|
1. The first `useCart().addLine(…)` creates the cart; its id is stored for you. Every cart answer
|
|
@@ -142,6 +154,7 @@ for buyers), quote `error.requestId` when reporting. GET calls retry on 429 / 5x
|
|
|
142
154
|
| `OTP_INVALID` | Let the buyer re-type the code. |
|
|
143
155
|
| `OTP_EXPIRED` | Ask for a new code. |
|
|
144
156
|
| `NETWORK_ERROR`, `ABORTED` | No answer (client-side): offer to try again. |
|
|
157
|
+
| `INVALID_RESPONSE` | The API answered 2xx with an unreadable body: report it. |
|
|
145
158
|
|
|
146
159
|
The full list, with the `meta` each code carries, is in the backend's `docs/api/v2/standards.md`.
|
|
147
160
|
|