@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/dist/analytics/index.mjs +31 -8
- package/dist/{analytics-ids-o0a07Ohr.mjs → analytics-CgD70OqH.mjs} +1 -37
- package/dist/analytics-ids-COElZiI3.mjs +38 -0
- package/dist/auth/index.d.mts +43 -0
- package/dist/auth/index.mjs +58 -0
- package/dist/client-Xb-2tNKT.mjs +776 -0
- package/dist/http-DWaRhLfG.d.mts +55 -0
- package/dist/index.d.mts +7 -43
- package/dist/index.mjs +4 -762
- package/package.json +6 -1
- package/src/analytics/index.ts +32 -8
- package/src/auth/index.ts +74 -0
- package/src/client.ts +8 -1
- package/src/schemas/auth.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sayren/storefront-sdk",
|
|
3
|
-
"version": "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": {
|
package/src/analytics/index.ts
CHANGED
|
@@ -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
|
-
|
|
158
|
-
|
|
159
|
-
if (
|
|
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
|
-
|
|
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")
|
|
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 {
|
|
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),
|
package/src/schemas/auth.ts
CHANGED
|
@@ -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>;
|