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.
@@ -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
- ): EntityServerClient {
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
- return useMemo(() => {
31
- const client = singleton
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
- client.configure({ baseUrl, packetMagicLen, token });
106
+ c.configure({ baseUrl, packetMagicLen, token });
41
107
  }
42
108
 
43
109
  const resolvedToken = tokenResolver?.();
44
110
  if (typeof resolvedToken === "string") {
45
- client.setToken(resolvedToken);
111
+ c.setToken(resolvedToken);
46
112
  }
47
113
 
48
- return client;
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`: `VITE_PACKET_MAGIC_LEN` 또는 `4`
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("VITE_PACKET_MAGIC_LEN");
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(JSON.stringify(body));
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;