@shopbb/helium 0.1.0 → 0.3.0-alpha.1
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/client.d.ts +48 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +64 -0
- package/dist/client.js.map +1 -0
- package/dist/components/AddToCartButton.d.ts +41 -0
- package/dist/components/AddToCartButton.d.ts.map +1 -0
- package/dist/components/AddToCartButton.js +51 -0
- package/dist/components/AddToCartButton.js.map +1 -0
- package/dist/components/CartLineQuantityAdjustButton.d.ts +40 -0
- package/dist/components/CartLineQuantityAdjustButton.d.ts.map +1 -0
- package/dist/components/CartLineQuantityAdjustButton.js +58 -0
- package/dist/components/CartLineQuantityAdjustButton.js.map +1 -0
- package/dist/components/Image.d.ts +39 -0
- package/dist/components/Image.d.ts.map +1 -0
- package/dist/components/Image.js +28 -0
- package/dist/components/Image.js.map +1 -0
- package/dist/components/Money.d.ts +41 -0
- package/dist/components/Money.d.ts.map +1 -0
- package/dist/components/Money.js +48 -0
- package/dist/components/Money.js.map +1 -0
- package/dist/components/ProductPrice.d.ts +28 -0
- package/dist/components/ProductPrice.d.ts.map +1 -0
- package/dist/components/ProductPrice.js +10 -0
- package/dist/components/ProductPrice.js.map +1 -0
- package/dist/components/VariantSelector.d.ts +80 -0
- package/dist/components/VariantSelector.d.ts.map +1 -0
- package/dist/components/VariantSelector.js +87 -0
- package/dist/components/VariantSelector.js.map +1 -0
- package/dist/components/index.d.ts +24 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +18 -0
- package/dist/components/index.js.map +1 -0
- package/dist/react.d.ts +98 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +168 -0
- package/dist/react.js.map +1 -0
- package/package.json +33 -3
- package/src/client.tsx +103 -0
- package/src/components/AddToCartButton.tsx +90 -0
- package/src/components/CartLineQuantityAdjustButton.tsx +119 -0
- package/src/components/Image.tsx +93 -0
- package/src/components/Money.tsx +106 -0
- package/src/components/ProductPrice.tsx +61 -0
- package/src/components/VariantSelector.tsx +148 -0
- package/src/components/index.ts +33 -0
- package/src/react.tsx +296 -0
package/src/react.tsx
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @shopbb/helium/react — React SSR renderer for Helium storefronts
|
|
3
|
+
*
|
|
4
|
+
* 用法(server worker entry):
|
|
5
|
+
*
|
|
6
|
+
* import { createReactRenderer, HeliumContextProvider } from '@shopbb/helium/react';
|
|
7
|
+
* import { App } from './app';
|
|
8
|
+
*
|
|
9
|
+
* export default {
|
|
10
|
+
* fetch: createReactRenderer({
|
|
11
|
+
* clientBundle: '/assets/client.js',
|
|
12
|
+
* title: '我的店铺',
|
|
13
|
+
* app: ({ ctx, url }) => (
|
|
14
|
+
* <HeliumContextProvider value={ctx}>
|
|
15
|
+
* <App url={url} />
|
|
16
|
+
* </HeliumContextProvider>
|
|
17
|
+
* ),
|
|
18
|
+
* }),
|
|
19
|
+
* };
|
|
20
|
+
*
|
|
21
|
+
* 行为:
|
|
22
|
+
* - 每个请求 createHeliumContext()
|
|
23
|
+
* - 拿 React 树调 react-dom/server.edge renderToReadableStream
|
|
24
|
+
* - 包一层 <!doctype html><html>...<body><div id="root">[SSR]</div><script src="clientBundle"></script>
|
|
25
|
+
* - 把 helium context 序列化嵌入 window.__HELIUM__ 给客户端 hydrate
|
|
26
|
+
*
|
|
27
|
+
* 客户端配套:见 ./client.ts
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import * as React from 'react';
|
|
31
|
+
import { renderToReadableStream } from 'react-dom/server';
|
|
32
|
+
import {
|
|
33
|
+
createHeliumContext,
|
|
34
|
+
cartGetIdDefault,
|
|
35
|
+
cartSetIdDefault,
|
|
36
|
+
} from './index';
|
|
37
|
+
import type { HeliumContext } from './types';
|
|
38
|
+
|
|
39
|
+
// ============================================================
|
|
40
|
+
// React Context for HeliumContext
|
|
41
|
+
// ============================================================
|
|
42
|
+
|
|
43
|
+
const HeliumReactContext = React.createContext<HeliumContext | null>(null);
|
|
44
|
+
|
|
45
|
+
export function HeliumContextProvider({
|
|
46
|
+
value,
|
|
47
|
+
children,
|
|
48
|
+
}: {
|
|
49
|
+
value: HeliumContext;
|
|
50
|
+
children: React.ReactNode;
|
|
51
|
+
}) {
|
|
52
|
+
return React.createElement(
|
|
53
|
+
HeliumReactContext.Provider,
|
|
54
|
+
{ value },
|
|
55
|
+
children,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Server-side hook: 拿当前请求的 HeliumContext。
|
|
61
|
+
* 只能在 React 组件树内调用。
|
|
62
|
+
*/
|
|
63
|
+
export function useHeliumContext(): HeliumContext {
|
|
64
|
+
const ctx = React.useContext(HeliumReactContext);
|
|
65
|
+
if (!ctx) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'useHeliumContext() must be called within <HeliumContextProvider>',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return ctx;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============================================================
|
|
74
|
+
// Renderer
|
|
75
|
+
// ============================================================
|
|
76
|
+
|
|
77
|
+
export interface ReactRendererOptions {
|
|
78
|
+
/**
|
|
79
|
+
* 返回 React 元素的工厂函数。
|
|
80
|
+
* 每个请求会调用一次。
|
|
81
|
+
* 内部应该用 <HeliumContextProvider value={ctx}> 包裹应用根。
|
|
82
|
+
*/
|
|
83
|
+
app: (input: {
|
|
84
|
+
ctx: HeliumContext;
|
|
85
|
+
url: URL;
|
|
86
|
+
request: Request;
|
|
87
|
+
}) => React.ReactElement | Promise<React.ReactElement>;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 浏览器加载的客户端 JS bundle URL,
|
|
91
|
+
* 例如 '/assets/client.js'。Vite 构建后通常会带 hash。
|
|
92
|
+
*
|
|
93
|
+
* 不传则只 SSR、不 hydrate(适合纯 SEO 场景)。
|
|
94
|
+
*/
|
|
95
|
+
clientBundle?: string;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 注入到 <head> 的 CSS bundle URL。
|
|
99
|
+
*/
|
|
100
|
+
cssBundle?: string;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* <title>
|
|
104
|
+
*/
|
|
105
|
+
title?: string;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* <head> 里额外的 HTML(meta、link、script)。
|
|
109
|
+
*/
|
|
110
|
+
headHtml?: string;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Storefront 配置(覆盖默认 header 解析行为)。
|
|
114
|
+
* 如不传,会自动从 Gateway 注入的 header 读:
|
|
115
|
+
* X-Public-Storefront-Token / X-Private-Storefront-Token / X-Store-Id
|
|
116
|
+
*/
|
|
117
|
+
storefront?: {
|
|
118
|
+
apiUrl?: string;
|
|
119
|
+
publicAccessToken?: string;
|
|
120
|
+
privateAccessToken?: string;
|
|
121
|
+
storeId?: string;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Cart 配置(默认走 cookie)。
|
|
126
|
+
*/
|
|
127
|
+
cart?: {
|
|
128
|
+
maxAgeSeconds?: number;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 是否启用 streaming SSR(默认 false,等全部渲染完)。
|
|
133
|
+
* 启用后首字节更快,但要求所有数据加载都用 Suspense。
|
|
134
|
+
*/
|
|
135
|
+
streaming?: boolean;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 处理错误的兜底(异常时返回的响应)。
|
|
139
|
+
*/
|
|
140
|
+
onError?: (err: unknown, request: Request) => Response | Promise<Response>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createReactRenderer(opts: ReactRendererOptions) {
|
|
144
|
+
return async function fetch(
|
|
145
|
+
request: Request,
|
|
146
|
+
env: any,
|
|
147
|
+
executionContext: ExecutionContext,
|
|
148
|
+
): Promise<Response> {
|
|
149
|
+
try {
|
|
150
|
+
// 1. 装配 HeliumContext
|
|
151
|
+
const storefrontApiUrl =
|
|
152
|
+
opts.storefront?.apiUrl ||
|
|
153
|
+
env?.PUBLIC_STOREFRONT_API_URL ||
|
|
154
|
+
'https://api.oxygen-demo.cloudc.top/api/2026-04/graphql.json';
|
|
155
|
+
|
|
156
|
+
const publicToken =
|
|
157
|
+
opts.storefront?.publicAccessToken ||
|
|
158
|
+
request.headers.get('X-Public-Storefront-Token') ||
|
|
159
|
+
'';
|
|
160
|
+
const privateToken =
|
|
161
|
+
opts.storefront?.privateAccessToken ||
|
|
162
|
+
request.headers.get('X-Private-Storefront-Token') ||
|
|
163
|
+
undefined;
|
|
164
|
+
const storeId =
|
|
165
|
+
opts.storefront?.storeId ||
|
|
166
|
+
request.headers.get('X-Store-Id') ||
|
|
167
|
+
'';
|
|
168
|
+
|
|
169
|
+
const ctx = createHeliumContext({
|
|
170
|
+
request,
|
|
171
|
+
env,
|
|
172
|
+
executionContext,
|
|
173
|
+
storefront: {
|
|
174
|
+
apiUrl: storefrontApiUrl,
|
|
175
|
+
publicAccessToken: publicToken,
|
|
176
|
+
privateAccessToken: privateToken,
|
|
177
|
+
storeId,
|
|
178
|
+
cache: typeof caches !== 'undefined'
|
|
179
|
+
? await caches.open('helium-storefront').catch(() => undefined as any)
|
|
180
|
+
: undefined,
|
|
181
|
+
},
|
|
182
|
+
cart: {
|
|
183
|
+
getId: cartGetIdDefault(request.headers),
|
|
184
|
+
setId: cartSetIdDefault({
|
|
185
|
+
maxage: opts.cart?.maxAgeSeconds ?? 60 * 60 * 24 * 365,
|
|
186
|
+
}),
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// 2. 拿 React 元素
|
|
191
|
+
const url = new URL(request.url);
|
|
192
|
+
const element = await opts.app({ ctx, url, request });
|
|
193
|
+
|
|
194
|
+
// 3. SSR Boot data:把店铺标识 + cart-id 提前传给客户端
|
|
195
|
+
const boot = {
|
|
196
|
+
storefront: {
|
|
197
|
+
apiUrl: storefrontApiUrl,
|
|
198
|
+
// 仅 public token 暴露到客户端
|
|
199
|
+
publicAccessToken: publicToken,
|
|
200
|
+
storeId,
|
|
201
|
+
},
|
|
202
|
+
url: request.url,
|
|
203
|
+
// 不传 token 等敏感字段
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const bootScript = `<script>window.__HELIUM__=${JSON.stringify(boot).replace(
|
|
207
|
+
/</g,
|
|
208
|
+
'\\u003c',
|
|
209
|
+
)}</script>`;
|
|
210
|
+
|
|
211
|
+
// 4. Render React 树
|
|
212
|
+
const wrappedApp = React.createElement(
|
|
213
|
+
HeliumReactContext.Provider,
|
|
214
|
+
{ value: ctx },
|
|
215
|
+
element,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const stream = await renderToReadableStream(wrappedApp, {
|
|
219
|
+
onError(err) {
|
|
220
|
+
console.error('[helium SSR error]', err);
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// 等所有数据加载完成(除非启用 streaming)
|
|
225
|
+
if (!opts.streaming) {
|
|
226
|
+
await stream.allReady;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 5. 拼接最终响应
|
|
230
|
+
const head = `<!doctype html>
|
|
231
|
+
<html lang="zh-CN">
|
|
232
|
+
<head>
|
|
233
|
+
<meta charset="UTF-8" />
|
|
234
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
235
|
+
${opts.title ? `<title>${escapeHtml(opts.title)}</title>` : ''}
|
|
236
|
+
${opts.cssBundle ? `<link rel="stylesheet" href="${opts.cssBundle}" />` : ''}
|
|
237
|
+
${opts.headHtml ?? ''}
|
|
238
|
+
</head>
|
|
239
|
+
<body>
|
|
240
|
+
<div id="root">`;
|
|
241
|
+
|
|
242
|
+
const tail = `</div>${bootScript}${
|
|
243
|
+
opts.clientBundle
|
|
244
|
+
? `<script type="module" src="${opts.clientBundle}"></script>`
|
|
245
|
+
: ''
|
|
246
|
+
}</body></html>`;
|
|
247
|
+
|
|
248
|
+
// 合并 stream
|
|
249
|
+
const body = composeStream(head, stream, tail);
|
|
250
|
+
|
|
251
|
+
const headers = new Headers(ctx.responseHeaders);
|
|
252
|
+
headers.set('Content-Type', 'text/html; charset=utf-8');
|
|
253
|
+
headers.set('X-Powered-By', '@shopbb/helium');
|
|
254
|
+
|
|
255
|
+
return new Response(body, { headers });
|
|
256
|
+
} catch (err) {
|
|
257
|
+
if (opts.onError) return opts.onError(err, request);
|
|
258
|
+
console.error('[helium] unhandled', err);
|
|
259
|
+
return new Response('Internal Error', { status: 500 });
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ============================================================
|
|
265
|
+
// helpers
|
|
266
|
+
// ============================================================
|
|
267
|
+
|
|
268
|
+
function escapeHtml(s: string): string {
|
|
269
|
+
return s.replace(/[<>&"']/g, (c) =>
|
|
270
|
+
({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c]!),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 把 head + react stream + tail 拼成一个 ReadableStream<Uint8Array>
|
|
276
|
+
*/
|
|
277
|
+
function composeStream(
|
|
278
|
+
head: string,
|
|
279
|
+
body: ReadableStream<Uint8Array>,
|
|
280
|
+
tail: string,
|
|
281
|
+
): ReadableStream<Uint8Array> {
|
|
282
|
+
const encoder = new TextEncoder();
|
|
283
|
+
return new ReadableStream({
|
|
284
|
+
async start(controller) {
|
|
285
|
+
controller.enqueue(encoder.encode(head));
|
|
286
|
+
const reader = body.getReader();
|
|
287
|
+
while (true) {
|
|
288
|
+
const { value, done } = await reader.read();
|
|
289
|
+
if (done) break;
|
|
290
|
+
controller.enqueue(value);
|
|
291
|
+
}
|
|
292
|
+
controller.enqueue(encoder.encode(tail));
|
|
293
|
+
controller.close();
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
}
|