entity-server-client 0.2.1 → 0.2.3
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 +19 -4
- package/dist/hooks/useEntityServer.d.ts +55 -4
- package/dist/index.d.ts +62 -0
- 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 +236 -7
- package/package.json +1 -1
- package/src/hooks/useEntityServer.ts +119 -14
- package/src/index.ts +143 -0
|
@@ -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,6 +180,14 @@ 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
|
*
|
|
@@ -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,57 @@ 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(
|
|
278
|
+
this._sessionRefreshToken,
|
|
279
|
+
);
|
|
280
|
+
this.onTokenRefreshed?.(result.access_token, result.expires_in);
|
|
281
|
+
// 갱신 성공 시 다음 만료 전 타이머 재예약
|
|
282
|
+
this._scheduleKeepSession(
|
|
283
|
+
this._sessionRefreshToken,
|
|
284
|
+
result.expires_in,
|
|
285
|
+
);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
this._clearRefreshTimer();
|
|
288
|
+
this.onSessionExpired?.(
|
|
289
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}, delayMs);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 자동 갱신 타이머를 정리합니다. */
|
|
296
|
+
private _clearRefreshTimer(): void {
|
|
297
|
+
if (this._refreshTimer !== null) {
|
|
298
|
+
clearTimeout(this._refreshTimer);
|
|
299
|
+
this._refreshTimer = null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 세션 유지 타이머를 중지합니다.
|
|
305
|
+
* `logout()` 호출 시 자동으로 중지되므로 직접 호출이 필요한 경우는 드뭅니다.
|
|
306
|
+
*/
|
|
307
|
+
stopKeepSession(): void {
|
|
308
|
+
this._clearRefreshTimer();
|
|
309
|
+
this._sessionRefreshToken = null;
|
|
310
|
+
}
|
|
311
|
+
|
|
208
312
|
/**
|
|
209
313
|
* 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
|
|
210
314
|
*
|
|
@@ -249,6 +353,12 @@ export class EntityServerClient {
|
|
|
249
353
|
};
|
|
250
354
|
}>("POST", "/v1/auth/login", { email, passwd: password }, false);
|
|
251
355
|
this.token = data.data.access_token;
|
|
356
|
+
if (this.keepSession) {
|
|
357
|
+
this._scheduleKeepSession(
|
|
358
|
+
data.data.refresh_token,
|
|
359
|
+
data.data.expires_in,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
252
362
|
return data.data;
|
|
253
363
|
}
|
|
254
364
|
|
|
@@ -260,9 +370,28 @@ export class EntityServerClient {
|
|
|
260
370
|
data: { access_token: string; expires_in: number };
|
|
261
371
|
}>("POST", "/v1/auth/refresh", { refresh_token: refreshToken }, false);
|
|
262
372
|
this.token = data.data.access_token;
|
|
373
|
+
if (this.keepSession) {
|
|
374
|
+
this._scheduleKeepSession(refreshToken, data.data.expires_in);
|
|
375
|
+
}
|
|
263
376
|
return data.data;
|
|
264
377
|
}
|
|
265
378
|
|
|
379
|
+
/**
|
|
380
|
+
* 서버에 로그아웃을 요청하고 내부 토큰을 초기화합니다.
|
|
381
|
+
* refresh_token을 서버에 전달해 무효화합니다.
|
|
382
|
+
*/
|
|
383
|
+
async logout(refreshToken: string): Promise<{ ok: boolean }> {
|
|
384
|
+
this.stopKeepSession();
|
|
385
|
+
const data = await this.request<{ ok: boolean }>(
|
|
386
|
+
"POST",
|
|
387
|
+
"/v1/auth/logout",
|
|
388
|
+
{ refresh_token: refreshToken },
|
|
389
|
+
false,
|
|
390
|
+
);
|
|
391
|
+
this.token = "";
|
|
392
|
+
return data;
|
|
393
|
+
}
|
|
394
|
+
|
|
266
395
|
/** 트랜잭션을 시작하고 활성 트랜잭션 ID를 저장합니다. */
|
|
267
396
|
async transStart(): Promise<string> {
|
|
268
397
|
const res = await this.request<{ ok: boolean; transaction_id: string }>(
|
|
@@ -315,6 +444,20 @@ export class EntityServerClient {
|
|
|
315
444
|
return this.request("GET", `/v1/entity/${entity}/${seq}${q}`);
|
|
316
445
|
}
|
|
317
446
|
|
|
447
|
+
/** 조건으로 엔티티 단건을 조회합니다. data 컬럼을 완전히 복호화하여 반환합니다. */
|
|
448
|
+
find<T = unknown>(
|
|
449
|
+
entity: string,
|
|
450
|
+
conditions?: Record<string, unknown>,
|
|
451
|
+
opts: { skipHooks?: boolean } = {},
|
|
452
|
+
): Promise<{ ok: boolean; data: T }> {
|
|
453
|
+
const q = opts.skipHooks ? "?skipHooks=true" : "";
|
|
454
|
+
return this.request(
|
|
455
|
+
"POST",
|
|
456
|
+
`/v1/entity/${entity}/find${q}`,
|
|
457
|
+
conditions ?? {},
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
318
461
|
/** 페이지네이션/정렬/필터 조건으로 엔티티 목록을 조회합니다. */
|
|
319
462
|
list<T = unknown>(
|
|
320
463
|
entity: string,
|