entity-server-client 0.2.0 → 0.2.2
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/README.md +45 -0
- package/dist/hooks/useEntityServer.d.ts +55 -4
- package/dist/index.d.ts +73 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +3 -3
- package/dist/react.js +1 -1
- package/dist/react.js.map +3 -3
- package/docs/apis.md +257 -6
- package/package.json +1 -1
- package/src/hooks/useEntityServer.ts +119 -14
- package/src/index.ts +159 -3
|
@@ -1,50 +1,155 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
EntityServerClient,
|
|
4
4
|
entityServer,
|
|
5
|
+
type EntityListParams,
|
|
6
|
+
type EntityQueryRequest,
|
|
5
7
|
type EntityServerClientOptions,
|
|
6
8
|
} from "../index";
|
|
7
9
|
|
|
8
10
|
export interface UseEntityServerOptions extends EntityServerClientOptions {
|
|
9
11
|
singleton?: boolean;
|
|
10
12
|
tokenResolver?: () => string | undefined | null;
|
|
13
|
+
/**
|
|
14
|
+
* 페이지 새로고침 후 로그인 상태를 복원할 때 사용합니다.
|
|
15
|
+
* 이 값이 있으면 마운트 시 `client.refreshToken()`을 호출해 새 access_token을 발급받습니다.
|
|
16
|
+
* `keepSession: true`와 함께 사용하면 세션 유지 타이머도 재시작됩니다.
|
|
17
|
+
* 갱신 성공 시 `onTokenRefreshed` 콜백이 호출됩니다.
|
|
18
|
+
*/
|
|
19
|
+
resumeSession?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UseEntityServerResult {
|
|
23
|
+
/** EntityServerClient 인스턴스 (read 전용 메서드 직접 호출 시 사용) */
|
|
24
|
+
client: EntityServerClient;
|
|
25
|
+
/** submit 또는 delete 진행 중 여부 */
|
|
26
|
+
isPending: boolean;
|
|
27
|
+
/** 마지막 mutation 에러 (없으면 null) */
|
|
28
|
+
error: Error | null;
|
|
29
|
+
/** 에러·결과 상태 초기화 */
|
|
30
|
+
reset: () => void;
|
|
31
|
+
/** entity 데이터 생성/수정 (seq 없으면 INSERT, 있으면 UPDATE) */
|
|
32
|
+
submit: (
|
|
33
|
+
entity: string,
|
|
34
|
+
data: Record<string, unknown>,
|
|
35
|
+
opts?: { transactionId?: string; skipHooks?: boolean },
|
|
36
|
+
) => Promise<{ ok: boolean; seq: number }>;
|
|
37
|
+
/** entity 데이터 삭제 */
|
|
38
|
+
del: (
|
|
39
|
+
entity: string,
|
|
40
|
+
seq: number,
|
|
41
|
+
opts?: { transactionId?: string; hard?: boolean; skipHooks?: boolean },
|
|
42
|
+
) => Promise<{ ok: boolean; deleted: number }>;
|
|
43
|
+
/** 커스텀 SQL 조회 */
|
|
44
|
+
query: <T = unknown>(
|
|
45
|
+
entity: string,
|
|
46
|
+
req: EntityQueryRequest,
|
|
47
|
+
) => Promise<{ ok: boolean; data: { items: T[]; count: number } }>;
|
|
11
48
|
}
|
|
12
49
|
|
|
13
50
|
/**
|
|
14
|
-
* React 환경에서 EntityServerClient
|
|
51
|
+
* React 환경에서 EntityServerClient 인스턴스와 mutation 상태를 반환합니다.
|
|
15
52
|
*
|
|
16
|
-
* - `singleton=true`(기본): 패키지 전역 `entityServer` 인스턴스를
|
|
53
|
+
* - `singleton=true`(기본): 패키지 전역 `entityServer` 인스턴스를 사용합니다.
|
|
17
54
|
* - `singleton=false`: 컴포넌트 스코프의 새 인스턴스를 생성합니다.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```tsx
|
|
58
|
+
* const { submit, del, isPending, error, reset } = useEntityServer();
|
|
59
|
+
*
|
|
60
|
+
* const handleSave = async () => {
|
|
61
|
+
* await submit("account", { name: "홍길동" });
|
|
62
|
+
* };
|
|
63
|
+
* ```
|
|
18
64
|
*/
|
|
19
65
|
export function useEntityServer(
|
|
20
66
|
options: UseEntityServerOptions = {},
|
|
21
|
-
):
|
|
67
|
+
): UseEntityServerResult {
|
|
22
68
|
const {
|
|
23
69
|
singleton = true,
|
|
24
70
|
tokenResolver,
|
|
25
71
|
baseUrl,
|
|
26
72
|
packetMagicLen,
|
|
27
73
|
token,
|
|
74
|
+
resumeSession,
|
|
28
75
|
} = options;
|
|
29
76
|
|
|
30
|
-
|
|
31
|
-
|
|
77
|
+
const [isPending, setIsPending] = useState(false);
|
|
78
|
+
const [error, setError] = useState<Error | null>(null);
|
|
79
|
+
|
|
80
|
+
// 언마운트 후 setState 방지
|
|
81
|
+
const mountedRef = useRef(true);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
mountedRef.current = true;
|
|
84
|
+
return () => {
|
|
85
|
+
mountedRef.current = false;
|
|
86
|
+
};
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
// 새로고침 후 로그인 상태 복원: resumeSession이 있으면 마운트 시 refreshToken() 호출
|
|
90
|
+
const resumeTokenRef = useRef(resumeSession);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const storedRefreshToken = resumeTokenRef.current;
|
|
93
|
+
if (!storedRefreshToken) return;
|
|
94
|
+
client.refreshToken(storedRefreshToken).catch(() => {
|
|
95
|
+
// refresh_token 만료 등 — onSessionExpired 콜백이 이미 처리
|
|
96
|
+
});
|
|
97
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
const client = useMemo(() => {
|
|
101
|
+
const c = singleton
|
|
32
102
|
? entityServer
|
|
33
|
-
: new EntityServerClient({
|
|
34
|
-
baseUrl,
|
|
35
|
-
packetMagicLen,
|
|
36
|
-
token,
|
|
37
|
-
});
|
|
103
|
+
: new EntityServerClient({ baseUrl, packetMagicLen, token });
|
|
38
104
|
|
|
39
105
|
if (singleton) {
|
|
40
|
-
|
|
106
|
+
c.configure({ baseUrl, packetMagicLen, token });
|
|
41
107
|
}
|
|
42
108
|
|
|
43
109
|
const resolvedToken = tokenResolver?.();
|
|
44
110
|
if (typeof resolvedToken === "string") {
|
|
45
|
-
|
|
111
|
+
c.setToken(resolvedToken);
|
|
46
112
|
}
|
|
47
113
|
|
|
48
|
-
return
|
|
114
|
+
return c;
|
|
49
115
|
}, [singleton, tokenResolver, baseUrl, packetMagicLen, token]);
|
|
116
|
+
|
|
117
|
+
const run = useCallback(async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
118
|
+
if (mountedRef.current) {
|
|
119
|
+
setIsPending(true);
|
|
120
|
+
setError(null);
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const result = await fn();
|
|
124
|
+
return result;
|
|
125
|
+
} catch (err) {
|
|
126
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
127
|
+
if (mountedRef.current) setError(e);
|
|
128
|
+
throw e;
|
|
129
|
+
} finally {
|
|
130
|
+
if (mountedRef.current) setIsPending(false);
|
|
131
|
+
}
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
const submit = useCallback<UseEntityServerResult["submit"]>(
|
|
135
|
+
(entity, data, opts) => run(() => client.submit(entity, data, opts)),
|
|
136
|
+
[client, run],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const del = useCallback<UseEntityServerResult["del"]>(
|
|
140
|
+
(entity, seq, opts) => run(() => client.delete(entity, seq, opts)),
|
|
141
|
+
[client, run],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const query = useCallback<UseEntityServerResult["query"]>(
|
|
145
|
+
(entity, req) => run(() => client.query(entity, req)),
|
|
146
|
+
[client, run],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const reset = useCallback(() => {
|
|
150
|
+
setIsPending(false);
|
|
151
|
+
setError(null);
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
154
|
+
return { client, isPending, error, reset, submit, del, query };
|
|
50
155
|
}
|
package/src/index.ts
CHANGED
|
@@ -96,6 +96,35 @@ export interface EntityServerClientOptions {
|
|
|
96
96
|
* 기본값: `false`
|
|
97
97
|
*/
|
|
98
98
|
encryptRequests?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* `true`이면 `login()` 성공 후 Access Token 만료 전에 자동으로 갱신(silent refresh)합니다.
|
|
101
|
+
* 갱신 시점은 `expires_in - refreshBuffer` 초입니다.
|
|
102
|
+
*
|
|
103
|
+
* 갱신 성공 시 `onTokenRefreshed`, 실패 시 `onSessionExpired` 콜백이 호출됩니다.
|
|
104
|
+
*
|
|
105
|
+
* 기본값: `false`
|
|
106
|
+
*/
|
|
107
|
+
keepSession?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* 만료 몇 초 전에 자동 갱신을 시도할지 설정합니다.
|
|
110
|
+
*
|
|
111
|
+
* 예: `expires_in = 3600`, `refreshBuffer = 60` → 3540초 후 갱신
|
|
112
|
+
*
|
|
113
|
+
* 기본값: `60`
|
|
114
|
+
*/
|
|
115
|
+
refreshBuffer?: number;
|
|
116
|
+
/**
|
|
117
|
+
* 자동 갱신 성공 시 호출되는 콜백입니다.
|
|
118
|
+
* 새 `access_token`과 `expires_in`이 전달됩니다.
|
|
119
|
+
* 앱은 이 콜백에서 localStorage 등에 토큰을 저장해야 합니다.
|
|
120
|
+
*/
|
|
121
|
+
onTokenRefreshed?: (accessToken: string, expiresIn: number) => void;
|
|
122
|
+
/**
|
|
123
|
+
* 세션 유지 갱신 실패 시 호출되는 콜백입니다.
|
|
124
|
+
* refresh_token 만료 등으로 재발급이 불가능한 경우입니다.
|
|
125
|
+
* 앱은 이 콜백에서 로그인 페이지로 이동하는 등의 처리를 해야 합니다.
|
|
126
|
+
*/
|
|
127
|
+
onSessionExpired?: (error: Error) => void;
|
|
99
128
|
}
|
|
100
129
|
|
|
101
130
|
/**
|
|
@@ -151,16 +180,24 @@ export class EntityServerClient {
|
|
|
151
180
|
private encryptRequests: boolean;
|
|
152
181
|
private activeTxId: string | null = null;
|
|
153
182
|
|
|
183
|
+
// 세션 유지 관련
|
|
184
|
+
private keepSession: boolean;
|
|
185
|
+
private refreshBuffer: number;
|
|
186
|
+
private onTokenRefreshed?: (accessToken: string, expiresIn: number) => void;
|
|
187
|
+
private onSessionExpired?: (error: Error) => void;
|
|
188
|
+
private _sessionRefreshToken: string | null = null;
|
|
189
|
+
private _refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
190
|
+
|
|
154
191
|
/**
|
|
155
192
|
* EntityServerClient 인스턴스를 생성합니다.
|
|
156
193
|
*
|
|
157
194
|
* 기본값:
|
|
158
195
|
* - `baseUrl`: `VITE_ENTITY_SERVER_URL` 또는 `http://localhost:47200`
|
|
159
|
-
* - `packetMagicLen`: `
|
|
196
|
+
* - `packetMagicLen`: `VITE_ENTITY_SERVER_PACKET_MAGIC_LEN` 또는 `4`
|
|
160
197
|
*/
|
|
161
198
|
constructor(options: EntityServerClientOptions = {}) {
|
|
162
199
|
const envBaseUrl = readEnv("VITE_ENTITY_SERVER_URL");
|
|
163
|
-
const envMagicLen = readEnv("
|
|
200
|
+
const envMagicLen = readEnv("VITE_ENTITY_SERVER_PACKET_MAGIC_LEN");
|
|
164
201
|
|
|
165
202
|
this.baseUrl = (
|
|
166
203
|
options.baseUrl ??
|
|
@@ -172,6 +209,10 @@ export class EntityServerClient {
|
|
|
172
209
|
this.packetMagicLen =
|
|
173
210
|
options.packetMagicLen ?? (envMagicLen ? Number(envMagicLen) : 4);
|
|
174
211
|
this.encryptRequests = options.encryptRequests ?? false;
|
|
212
|
+
this.keepSession = options.keepSession ?? false;
|
|
213
|
+
this.refreshBuffer = options.refreshBuffer ?? 60;
|
|
214
|
+
this.onTokenRefreshed = options.onTokenRefreshed;
|
|
215
|
+
this.onSessionExpired = options.onSessionExpired;
|
|
175
216
|
}
|
|
176
217
|
|
|
177
218
|
/** baseUrl, token, packetMagicLen, encryptRequests 값을 런타임에 갱신합니다. */
|
|
@@ -188,6 +229,18 @@ export class EntityServerClient {
|
|
|
188
229
|
if (typeof options.encryptRequests === "boolean") {
|
|
189
230
|
this.encryptRequests = options.encryptRequests;
|
|
190
231
|
}
|
|
232
|
+
if (typeof options.keepSession === "boolean") {
|
|
233
|
+
this.keepSession = options.keepSession;
|
|
234
|
+
}
|
|
235
|
+
if (typeof options.refreshBuffer === "number") {
|
|
236
|
+
this.refreshBuffer = options.refreshBuffer;
|
|
237
|
+
}
|
|
238
|
+
if (options.onTokenRefreshed) {
|
|
239
|
+
this.onTokenRefreshed = options.onTokenRefreshed;
|
|
240
|
+
}
|
|
241
|
+
if (options.onSessionExpired) {
|
|
242
|
+
this.onSessionExpired = options.onSessionExpired;
|
|
243
|
+
}
|
|
191
244
|
}
|
|
192
245
|
|
|
193
246
|
/** 인증 요청에 사용할 JWT Access Token을 설정합니다. */
|
|
@@ -205,6 +258,82 @@ export class EntityServerClient {
|
|
|
205
258
|
return this.packetMagicLen;
|
|
206
259
|
}
|
|
207
260
|
|
|
261
|
+
/**
|
|
262
|
+
* 자동 토큰 갱신 타이머를 시작합니다.
|
|
263
|
+
* @param refreshToken 갱신에 사용할 Refresh Token
|
|
264
|
+
* @param expiresIn Access Token의 유효 기간 (초)
|
|
265
|
+
*/
|
|
266
|
+
private _scheduleKeepSession(
|
|
267
|
+
refreshToken: string,
|
|
268
|
+
expiresIn: number,
|
|
269
|
+
): void {
|
|
270
|
+
this._clearRefreshTimer();
|
|
271
|
+
this._sessionRefreshToken = refreshToken;
|
|
272
|
+
|
|
273
|
+
const delayMs = Math.max((expiresIn - this.refreshBuffer) * 1000, 0);
|
|
274
|
+
this._refreshTimer = setTimeout(async () => {
|
|
275
|
+
if (!this._sessionRefreshToken) return;
|
|
276
|
+
try {
|
|
277
|
+
const result = await this.refreshToken(this._sessionRefreshToken);
|
|
278
|
+
this.onTokenRefreshed?.(result.access_token, result.expires_in);
|
|
279
|
+
// 갱신 성공 시 다음 만료 전 타이머 재예약
|
|
280
|
+
this._scheduleKeepSession(
|
|
281
|
+
this._sessionRefreshToken,
|
|
282
|
+
result.expires_in,
|
|
283
|
+
);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
this._clearRefreshTimer();
|
|
286
|
+
this.onSessionExpired?.(
|
|
287
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}, delayMs);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 자동 갱신 타이머를 정리합니다. */
|
|
294
|
+
private _clearRefreshTimer(): void {
|
|
295
|
+
if (this._refreshTimer !== null) {
|
|
296
|
+
clearTimeout(this._refreshTimer);
|
|
297
|
+
this._refreshTimer = null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 세션 유지 타이머를 중지합니다.
|
|
303
|
+
* `logout()` 호출 시 자동으로 중지되므로 직접 호출이 필요한 경우는 드뭅니다.
|
|
304
|
+
*/
|
|
305
|
+
stopKeepSession(): void {
|
|
306
|
+
this._clearRefreshTimer();
|
|
307
|
+
this._sessionRefreshToken = null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
|
|
312
|
+
*
|
|
313
|
+
* 서버가 `packet_encryption: true`를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
|
|
314
|
+
* 초기화 직후 또는 로그인 전에 호출하면 암호화 설정을 자동으로 구성할 수 있습니다.
|
|
315
|
+
*
|
|
316
|
+
* ```ts
|
|
317
|
+
* await client.checkHealth();
|
|
318
|
+
* await client.login(email, password); // 이후 요청은 암호화 자동 적용
|
|
319
|
+
* ```
|
|
320
|
+
*
|
|
321
|
+
* @returns `{ ok: true }` 또는 `{ ok: true, packet_encryption: true }`
|
|
322
|
+
*/
|
|
323
|
+
async checkHealth(): Promise<{ ok: boolean; packet_encryption?: boolean }> {
|
|
324
|
+
const res = await fetch(`${this.baseUrl}/v1/health`, {
|
|
325
|
+
signal: AbortSignal.timeout(3000),
|
|
326
|
+
});
|
|
327
|
+
const data = (await res.json()) as {
|
|
328
|
+
ok: boolean;
|
|
329
|
+
packet_encryption?: boolean;
|
|
330
|
+
};
|
|
331
|
+
if (data.packet_encryption) {
|
|
332
|
+
this.encryptRequests = true;
|
|
333
|
+
}
|
|
334
|
+
return data;
|
|
335
|
+
}
|
|
336
|
+
|
|
208
337
|
/** 로그인 후 `access_token`을 내부 상태에 저장합니다. */
|
|
209
338
|
async login(
|
|
210
339
|
email: string,
|
|
@@ -222,6 +351,12 @@ export class EntityServerClient {
|
|
|
222
351
|
};
|
|
223
352
|
}>("POST", "/v1/auth/login", { email, passwd: password }, false);
|
|
224
353
|
this.token = data.data.access_token;
|
|
354
|
+
if (this.keepSession) {
|
|
355
|
+
this._scheduleKeepSession(
|
|
356
|
+
data.data.refresh_token,
|
|
357
|
+
data.data.expires_in,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
225
360
|
return data.data;
|
|
226
361
|
}
|
|
227
362
|
|
|
@@ -233,9 +368,28 @@ export class EntityServerClient {
|
|
|
233
368
|
data: { access_token: string; expires_in: number };
|
|
234
369
|
}>("POST", "/v1/auth/refresh", { refresh_token: refreshToken }, false);
|
|
235
370
|
this.token = data.data.access_token;
|
|
371
|
+
if (this.keepSession) {
|
|
372
|
+
this._scheduleKeepSession(refreshToken, data.data.expires_in);
|
|
373
|
+
}
|
|
236
374
|
return data.data;
|
|
237
375
|
}
|
|
238
376
|
|
|
377
|
+
/**
|
|
378
|
+
* 서버에 로그아웃을 요청하고 내부 토큰을 초기화합니다.
|
|
379
|
+
* refresh_token을 서버에 전달해 무효화합니다.
|
|
380
|
+
*/
|
|
381
|
+
async logout(refreshToken: string): Promise<{ ok: boolean }> {
|
|
382
|
+
this.stopKeepSession();
|
|
383
|
+
const data = await this.request<{ ok: boolean }>(
|
|
384
|
+
"POST",
|
|
385
|
+
"/v1/auth/logout",
|
|
386
|
+
{ refresh_token: refreshToken },
|
|
387
|
+
false,
|
|
388
|
+
);
|
|
389
|
+
this.token = "";
|
|
390
|
+
return data;
|
|
391
|
+
}
|
|
392
|
+
|
|
239
393
|
/** 트랜잭션을 시작하고 활성 트랜잭션 ID를 저장합니다. */
|
|
240
394
|
async transStart(): Promise<string> {
|
|
241
395
|
const res = await this.request<{ ok: boolean; transaction_id: string }>(
|
|
@@ -570,7 +724,9 @@ export class EntityServerClient {
|
|
|
570
724
|
method !== "HEAD";
|
|
571
725
|
|
|
572
726
|
if (shouldEncrypt) {
|
|
573
|
-
const plaintext = new TextEncoder().encode(
|
|
727
|
+
const plaintext = new TextEncoder().encode(
|
|
728
|
+
JSON.stringify(body),
|
|
729
|
+
);
|
|
574
730
|
const encrypted = this.encryptPacket(plaintext);
|
|
575
731
|
headers["Content-Type"] = "application/octet-stream";
|
|
576
732
|
fetchBody = encrypted;
|