@sayren/storefront-sdk 0.2.0 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/storefront-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
@@ -13,6 +13,11 @@
13
13
  "development": "./src/analytics/index.ts",
14
14
  "types": "./dist/analytics/index.d.mts",
15
15
  "default": "./dist/analytics/index.mjs"
16
+ },
17
+ "./auth": {
18
+ "development": "./src/auth/index.ts",
19
+ "types": "./dist/auth/index.d.mts",
20
+ "default": "./dist/auth/index.mjs"
16
21
  }
17
22
  },
18
23
  "dependencies": {
@@ -84,6 +84,11 @@ const SESSION_CAMPAIGN_COOKIE = "sy_scmp";
84
84
  const MAX_BATCH = 50;
85
85
  /** 이벤트 이름당 분당 상한 — 무한 루프 버그가 트래픽을 만들지 못하게 */
86
86
  const PER_NAME_PER_MINUTE = 30;
87
+ /**
88
+ * 전체 분당 상한 — 서버의 방문자당 분당 한도(120)보다 낮게 둬, SDK가 보낸 이벤트가 서버 한도에 걸려
89
+ * 조용히 버려지지 않게 한다(이름당 상한만으로는 이름 5종 × 30 = 150까지 갈 수 있다).
90
+ */
91
+ const TOTAL_PER_MINUTE = 100;
87
92
  const MAX_QUEUE = 500;
88
93
 
89
94
  const DEV_HOST =
@@ -110,9 +115,15 @@ export function createAnalyticsWith(options: AnalyticsOptions, env: AnalyticsEnv
110
115
  let visibleMs = 0;
111
116
  let engagementSent = false;
112
117
  let firstPage = true;
118
+ /**
119
+ * 지금 페이지뷰를 보냈는가 — 상한으로 페이지뷰가 버려졌으면 그 페이지의 상품 조회·목록·검색·체류도 보내지 않는다.
120
+ * 그러지 않으면 존재하지 않는 페이지뷰를 가리키는 이벤트가 남는다.
121
+ */
122
+ let pageAccepted = false;
113
123
  const seen = new Set<string>();
114
124
  let rateMinute = -1;
115
125
  const perName = new Map<string, number>();
126
+ let totalThisMinute = 0;
116
127
  const cleanups: Array<() => void> = [];
117
128
 
118
129
  const campaignOf = (href: string) => {
@@ -147,20 +158,24 @@ export function createAnalyticsWith(options: AnalyticsOptions, env: AnalyticsEnv
147
158
  if (minute !== rateMinute) {
148
159
  rateMinute = minute;
149
160
  perName.clear();
161
+ totalThisMinute = 0;
150
162
  }
151
163
  const used = perName.get(name) ?? 0;
152
- if (used >= PER_NAME_PER_MINUTE) return false;
164
+ if (used >= PER_NAME_PER_MINUTE || totalThisMinute >= TOTAL_PER_MINUTE) return false;
153
165
  perName.set(name, used + 1);
166
+ totalThisMinute += 1;
154
167
  return true;
155
168
  };
156
169
 
157
- const push = (event: Pending, dedupeKey: string) => {
158
- if (!enabled || consent === "denied") return;
159
- if (seen.has(dedupeKey) || !allowRate(event.name)) return;
170
+ /** 큐에 넣었으면 true — 중복·상한·거부로 버리면 false */
171
+ const push = (event: Pending, dedupeKey: string): boolean => {
172
+ if (!enabled || consent === "denied") return false;
173
+ if (seen.has(dedupeKey) || !allowRate(event.name)) return false;
160
174
  seen.add(dedupeKey);
161
175
  if (queue.length >= MAX_QUEUE) queue.shift();
162
176
  queue.push(event);
163
177
  if (queue.length >= MAX_BATCH) flush();
178
+ return true;
164
179
  };
165
180
 
166
181
  const flush = () => {
@@ -183,7 +198,7 @@ export function createAnalyticsWith(options: AnalyticsOptions, env: AnalyticsEnv
183
198
 
184
199
  /** 지금 페이지의 체류를 한 번 보낸다(이동·숨김·닫힘 중 먼저 오는 때) */
185
200
  const endPage = () => {
186
- if (!pageViewId || engagementSent) return;
201
+ if (!pageViewId || engagementSent || !pageAccepted) return;
187
202
  if (visibleSince !== null) {
188
203
  visibleMs += env.now() - visibleSince;
189
204
  visibleSince = env.visible() ? env.now() : null;
@@ -219,8 +234,7 @@ export function createAnalyticsWith(options: AnalyticsOptions, env: AnalyticsEnv
219
234
  visibleSince = env.visible() ? env.now() : null;
220
235
  engagementSent = false;
221
236
  const referrer = firstPage ? env.referrer() : "";
222
- firstPage = false;
223
- push(
237
+ pageAccepted = push(
224
238
  {
225
239
  name: "page_view",
226
240
  occurredAt: occurredAt(),
@@ -230,6 +244,8 @@ export function createAnalyticsWith(options: AnalyticsOptions, env: AnalyticsEnv
230
244
  },
231
245
  `page:${pageViewId}`,
232
246
  );
247
+ // 외부 유입(리퍼러)은 실제로 보낸 첫 페이지뷰에만 싣는다 — 거부 중 버린 페이지뷰가 가져가지 않게
248
+ if (pageAccepted) firstPage = false;
233
249
  };
234
250
 
235
251
  /** 프리렌더·숨은 탭은 화면에 보이는 순간 첫 페이지뷰를 보낸다 */
@@ -268,6 +284,7 @@ export function createAnalyticsWith(options: AnalyticsOptions, env: AnalyticsEnv
268
284
  return {
269
285
  track(input) {
270
286
  if (!pageViewId) startPage();
287
+ if (!pageAccepted) return; // 페이지뷰가 버려진 페이지의 행동은 보내지 않는다
271
288
  const common = { occurredAt: occurredAt(), url: env.href(), pageViewId };
272
289
  switch (input.name) {
273
290
  case "product_view":
@@ -308,7 +325,14 @@ export function createAnalyticsWith(options: AnalyticsOptions, env: AnalyticsEnv
308
325
  setConsent(next) {
309
326
  consent = next;
310
327
  if (next === "denied") queue = [];
311
- if (next === "granted") flush();
328
+ if (next === "granted") {
329
+ // 거부 중에 연 페이지는 페이지뷰가 없다 — 동의한 지금 그 페이지를 새로 센다
330
+ if (pageViewId && !pageAccepted && options.autoPageViews !== false) {
331
+ pageKey = "";
332
+ startPage();
333
+ }
334
+ flush();
335
+ }
312
336
  },
313
337
  ids: () => (enabled ? ensureIds() : null),
314
338
  flush,
@@ -0,0 +1,74 @@
1
+ import { createStorefrontClient } from "../client";
2
+ import { ApiError } from "../http";
3
+ import type { AnonymousSession, LoginRequest, SignupRequest, TokenPair } from "../schemas/auth";
4
+
5
+ /** 실패는 모두 이 클래스다 — 메인 엔트리의 `ApiError`와 같은 클래스라 어느 쪽으로 `instanceof`해도 된다 */
6
+ export { ApiError } from "../http";
7
+ export type { AnonymousSession, LoginRequest, SignupRequest, TokenPair } from "../schemas/auth";
8
+
9
+ export interface StorefrontAuthOptions {
10
+ /** 예: https://api.sayren.app/storefront/v1 */
11
+ baseUrl: string;
12
+ /** 테넌트 스토어 코드 — `X-Store-Code` 헤더로 보낸다 */
13
+ storeCode: string;
14
+ fetch?: typeof globalThis.fetch;
15
+ }
16
+
17
+ /**
18
+ * 구매자 인증 — 화면은 스토어프런트가 그리고, 계정 저장과 인증 처리는 sayren이 한다.
19
+ * 어느 방식으로 로그인해도 결과는 같은 구매자 토큰(`TokenPair`)이고 `/me/*`·장바구니·주문 API에 그대로 쓴다.
20
+ *
21
+ * 토큰 보관은 앱 몫이다. 서버(loader·action)에서 호출하고 httpOnly 쿠키에 두기를 권한다.
22
+ *
23
+ * ```ts
24
+ * const auth = createStorefrontAuth({ baseUrl, storeCode });
25
+ * const guest = await auth.anonymous(); // 비회원: 장바구니 토큰
26
+ * const tokens = await auth.session({ email, password, cartToken: guest.cartToken });
27
+ * ```
28
+ */
29
+ export function createStorefrontAuth(options: StorefrontAuthOptions) {
30
+ const client = (accessToken?: string) =>
31
+ createStorefrontClient({
32
+ baseUrl: options.baseUrl,
33
+ storeCode: options.storeCode,
34
+ accessToken,
35
+ fetch: options.fetch,
36
+ });
37
+
38
+ return {
39
+ /**
40
+ * 비회원 익명 세션 — 구매자 계정을 만들지 않는다. `cartToken`을 `X-Cart-Token`으로 보내면 장바구니·
41
+ * 비회원 주문이 쌓이고, `session()`·`signUp()`에 실으면 회원 장바구니로 병합된 뒤 닫힌다.
42
+ * 마지막 활동 후 30일이 지나면 만료된다.
43
+ */
44
+ anonymous(): Promise<AnonymousSession> {
45
+ return client().auth.anonymous();
46
+ },
47
+ /** 이메일·비밀번호 로그인. `cartToken`을 주면 비회원 장바구니를 병합한다 */
48
+ session(body: LoginRequest): Promise<TokenPair> {
49
+ return client().auth.login(body);
50
+ },
51
+ /** 회원가입 — 가입과 동시에 로그인 토큰을 받는다. `cartToken`을 주면 장바구니를 병합한다 */
52
+ signUp(body: SignupRequest): Promise<TokenPair> {
53
+ return client().auth.signup(body);
54
+ },
55
+ /** 토큰 갱신 — refreshToken은 한 번 쓰면 폐기되고 새 쌍이 온다(회전) */
56
+ refresh(refreshToken: string): Promise<TokenPair> {
57
+ return client().auth.refresh(refreshToken);
58
+ },
59
+ /**
60
+ * 로그아웃 — 이 구매자의 refreshToken을 모두 폐기한다. 앱은 보관한 토큰을 지운다.
61
+ * 액세스 토큰이 이미 만료됐으면(401) 서버에서 할 일이 없으므로 성공으로 본다.
62
+ */
63
+ async signOut(accessToken: string): Promise<void> {
64
+ try {
65
+ await client(accessToken).auth.logout();
66
+ } catch (error) {
67
+ if (error instanceof ApiError && error.status === 401) return;
68
+ throw error;
69
+ }
70
+ },
71
+ };
72
+ }
73
+
74
+ export type StorefrontAuth = ReturnType<typeof createStorefrontAuth>;
package/src/client.ts CHANGED
@@ -2,7 +2,12 @@ import { z } from "zod";
2
2
  import { createHttp, path, type Query, resolveToken, type TokenSource } from "./http";
3
3
  import { ANALYTICS_SESSION_HEADER, ANALYTICS_VISITOR_HEADER } from "./schemas/analytics";
4
4
  import type { LoginRequest, SignupRequest } from "./schemas/auth";
5
- import { loginRequestSchema, signupRequestSchema, tokenPairSchema } from "./schemas/auth";
5
+ import {
6
+ anonymousSessionSchema,
7
+ loginRequestSchema,
8
+ signupRequestSchema,
9
+ tokenPairSchema,
10
+ } from "./schemas/auth";
6
11
  import {
7
12
  type AddCartItemRequest,
8
13
  addCartItemRequestSchema,
@@ -163,6 +168,8 @@ export function createStorefrontClient(options: StorefrontClientOptions) {
163
168
  return session.userinfo();
164
169
  },
165
170
  auth: {
171
+ /** 비회원 익명 세션 발급 — 응답의 cartToken을 `cartToken` 옵션(X-Cart-Token)으로 쓴다 */
172
+ anonymous: () => http.request("POST", "/auth/anonymous", anonymousSessionSchema),
166
173
  signup: (body: SignupRequest) =>
167
174
  http.request("POST", "/auth/signup", tokenPairSchema, {
168
175
  body: signupRequestSchema.parse(body),
@@ -28,6 +28,8 @@ export const signupRequestSchema = z.object({
28
28
  privacy: z.literal(true),
29
29
  marketing: z.boolean().default(false),
30
30
  }),
31
+ /** 비회원 장바구니 토큰 — 가입과 함께 회원 장바구니로 옮긴다 */
32
+ cartToken: z.string().optional(),
31
33
  });
32
34
 
33
35
  export type SignupRequest = z.infer<typeof signupRequestSchema>;
@@ -39,3 +41,16 @@ export const loginRequestSchema = z.object({
39
41
  });
40
42
 
41
43
  export type LoginRequest = z.infer<typeof loginRequestSchema>;
44
+
45
+ /**
46
+ * 비회원 익명 세션 — `POST /auth/anonymous` 응답. `cartToken`을 `X-Cart-Token` 헤더로 보내면 장바구니와
47
+ * 비회원 주문이 이 세션에 쌓인다. 구매자 계정이 아니므로 `/me/*`에는 쓸 수 없다.
48
+ * 마지막 활동 후 30일이 지나거나 로그인·가입으로 회원 장바구니에 병합되면 더는 쓸 수 없다.
49
+ */
50
+ export const anonymousSessionSchema = z.object({
51
+ cartToken: z.string(),
52
+ /** 이 시각까지 활동이 없으면 만료(ISO 8601). 쓸 때마다 늘어난다 */
53
+ expiresAt: z.string(),
54
+ });
55
+
56
+ export type AnonymousSession = z.infer<typeof anonymousSessionSchema>;