@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.
Files changed (46) hide show
  1. package/dist/client.d.ts +48 -0
  2. package/dist/client.d.ts.map +1 -0
  3. package/dist/client.js +64 -0
  4. package/dist/client.js.map +1 -0
  5. package/dist/components/AddToCartButton.d.ts +41 -0
  6. package/dist/components/AddToCartButton.d.ts.map +1 -0
  7. package/dist/components/AddToCartButton.js +51 -0
  8. package/dist/components/AddToCartButton.js.map +1 -0
  9. package/dist/components/CartLineQuantityAdjustButton.d.ts +40 -0
  10. package/dist/components/CartLineQuantityAdjustButton.d.ts.map +1 -0
  11. package/dist/components/CartLineQuantityAdjustButton.js +58 -0
  12. package/dist/components/CartLineQuantityAdjustButton.js.map +1 -0
  13. package/dist/components/Image.d.ts +39 -0
  14. package/dist/components/Image.d.ts.map +1 -0
  15. package/dist/components/Image.js +28 -0
  16. package/dist/components/Image.js.map +1 -0
  17. package/dist/components/Money.d.ts +41 -0
  18. package/dist/components/Money.d.ts.map +1 -0
  19. package/dist/components/Money.js +48 -0
  20. package/dist/components/Money.js.map +1 -0
  21. package/dist/components/ProductPrice.d.ts +28 -0
  22. package/dist/components/ProductPrice.d.ts.map +1 -0
  23. package/dist/components/ProductPrice.js +10 -0
  24. package/dist/components/ProductPrice.js.map +1 -0
  25. package/dist/components/VariantSelector.d.ts +80 -0
  26. package/dist/components/VariantSelector.d.ts.map +1 -0
  27. package/dist/components/VariantSelector.js +87 -0
  28. package/dist/components/VariantSelector.js.map +1 -0
  29. package/dist/components/index.d.ts +24 -0
  30. package/dist/components/index.d.ts.map +1 -0
  31. package/dist/components/index.js +18 -0
  32. package/dist/components/index.js.map +1 -0
  33. package/dist/react.d.ts +98 -0
  34. package/dist/react.d.ts.map +1 -0
  35. package/dist/react.js +168 -0
  36. package/dist/react.js.map +1 -0
  37. package/package.json +33 -3
  38. package/src/client.tsx +103 -0
  39. package/src/components/AddToCartButton.tsx +90 -0
  40. package/src/components/CartLineQuantityAdjustButton.tsx +119 -0
  41. package/src/components/Image.tsx +93 -0
  42. package/src/components/Money.tsx +106 -0
  43. package/src/components/ProductPrice.tsx +61 -0
  44. package/src/components/VariantSelector.tsx +148 -0
  45. package/src/components/index.ts +33 -0
  46. 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
+ ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' }[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
+ }