entity-server-client 0.2.5 → 0.2.6

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.
Files changed (44) hide show
  1. package/README.md +0 -1
  2. package/build.mjs +4 -1
  3. package/dist/EntityServerClient.d.ts +705 -199
  4. package/dist/client/base.d.ts +59 -0
  5. package/dist/client/packet.d.ts +8 -6
  6. package/dist/client/request.d.ts +0 -1
  7. package/dist/client/utils.d.ts +5 -1
  8. package/dist/index.js +1 -1
  9. package/dist/index.js.map +4 -4
  10. package/dist/mixins/alimtalk.d.ts +56 -0
  11. package/dist/mixins/auth.d.ts +167 -0
  12. package/dist/mixins/email.d.ts +51 -0
  13. package/dist/mixins/entity.d.ts +119 -0
  14. package/dist/mixins/file.d.ts +78 -0
  15. package/dist/mixins/identity.d.ts +52 -0
  16. package/dist/mixins/pg.d.ts +63 -0
  17. package/dist/mixins/push.d.ts +110 -0
  18. package/dist/mixins/sms.d.ts +55 -0
  19. package/dist/mixins/smtp.d.ts +44 -0
  20. package/dist/mixins/utils.d.ts +70 -0
  21. package/dist/react.js +1 -1
  22. package/dist/react.js.map +4 -4
  23. package/dist/types.d.ts +165 -1
  24. package/docs/apis.md +5 -12
  25. package/docs/react.md +6 -7
  26. package/package.json +2 -1
  27. package/src/EntityServerClient.ts +54 -546
  28. package/src/client/base.ts +246 -0
  29. package/src/client/packet.ts +14 -27
  30. package/src/client/request.ts +2 -11
  31. package/src/client/utils.ts +18 -2
  32. package/src/hooks/useEntityServer.ts +3 -4
  33. package/src/mixins/alimtalk.ts +35 -0
  34. package/src/mixins/auth.ts +287 -0
  35. package/src/mixins/email.ts +46 -0
  36. package/src/mixins/entity.ts +205 -0
  37. package/src/mixins/file.ts +99 -0
  38. package/src/mixins/identity.ts +35 -0
  39. package/src/mixins/pg.ts +58 -0
  40. package/src/mixins/push.ts +132 -0
  41. package/src/mixins/sms.ts +46 -0
  42. package/src/mixins/smtp.ts +20 -0
  43. package/src/mixins/utils.ts +75 -0
  44. package/src/types.ts +203 -1
@@ -0,0 +1,246 @@
1
+ import type { EntityServerClientOptions } from "../types";
2
+ import { readEnv } from "./utils";
3
+ import { derivePacketKey, parseRequestBody } from "./packet";
4
+ import { entityRequest, type RequestOptions } from "./request";
5
+
6
+ // mixin 헬퍼 타입
7
+ export type GConstructor<T = object> = new (...args: any[]) => T;
8
+
9
+ export class EntityServerClientBase {
10
+ baseUrl: string;
11
+ token: string;
12
+ apiKey: string;
13
+ hmacSecret: string;
14
+ encryptRequests: boolean;
15
+ activeTxId: string | null = null;
16
+
17
+ // 세션 유지 관련
18
+ keepSession: boolean;
19
+ refreshBuffer: number;
20
+ onTokenRefreshed?: (
21
+ accessToken: string,
22
+ expiresIn: number,
23
+ ) => void;
24
+ onSessionExpired?: (error: Error) => void;
25
+ _sessionRefreshToken: string | null = null;
26
+ _refreshTimer: ReturnType<typeof setTimeout> | null = null;
27
+
28
+ // ─── 초기화 & 설정 ────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * EntityServerClient 인스턴스를 생성합니다.
32
+ *
33
+ * 기본값:
34
+ * - `baseUrl`: `VITE_ENTITY_SERVER_URL` 또는 `http://localhost:47200`
35
+ */
36
+ constructor(options: EntityServerClientOptions = {}) {
37
+ const envBaseUrl = readEnv("VITE_ENTITY_SERVER_URL");
38
+
39
+ this.baseUrl = (
40
+ options.baseUrl ??
41
+ envBaseUrl ??
42
+ "http://localhost:47200"
43
+ ).replace(/\/$/, "");
44
+ this.token = options.token ?? "";
45
+ this.apiKey = options.apiKey ?? "";
46
+ this.hmacSecret = options.hmacSecret ?? "";
47
+ this.encryptRequests = options.encryptRequests ?? false;
48
+ this.keepSession = options.keepSession ?? false;
49
+ this.refreshBuffer = options.refreshBuffer ?? 60;
50
+ this.onTokenRefreshed = options.onTokenRefreshed;
51
+ this.onSessionExpired = options.onSessionExpired;
52
+ }
53
+
54
+ /** baseUrl, token, encryptRequests 값을 런타임에 갱신합니다. */
55
+ configure(options: Partial<EntityServerClientOptions>): void {
56
+ if (options.baseUrl) this.baseUrl = options.baseUrl.replace(/\/$/, "");
57
+ if (typeof options.token === "string") this.token = options.token;
58
+ if (typeof options.encryptRequests === "boolean")
59
+ this.encryptRequests = options.encryptRequests;
60
+ if (typeof options.apiKey === "string") this.apiKey = options.apiKey;
61
+ if (typeof options.hmacSecret === "string")
62
+ this.hmacSecret = options.hmacSecret;
63
+ if (typeof options.keepSession === "boolean")
64
+ this.keepSession = options.keepSession;
65
+ if (typeof options.refreshBuffer === "number")
66
+ this.refreshBuffer = options.refreshBuffer;
67
+ if (options.onTokenRefreshed)
68
+ this.onTokenRefreshed = options.onTokenRefreshed;
69
+ if (options.onSessionExpired)
70
+ this.onSessionExpired = options.onSessionExpired;
71
+ }
72
+
73
+ /** 인증 요청에 사용할 JWT Access Token을 설정합니다. */
74
+ setToken(token: string): void {
75
+ this.token = token;
76
+ }
77
+
78
+ /** HMAC 인증용 API Key를 설정합니다. */
79
+ setApiKey(apiKey: string): void {
80
+ this.apiKey = apiKey;
81
+ }
82
+
83
+ /** HMAC 인증용 시크릿을 설정합니다. */
84
+ setHmacSecret(secret: string): void {
85
+ this.hmacSecret = secret;
86
+ }
87
+
88
+ /** 암호화 요청 활성화 여부를 설정합니다. */
89
+ setEncryptRequests(value: boolean): void {
90
+ this.encryptRequests = value;
91
+ }
92
+
93
+ // ─── 세션 유지 ────────────────────────────────────────────────────────────
94
+
95
+ /** @internal 자동 토큰 갱신 타이머를 시작합니다. */
96
+ _scheduleKeepSession(
97
+ refreshToken: string,
98
+ expiresIn: number,
99
+ refreshFn: (
100
+ rt: string,
101
+ ) => Promise<{ access_token: string; expires_in: number }>,
102
+ ): void {
103
+ this._clearRefreshTimer();
104
+ this._sessionRefreshToken = refreshToken;
105
+ const delayMs = Math.max((expiresIn - this.refreshBuffer) * 1000, 0);
106
+ this._refreshTimer = setTimeout(async () => {
107
+ if (!this._sessionRefreshToken) return;
108
+ try {
109
+ const result = await refreshFn(this._sessionRefreshToken);
110
+ this.onTokenRefreshed?.(result.access_token, result.expires_in);
111
+ this._scheduleKeepSession(
112
+ this._sessionRefreshToken,
113
+ result.expires_in,
114
+ refreshFn,
115
+ );
116
+ } catch (err) {
117
+ this._clearRefreshTimer();
118
+ this.onSessionExpired?.(
119
+ err instanceof Error ? err : new Error(String(err)),
120
+ );
121
+ }
122
+ }, delayMs);
123
+ }
124
+
125
+ /** @internal 자동 갱신 타이머를 정리합니다. */
126
+ _clearRefreshTimer(): void {
127
+ if (this._refreshTimer !== null) {
128
+ clearTimeout(this._refreshTimer);
129
+ this._refreshTimer = null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 세션 유지 타이머를 중지합니다.
135
+ * `logout()` 호출 시 자동으로 중지되며, 직접 호출이 필요한 경우는 드뭅니다.
136
+ */
137
+ stopKeepSession(): void {
138
+ this._clearRefreshTimer();
139
+ this._sessionRefreshToken = null;
140
+ }
141
+
142
+ // ─── 요청 본문 파싱 ───────────────────────────────────────────────────────
143
+
144
+ /**
145
+ * 요청 바디를 파싱합니다.
146
+ * `application/octet-stream`이면 XChaCha20-Poly1305 복호화, 그 외는 JSON 파싱합니다.
147
+ *
148
+ * @param requireEncrypted `true`이면 암호화된 요청만 허용합니다.
149
+ */
150
+ readRequestBody<T = Record<string, unknown>>(
151
+ body: ArrayBuffer | Uint8Array | string | T | null | undefined,
152
+ contentType = "application/json",
153
+ requireEncrypted = false,
154
+ ): T {
155
+ const key = derivePacketKey(this.hmacSecret, this.token);
156
+ return parseRequestBody<T>(body, contentType, requireEncrypted, key);
157
+ }
158
+
159
+ // ─── 내부 헬퍼 ───────────────────────────────────────────────────────────
160
+
161
+ get _reqOpts(): RequestOptions {
162
+ return {
163
+ baseUrl: this.baseUrl,
164
+ token: this.token,
165
+ apiKey: this.apiKey,
166
+ hmacSecret: this.hmacSecret,
167
+ encryptRequests: this.encryptRequests,
168
+ };
169
+ }
170
+
171
+ _request<T>(
172
+ method: string,
173
+ path: string,
174
+ body?: unknown,
175
+ withAuth = true,
176
+ extraHeaders?: Record<string, string>,
177
+ ): Promise<T> {
178
+ return entityRequest<T>(
179
+ this._reqOpts,
180
+ method,
181
+ path,
182
+ body,
183
+ withAuth,
184
+ extraHeaders,
185
+ );
186
+ }
187
+
188
+ /** PNG/바이너리 응답을 ArrayBuffer로 반환합니다. (QR, 바코드 등) */
189
+ async _requestBinary(
190
+ method: string,
191
+ path: string,
192
+ body?: unknown,
193
+ withAuth = true,
194
+ ): Promise<ArrayBuffer> {
195
+ const headers: Record<string, string> = {
196
+ "Content-Type": "application/json",
197
+ };
198
+ if (withAuth && this.token)
199
+ headers["Authorization"] = `Bearer ${this.token}`;
200
+ if (this.apiKey) headers["X-API-Key"] = this.apiKey;
201
+
202
+ const res = await fetch(this.baseUrl + path, {
203
+ method,
204
+ headers,
205
+ ...(body != null ? { body: JSON.stringify(body) } : {}),
206
+ });
207
+
208
+ if (!res.ok) {
209
+ const text = await res.text();
210
+ const err = new Error(`HTTP ${res.status}: ${text}`);
211
+ (err as { status?: number }).status = res.status;
212
+ throw err;
213
+ }
214
+
215
+ return res.arrayBuffer();
216
+ }
217
+
218
+ /** multipart/form-data 요청을 보냅니다. (파일 업로드 등) */
219
+ async _requestForm<T>(
220
+ method: string,
221
+ path: string,
222
+ form: FormData,
223
+ withAuth = true,
224
+ ): Promise<T> {
225
+ const headers: Record<string, string> = {};
226
+ if (withAuth && this.token)
227
+ headers["Authorization"] = `Bearer ${this.token}`;
228
+ if (this.apiKey) headers["X-API-Key"] = this.apiKey;
229
+
230
+ const res = await fetch(this.baseUrl + path, {
231
+ method,
232
+ headers,
233
+ body: form,
234
+ });
235
+
236
+ const data = await res.json();
237
+ if (!data.ok) {
238
+ const err = new Error(
239
+ data.message ?? `EntityServer error (HTTP ${res.status})`,
240
+ );
241
+ (err as { status?: number }).status = res.status;
242
+ throw err;
243
+ }
244
+ return data as T;
245
+ }
246
+ }
@@ -8,34 +8,25 @@ import { hkdf } from "@noble/hashes/hkdf";
8
8
  /**
9
9
  * 패킷 암호화 키를 유도합니다.
10
10
  * - HMAC 모드 (`hmacSecret` 유효 시): HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
11
- * - JWT 모드: SHA-256(jwt_token)
11
+ * - JWT 모드: HKDF-SHA256(jwt_token, "entity-server:packet-encryption")
12
12
  */
13
13
  export function derivePacketKey(hmacSecret: string, token: string): Uint8Array {
14
- if (hmacSecret) {
15
- const salt = new TextEncoder().encode("entity-server:hkdf:v1");
16
- const info = new TextEncoder().encode(
17
- "entity-server:packet-encryption",
18
- );
19
- return hkdf(
20
- sha256,
21
- new TextEncoder().encode(hmacSecret),
22
- salt,
23
- info,
24
- 32,
25
- );
26
- }
27
- return sha256(new TextEncoder().encode(token));
14
+ const ikm = hmacSecret || token;
15
+ const salt = new TextEncoder().encode("entity-server:hkdf:v1");
16
+ const info = new TextEncoder().encode("entity-server:packet-encryption");
17
+ return hkdf(sha256, new TextEncoder().encode(ikm), salt, info, 32);
28
18
  }
29
19
 
30
20
  /**
31
21
  * 평문 바이트를 XChaCha20-Poly1305로 암호화합니다.
32
- * 포맷: [random_magic:magicLen][random_nonce:24][ciphertext+tag]
22
+ * 포맷: [random_magic:K][random_nonce:24][ciphertext+tag]
23
+ * K = 2 + key[31] % 14 (패킷 키에서 자동 파생)
33
24
  */
34
25
  export function encryptPacket(
35
26
  plaintext: Uint8Array,
36
27
  key: Uint8Array,
37
- magicLen: number,
38
28
  ): Uint8Array {
29
+ const magicLen = 2 + (key[31] % 14);
39
30
  const magic = new Uint8Array(magicLen);
40
31
  const nonce = new Uint8Array(24);
41
32
  crypto.getRandomValues(magic);
@@ -51,13 +42,11 @@ export function encryptPacket(
51
42
 
52
43
  /**
53
44
  * XChaCha20-Poly1305 패킷을 복호화해 JSON 객체로 변환합니다.
54
- * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
45
+ * 포맷: [magic:K][nonce:24][ciphertext+tag]
46
+ * K = 2 + key[31] % 14 (패킷 키에서 자동 파생)
55
47
  */
56
- export function decryptPacket<T>(
57
- buffer: ArrayBuffer,
58
- key: Uint8Array,
59
- magicLen: number,
60
- ): T {
48
+ export function decryptPacket<T>(buffer: ArrayBuffer, key: Uint8Array): T {
49
+ const magicLen = 2 + (key[31] % 14);
61
50
  const data = new Uint8Array(buffer);
62
51
  if (data.length < magicLen + 24 + 16) {
63
52
  throw new Error("Encrypted packet too short");
@@ -79,7 +68,6 @@ export function parseRequestBody<T>(
79
68
  contentType: string,
80
69
  requireEncrypted: boolean,
81
70
  key: Uint8Array,
82
- magicLen: number,
83
71
  ): T {
84
72
  const isEncrypted = contentType
85
73
  .toLowerCase()
@@ -93,14 +81,13 @@ export function parseRequestBody<T>(
93
81
 
94
82
  if (isEncrypted) {
95
83
  if (body == null) throw new Error("Encrypted request body is empty");
96
- if (body instanceof ArrayBuffer)
97
- return decryptPacket<T>(body, key, magicLen);
84
+ if (body instanceof ArrayBuffer) return decryptPacket<T>(body, key);
98
85
  if (body instanceof Uint8Array) {
99
86
  const sliced = body.buffer.slice(
100
87
  body.byteOffset,
101
88
  body.byteOffset + body.byteLength,
102
89
  );
103
- return decryptPacket<T>(sliced as ArrayBuffer, key, magicLen);
90
+ return decryptPacket<T>(sliced as ArrayBuffer, key);
104
91
  }
105
92
  throw new Error(
106
93
  "Encrypted request body must be ArrayBuffer or Uint8Array",
@@ -6,7 +6,6 @@ export interface RequestOptions {
6
6
  token: string;
7
7
  apiKey: string;
8
8
  hmacSecret: string;
9
- packetMagicLen: number;
10
9
  encryptRequests: boolean;
11
10
  }
12
11
 
@@ -25,14 +24,7 @@ export async function entityRequest<T>(
25
24
  withAuth = true,
26
25
  extraHeaders: Record<string, string> = {},
27
26
  ): Promise<T> {
28
- const {
29
- baseUrl,
30
- token,
31
- apiKey,
32
- hmacSecret,
33
- packetMagicLen,
34
- encryptRequests,
35
- } = opts;
27
+ const { baseUrl, token, apiKey, hmacSecret, encryptRequests } = opts;
36
28
  const isHmacMode = withAuth && !!(apiKey && hmacSecret);
37
29
 
38
30
  const headers: Record<string, string> = {
@@ -57,7 +49,6 @@ export async function entityRequest<T>(
57
49
  fetchBody = encryptPacket(
58
50
  new TextEncoder().encode(JSON.stringify(body)),
59
51
  key,
60
- packetMagicLen,
61
52
  );
62
53
  headers["Content-Type"] = "application/octet-stream";
63
54
  } else {
@@ -87,7 +78,7 @@ export async function entityRequest<T>(
87
78
  const contentType = res.headers.get("Content-Type") ?? "";
88
79
  if (contentType.includes("application/octet-stream")) {
89
80
  const key = derivePacketKey(hmacSecret, token);
90
- return decryptPacket<T>(await res.arrayBuffer(), key, packetMagicLen);
81
+ return decryptPacket<T>(await res.arrayBuffer(), key);
91
82
  }
92
83
 
93
84
  const data = await res.json();
@@ -1,9 +1,25 @@
1
- /** Vite 환경변수(`import.meta.env`)에서 값을 읽습니다. */
1
+ /**
2
+ * 환경변수를 읽습니다.
3
+ * - 브라우저/Vite: `import.meta.env`
4
+ * - Node.js: `process.env`
5
+ */
2
6
  export function readEnv(name: string): string | undefined {
7
+ // Vite / 기타 번들러 (import.meta.env)
3
8
  const meta = import.meta as unknown as {
4
9
  env?: Record<string, string | undefined>;
5
10
  };
6
- return meta?.env?.[name];
11
+ if (meta?.env?.[name] != null) return meta.env[name];
12
+
13
+ // Node.js (process.env)
14
+ if (
15
+ typeof process !== "undefined" &&
16
+ process.env &&
17
+ process.env[name] != null
18
+ ) {
19
+ return process.env[name];
20
+ }
21
+
22
+ return undefined;
7
23
  }
8
24
 
9
25
  /** 쿼리 파라미터 객체를 URL 쿼리 문자열로 변환합니다. `orderBy` 키는 `order_by`로 변환됩니다. */
@@ -69,7 +69,6 @@ export function useEntityServer(
69
69
  singleton = true,
70
70
  tokenResolver,
71
71
  baseUrl,
72
- packetMagicLen,
73
72
  token,
74
73
  resumeSession,
75
74
  } = options;
@@ -100,10 +99,10 @@ export function useEntityServer(
100
99
  const client = useMemo(() => {
101
100
  const c = singleton
102
101
  ? entityServer
103
- : new EntityServerClient({ baseUrl, packetMagicLen, token });
102
+ : new EntityServerClient({ baseUrl, token });
104
103
 
105
104
  if (singleton) {
106
- c.configure({ baseUrl, packetMagicLen, token });
105
+ c.configure({ baseUrl, token });
107
106
  }
108
107
 
109
108
  const resolvedToken = tokenResolver?.();
@@ -112,7 +111,7 @@ export function useEntityServer(
112
111
  }
113
112
 
114
113
  return c;
115
- }, [singleton, tokenResolver, baseUrl, packetMagicLen, token]);
114
+ }, [singleton, tokenResolver, baseUrl, token]);
116
115
 
117
116
  const run = useCallback(async <T>(fn: () => Promise<T>): Promise<T> => {
118
117
  if (mountedRef.current) {
@@ -0,0 +1,35 @@
1
+ import type { AlimtalkSendRequest, FriendtalkSendRequest } from "../types";
2
+ import type { GConstructor, EntityServerClientBase } from "../client/base";
3
+
4
+ export function AlimtalkMixin<
5
+ TBase extends GConstructor<EntityServerClientBase>,
6
+ >(Base: TBase) {
7
+ return class AlimtalkMixinClass extends Base {
8
+ // ─── 알림톡 / 친구톡 ─────────────────────────────────────────────────
9
+
10
+ /** 알림톡(KakaoTalk 비즈 메시지)을 발송합니다. */
11
+ alimtalkSend(req: AlimtalkSendRequest): Promise<{ message: string }> {
12
+ return this._request("POST", "/v1/alimtalk/send", req);
13
+ }
14
+
15
+ /** 알림톡 발송 상태를 조회합니다. */
16
+ alimtalkStatus(seq: number): Promise<{ ok: boolean; status: string }> {
17
+ return this._request("GET", `/v1/alimtalk/status/${seq}`);
18
+ }
19
+
20
+ /** 사용 가능한 알림톡 템플릿 목록을 조회합니다. */
21
+ alimtalkTemplates(): Promise<{
22
+ templates: Array<{ code: string; name: string; content: string }>;
23
+ count: number;
24
+ }> {
25
+ return this._request("GET", "/v1/alimtalk/templates");
26
+ }
27
+
28
+ /** 친구톡(KakaoTalk 채널 메시지)을 발송합니다. */
29
+ friendtalkSend(
30
+ req: FriendtalkSendRequest,
31
+ ): Promise<{ message: string }> {
32
+ return this._request("POST", "/v1/friendtalk/send", req);
33
+ }
34
+ };
35
+ }