@sayren/storefront-sdk 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.
- package/dist/index.d.mts +1383 -0
- package/dist/index.mjs +880 -0
- package/package.json +43 -0
- package/src/client.ts +349 -0
- package/src/http.ts +178 -0
- package/src/index.ts +22 -0
- package/src/schemas/auth.ts +41 -0
- package/src/schemas/cart.ts +47 -0
- package/src/schemas/catalog.ts +101 -0
- package/src/schemas/checkout.ts +183 -0
- package/src/schemas/claim.ts +51 -0
- package/src/schemas/common.ts +46 -0
- package/src/schemas/customer-inquiry.ts +39 -0
- package/src/schemas/inquiry.ts +25 -0
- package/src/schemas/member.ts +50 -0
- package/src/schemas/order.ts +76 -0
- package/src/schemas/review.ts +65 -0
- package/src/session.ts +212 -0
package/src/session.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { toApiError, unwrapData } from "./http";
|
|
2
|
+
import type { LoginRequest, SignupRequest, TokenPair } from "./schemas/auth";
|
|
3
|
+
import { loginRequestSchema, signupRequestSchema, tokenPairSchema } from "./schemas/auth";
|
|
4
|
+
import type { Member } from "./schemas/member";
|
|
5
|
+
import { memberSchema } from "./schemas/member";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SDK 세션 매니저 — 스토어프론트(회원, member) CREDENTIAL 로그인(email/password)의
|
|
9
|
+
* 저장·무음 갱신·회전을 캡슐화한다. store-sdk의 세션과 동형이지만 진입 메서드는
|
|
10
|
+
* `.login()`이다 (구매자는 oauth2 authorize 플로우를 타지 않는다 — `/auth/login` → tokenPair).
|
|
11
|
+
*
|
|
12
|
+
* 저장 매체(쿠키/localStorage/…)와 그 속성은 **어댑터(앱) 책임**이다. SDK는 "무엇을 어떤 키로
|
|
13
|
+
* 저장할지"만 알고, "어떻게 저장할지"는 SessionStorage 어댑터에 위임한다.
|
|
14
|
+
*/
|
|
15
|
+
export interface SessionStorage {
|
|
16
|
+
get(key: string): string | null;
|
|
17
|
+
set(key: string, value: string): void;
|
|
18
|
+
remove(key: string): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 세션 키 상수 — SDK가 소유한다. store-sdk와 겹치지 않는 별도 네임스페이스. */
|
|
22
|
+
export const STOREFRONT_SESSION_KEYS = {
|
|
23
|
+
accessToken: "sayren.storefront.accessToken",
|
|
24
|
+
refreshToken: "sayren.storefront.refreshToken",
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
/** Web Locks 이름 — 탭 간 refresh 회전 직렬화용 */
|
|
28
|
+
const REFRESH_LOCK = STOREFRONT_SESSION_KEYS.refreshToken;
|
|
29
|
+
|
|
30
|
+
export interface StorefrontSessionOptions {
|
|
31
|
+
/** 예: https://shop.api.example.com/storefront/v1 */
|
|
32
|
+
baseUrl: string;
|
|
33
|
+
/** 테넌트 스토어 코드 — X-Store-Code 헤더로 전송한다 */
|
|
34
|
+
storeCode?: string;
|
|
35
|
+
/** 저장 매체 어댑터 (앱 소유) */
|
|
36
|
+
storage: SessionStorage;
|
|
37
|
+
fetch?: typeof globalThis.fetch;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface StorefrontSession {
|
|
41
|
+
/** POST /auth/login — 성공 시 accessToken/refreshToken 저장 + 구독자 통지 */
|
|
42
|
+
login(body: LoginRequest): Promise<void>;
|
|
43
|
+
/** POST /auth/signup — login과 동일하게 tokenPair를 저장한다 */
|
|
44
|
+
signup(body: SignupRequest): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* 무음 세션 갱신 — refreshToken으로 새 tokenPair를 받아 회전 저장한다.
|
|
47
|
+
* - 탭 내부: 동시 다발 호출을 single-flight로 합친다.
|
|
48
|
+
* - 탭 간: navigator.locks(있으면)로 회전을 직렬화하고, 락 진입 시 다른 탭이 이미
|
|
49
|
+
* 회전했으면(accessToken 변경) 내 refreshToken을 소비하지 않고 true를 반환한다.
|
|
50
|
+
* refreshToken이 없거나 실패(만료·재사용 폐기)하면 세션을 clear하고 false.
|
|
51
|
+
*/
|
|
52
|
+
refresh(): Promise<boolean>;
|
|
53
|
+
/** 현재 accessToken (없으면 null) */
|
|
54
|
+
readonly accessToken: string | null;
|
|
55
|
+
/** 현재 refreshToken (없으면 null) */
|
|
56
|
+
readonly refreshToken: string | null;
|
|
57
|
+
/** GET /me — 토큰이 없으면 null, 있으면 Member */
|
|
58
|
+
userinfo(): Promise<Member | null>;
|
|
59
|
+
/** 세션 키를 지우고 best-effort로 /auth/logout을 호출한 뒤 구독자에게 통지한다 */
|
|
60
|
+
clear(): void;
|
|
61
|
+
/** 변경 구독(useSyncExternalStore 소비용) — 해지 함수 반환 */
|
|
62
|
+
subscribe(cb: () => void): () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildHeaders(storeCode: string | undefined, accessToken?: string): Record<string, string> {
|
|
66
|
+
const headers: Record<string, string> = {};
|
|
67
|
+
if (storeCode) headers["x-store-code"] = storeCode;
|
|
68
|
+
if (accessToken) headers.authorization = `Bearer ${accessToken}`;
|
|
69
|
+
return headers;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function postJson(
|
|
73
|
+
fetchFn: typeof globalThis.fetch,
|
|
74
|
+
baseUrl: string,
|
|
75
|
+
path: string,
|
|
76
|
+
body: unknown,
|
|
77
|
+
storeCode: string | undefined,
|
|
78
|
+
accessToken?: string,
|
|
79
|
+
): Promise<unknown> {
|
|
80
|
+
const response = await fetchFn(`${baseUrl}${path}`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "content-type": "application/json", ...buildHeaders(storeCode, accessToken) },
|
|
83
|
+
body: JSON.stringify(body ?? {}),
|
|
84
|
+
});
|
|
85
|
+
const json: unknown = await response.json().catch(() => null);
|
|
86
|
+
// createStorefrontClient(http.ts)와 동일한 ApiError 형태로 던진다 — 앱이 error.code/status로 분기.
|
|
87
|
+
if (!response.ok) throw toApiError(response.status, json);
|
|
88
|
+
return unwrapData(json);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createStorefrontSession(options: StorefrontSessionOptions): StorefrontSession {
|
|
92
|
+
const { baseUrl, storeCode, storage } = options;
|
|
93
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
94
|
+
const listeners = new Set<() => void>();
|
|
95
|
+
|
|
96
|
+
function notify(): void {
|
|
97
|
+
for (const listener of listeners) listener();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getAccessToken(): string | null {
|
|
101
|
+
return storage.get(STOREFRONT_SESSION_KEYS.accessToken);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getRefreshToken(): string | null {
|
|
105
|
+
return storage.get(STOREFRONT_SESSION_KEYS.refreshToken);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function setTokens(tokens: TokenPair): void {
|
|
109
|
+
storage.set(STOREFRONT_SESSION_KEYS.accessToken, tokens.accessToken);
|
|
110
|
+
storage.set(STOREFRONT_SESSION_KEYS.refreshToken, tokens.refreshToken);
|
|
111
|
+
notify();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function clear(): void {
|
|
115
|
+
const accessToken = getAccessToken();
|
|
116
|
+
storage.remove(STOREFRONT_SESSION_KEYS.accessToken);
|
|
117
|
+
storage.remove(STOREFRONT_SESSION_KEYS.refreshToken);
|
|
118
|
+
notify();
|
|
119
|
+
// best-effort — 실패해도 clear() 호출부로 전파하지 않는다 (fire and forget)
|
|
120
|
+
void postJson(fetchFn, baseUrl, "/auth/logout", {}, storeCode, accessToken ?? undefined).catch(
|
|
121
|
+
() => {},
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function login(body: LoginRequest): Promise<void> {
|
|
126
|
+
const parsed = loginRequestSchema.parse(body);
|
|
127
|
+
const raw = await postJson(fetchFn, baseUrl, "/auth/login", parsed, storeCode);
|
|
128
|
+
setTokens(tokenPairSchema.parse(raw));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function signup(body: SignupRequest): Promise<void> {
|
|
132
|
+
const parsed = signupRequestSchema.parse(body);
|
|
133
|
+
const raw = await postJson(fetchFn, baseUrl, "/auth/signup", parsed, storeCode);
|
|
134
|
+
setTokens(tokenPairSchema.parse(raw));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let refreshInFlight: Promise<boolean> | null = null;
|
|
138
|
+
|
|
139
|
+
function refresh(): Promise<boolean> {
|
|
140
|
+
if (!refreshInFlight) {
|
|
141
|
+
refreshInFlight = doRefresh().finally(() => {
|
|
142
|
+
refreshInFlight = null;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return refreshInFlight;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function doRefresh(): Promise<boolean> {
|
|
149
|
+
// 락을 기다리는 동안 다른 탭이 회전을 끝낼 수 있으므로, 대기 전 accessToken을 스냅샷한다.
|
|
150
|
+
const before = getAccessToken();
|
|
151
|
+
const locks = typeof navigator !== "undefined" ? navigator.locks : undefined;
|
|
152
|
+
return locks ? locks.request(REFRESH_LOCK, () => rotate(before)) : rotate(before);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function rotate(before: string | null): Promise<boolean> {
|
|
156
|
+
// 락 획득 후 accessToken이 바뀌었으면 다른 탭이 이미 회전한 것 → 내 refreshToken은 소비하지 않는다.
|
|
157
|
+
if (getAccessToken() !== before) return true;
|
|
158
|
+
const refreshToken = getRefreshToken();
|
|
159
|
+
if (!refreshToken) {
|
|
160
|
+
clear();
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const raw = await postJson(
|
|
165
|
+
fetchFn,
|
|
166
|
+
baseUrl,
|
|
167
|
+
"/auth/token/refresh",
|
|
168
|
+
{ refreshToken },
|
|
169
|
+
storeCode,
|
|
170
|
+
);
|
|
171
|
+
setTokens(tokenPairSchema.parse(raw));
|
|
172
|
+
return true;
|
|
173
|
+
} catch {
|
|
174
|
+
clear(); // refreshToken까지 함께 폐기
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function userinfo(): Promise<Member | null> {
|
|
180
|
+
const accessToken = getAccessToken();
|
|
181
|
+
if (!accessToken) return null;
|
|
182
|
+
const response = await fetchFn(`${baseUrl}/me`, {
|
|
183
|
+
method: "GET",
|
|
184
|
+
headers: buildHeaders(storeCode, accessToken),
|
|
185
|
+
});
|
|
186
|
+
const json: unknown = await response.json().catch(() => null);
|
|
187
|
+
if (!response.ok) throw toApiError(response.status, json);
|
|
188
|
+
return memberSchema.parse(unwrapData(json));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function subscribe(cb: () => void): () => void {
|
|
192
|
+
listeners.add(cb);
|
|
193
|
+
return () => {
|
|
194
|
+
listeners.delete(cb);
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
login,
|
|
200
|
+
signup,
|
|
201
|
+
refresh,
|
|
202
|
+
clear,
|
|
203
|
+
subscribe,
|
|
204
|
+
userinfo,
|
|
205
|
+
get accessToken() {
|
|
206
|
+
return getAccessToken();
|
|
207
|
+
},
|
|
208
|
+
get refreshToken() {
|
|
209
|
+
return getRefreshToken();
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|