@shopbb/helium 0.1.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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * createStorefrontClient
3
+ *
4
+ * A thin GraphQL client wrapper around fetch(), with caching.
5
+ *
6
+ * 行为对齐 Shopify Hydrogen 的 storefront client:
7
+ * - query() 可缓存(CacheLong / CacheShort / etc.)
8
+ * - mutate() 不缓存
9
+ * - 自动注入 X-Storefront-Access-Token header
10
+ * - GraphQL errors 非空时抛出
11
+ *
12
+ * 内部缓存策略:
13
+ * - 用 caches.default 或传入的 cache 对象(Cache API)
14
+ * - cache key = SHA256(query + variables + storeId)
15
+ * - 命中 / SWR / MISS 模式区分
16
+ */
17
+
18
+ import type {
19
+ StorefrontClient,
20
+ StorefrontClientOptions,
21
+ QueryOptions,
22
+ MutateOptions,
23
+ CacheStrategy,
24
+ } from './types';
25
+ import { CacheNone, CacheShort, CacheLong, CacheCustom } from './cache';
26
+
27
+ export function createStorefrontClient(options: StorefrontClientOptions): StorefrontClient {
28
+ const {
29
+ apiUrl,
30
+ publicAccessToken,
31
+ privateAccessToken,
32
+ storeId,
33
+ cache,
34
+ waitUntil,
35
+ } = options;
36
+
37
+ const headers = (): Record<string, string> => ({
38
+ 'Content-Type': 'application/json',
39
+ Accept: 'application/json',
40
+ 'X-Storefront-Access-Token': privateAccessToken || publicAccessToken,
41
+ 'X-Store-Id': storeId,
42
+ });
43
+
44
+ /**
45
+ * Compute a stable cache key for a query + variables.
46
+ */
47
+ async function cacheKeyFor(query: string, variables: any): Promise<Request> {
48
+ const payload = JSON.stringify({ q: query, v: variables ?? {}, s: storeId });
49
+ const hashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
50
+ const hashHex = Array.from(new Uint8Array(hashBuf))
51
+ .map((b) => b.toString(16).padStart(2, '0'))
52
+ .join('');
53
+ return new Request(`https://helium-cache.invalid/${storeId}/${hashHex}`, { method: 'GET' });
54
+ }
55
+
56
+ async function executeGraphQL<TData>(
57
+ query: string,
58
+ variables: any | undefined,
59
+ ): Promise<TData> {
60
+ const body = JSON.stringify({ query, variables });
61
+ const res = await fetch(apiUrl, {
62
+ method: 'POST',
63
+ headers: headers(),
64
+ body,
65
+ });
66
+ if (!res.ok) {
67
+ throw new Error(`Storefront API HTTP ${res.status}: ${await res.text()}`);
68
+ }
69
+ const json: any = await res.json();
70
+ if (json.errors && json.errors.length > 0) {
71
+ const messages = json.errors.map((e: any) => e.message).join('; ');
72
+ throw new Error(`Storefront GraphQL errors: ${messages}`);
73
+ }
74
+ return json.data as TData;
75
+ }
76
+
77
+ async function query<TData = any>(q: string, opts: QueryOptions = {}): Promise<TData> {
78
+ const cacheStrategy: CacheStrategy = opts.cache ?? CacheNone();
79
+
80
+ // No caching → direct call
81
+ if (cacheStrategy.mode === 'NONE' || !cache) {
82
+ return executeGraphQL<TData>(q, opts.variables);
83
+ }
84
+
85
+ const key = await cacheKeyFor(q, opts.variables);
86
+ const cached = await cache.match(key);
87
+ const now = Date.now();
88
+
89
+ if (cached) {
90
+ const ttl = Number(cached.headers.get('helium-ttl') ?? '0');
91
+ const cachedAt = Number(cached.headers.get('helium-cached-at') ?? '0');
92
+ const age = (now - cachedAt) / 1000;
93
+
94
+ if (age < ttl) {
95
+ // Fresh
96
+ return (await cached.json()) as TData;
97
+ }
98
+ // Stale, return stale-while-revalidate if configured
99
+ const swr = cacheStrategy.staleWhileRevalidate ?? 0;
100
+ if (age < ttl + swr) {
101
+ // Trigger background revalidation
102
+ if (waitUntil) {
103
+ waitUntil(refreshCache(q, opts.variables, key, cacheStrategy));
104
+ }
105
+ return (await cached.json()) as TData;
106
+ }
107
+ // Expired
108
+ }
109
+
110
+ const data = await executeGraphQL<TData>(q, opts.variables);
111
+ // Async write to cache
112
+ if (waitUntil) {
113
+ waitUntil(writeCache(key, data, cacheStrategy));
114
+ } else {
115
+ // Best-effort sync
116
+ await writeCache(key, data, cacheStrategy);
117
+ }
118
+ return data;
119
+ }
120
+
121
+ async function refreshCache(
122
+ q: string,
123
+ variables: any,
124
+ key: Request,
125
+ strategy: CacheStrategy,
126
+ ): Promise<void> {
127
+ try {
128
+ const data = await executeGraphQL(q, variables);
129
+ await writeCache(key, data, strategy);
130
+ } catch (e) {
131
+ // Swallow errors during background refresh
132
+ console.error('[helium] cache refresh failed', e);
133
+ }
134
+ }
135
+
136
+ async function writeCache(key: Request, data: any, strategy: CacheStrategy): Promise<void> {
137
+ if (!cache) return;
138
+ const ttl = strategy.maxAge;
139
+ const swr = strategy.staleWhileRevalidate ?? 0;
140
+ const totalAge = ttl + swr;
141
+ const resp = new Response(JSON.stringify(data), {
142
+ headers: {
143
+ 'Content-Type': 'application/json',
144
+ 'Cache-Control': `public, max-age=${totalAge}`,
145
+ 'helium-ttl': String(ttl),
146
+ 'helium-cached-at': String(Date.now()),
147
+ },
148
+ });
149
+ await cache.put(key, resp);
150
+ }
151
+
152
+ async function mutate<TData = any>(m: string, opts: MutateOptions = {}): Promise<TData> {
153
+ return executeGraphQL<TData>(m, opts.variables);
154
+ }
155
+
156
+ return {
157
+ query,
158
+ mutate,
159
+ CacheNone,
160
+ CacheShort,
161
+ CacheLong,
162
+ CacheCustom,
163
+ };
164
+ }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @shopbb/helium
3
+ *
4
+ * shopbb's storefront framework. Hydrogen-equivalent for the shopbb platform.
5
+ *
6
+ * Public API:
7
+ *
8
+ * - createHeliumContext Top-level factory
9
+ * - createStorefrontClient GraphQL client (used by createHeliumContext)
10
+ * - createCartHandler Cart operations (used by createHeliumContext)
11
+ * - cartGetIdDefault Default cart ID getter (reads cookie)
12
+ * - cartSetIdDefault Default cart ID setter (writes Set-Cookie)
13
+ * - CacheNone / CacheShort / CacheLong / CacheCustom
14
+ */
15
+
16
+ export { createHeliumContext } from './createHeliumContext';
17
+ export { createStorefrontClient } from './createStorefrontClient';
18
+ export { createCartHandler, DEFAULT_CART_FRAGMENT } from './createCartHandler';
19
+ export { cartGetIdDefault, cartSetIdDefault } from './cart-id';
20
+ export type { CartSetIdOptions } from './cart-id';
21
+ export { CacheNone, CacheShort, CacheLong, CacheCustom } from './cache';
22
+
23
+ // Re-export all types
24
+ export type {
25
+ CacheStrategy,
26
+ StorefrontClient,
27
+ StorefrontClientOptions,
28
+ QueryOptions,
29
+ MutateOptions,
30
+ CartHandler,
31
+ CartHandlerOptions,
32
+ CartLineInput,
33
+ CartLineUpdateInput,
34
+ CartUserError,
35
+ CartResult,
36
+ CartIdGetter,
37
+ CartIdSetter,
38
+ HeliumContext,
39
+ HeliumContextOptions,
40
+ } from './types';
package/src/types.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Helium shared types.
3
+ *
4
+ * 这些类型在 storefront client / cart handler / context 之间共享。
5
+ * 对应 shopbb Storefront API GraphQL schema 的核心类型。
6
+ */
7
+
8
+ // ============================================================
9
+ // Cache
10
+ // ============================================================
11
+
12
+ export interface CacheStrategy {
13
+ mode: 'NONE' | 'PUBLIC';
14
+ /** CDN/edge cache TTL in seconds */
15
+ maxAge: number;
16
+ /** SWR window in seconds */
17
+ staleWhileRevalidate?: number;
18
+ }
19
+
20
+ // ============================================================
21
+ // Storefront Client
22
+ // ============================================================
23
+
24
+ export interface StorefrontClientOptions {
25
+ /** GraphQL endpoint URL */
26
+ apiUrl: string;
27
+ /** Public Storefront API access token */
28
+ publicAccessToken: string;
29
+ /** Private Storefront API access token (preferred when available) */
30
+ privateAccessToken?: string;
31
+ /** Store ID */
32
+ storeId: string;
33
+ /** Original request, used to forward identifying headers if needed */
34
+ request?: Request;
35
+ /** Cache instance (e.g. caches.default or caches.open(...)) */
36
+ cache?: Cache;
37
+ /** waitUntil from ExecutionContext for SWR */
38
+ waitUntil?: (promise: Promise<any>) => void;
39
+ }
40
+
41
+ export interface QueryOptions {
42
+ variables?: Record<string, any>;
43
+ cache?: CacheStrategy;
44
+ }
45
+
46
+ export interface MutateOptions {
47
+ variables?: Record<string, any>;
48
+ }
49
+
50
+ export interface StorefrontClient {
51
+ query<TData = any>(query: string, options?: QueryOptions): Promise<TData>;
52
+ mutate<TData = any>(mutation: string, options?: MutateOptions): Promise<TData>;
53
+ CacheNone(): CacheStrategy;
54
+ CacheShort(): CacheStrategy;
55
+ CacheLong(): CacheStrategy;
56
+ CacheCustom(seconds: number): CacheStrategy;
57
+ }
58
+
59
+ // ============================================================
60
+ // Cart
61
+ // ============================================================
62
+
63
+ export type CartIdGetter = () => string | null;
64
+ export type CartIdSetter = (cartId: string, responseHeaders: Headers) => void;
65
+
66
+ export interface CartHandlerOptions {
67
+ storefront: StorefrontClient;
68
+ getCartId: CartIdGetter;
69
+ /** setCartId 会写入 responseHeaders(注入 Set-Cookie) */
70
+ setCartId: CartIdSetter;
71
+ responseHeaders: Headers;
72
+ }
73
+
74
+ export interface CartLineInput {
75
+ merchandiseId: string;
76
+ quantity: number;
77
+ }
78
+
79
+ export interface CartLineUpdateInput {
80
+ id: string;
81
+ quantity: number;
82
+ }
83
+
84
+ export interface CartUserError {
85
+ field?: string[] | null;
86
+ message: string;
87
+ code?: string | null;
88
+ }
89
+
90
+ export interface CartResult<TCart = any> {
91
+ cart: TCart | null;
92
+ userErrors: CartUserError[];
93
+ }
94
+
95
+ export interface CartHandler<TCart = any> {
96
+ get(): Promise<TCart | null>;
97
+ getCartId(): string | null;
98
+ setCartId(cartId: string): void;
99
+ create(input?: { lines?: CartLineInput[] }): Promise<CartResult<TCart>>;
100
+ addLines(lines: CartLineInput[]): Promise<CartResult<TCart>>;
101
+ updateLines(lines: CartLineUpdateInput[]): Promise<CartResult<TCart>>;
102
+ removeLines(lineIds: string[]): Promise<CartResult<TCart>>;
103
+ }
104
+
105
+ // ============================================================
106
+ // Helium Context
107
+ // ============================================================
108
+
109
+ export interface HeliumContextOptions {
110
+ request: Request;
111
+ env: Record<string, any>;
112
+ executionContext: ExecutionContext;
113
+ storefront: Omit<StorefrontClientOptions, 'request' | 'cache' | 'waitUntil'> & {
114
+ request?: Request;
115
+ cache?: Cache;
116
+ waitUntil?: (p: Promise<any>) => void;
117
+ };
118
+ cart?: {
119
+ getId: CartIdGetter;
120
+ setId: (cartId: string, headers: Headers) => void;
121
+ /** Optional custom GraphQL fragment for the Cart return type */
122
+ cartFragment?: string;
123
+ };
124
+ }
125
+
126
+ export interface HeliumContext {
127
+ env: Record<string, any>;
128
+ request: Request;
129
+ storefront: StorefrontClient;
130
+ cart: CartHandler;
131
+ /** Headers that should be merged into the outgoing response (e.g. Set-Cookie) */
132
+ responseHeaders: Headers;
133
+ }