@xfilecom/xframe 0.1.34 → 0.1.36

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 (40) hide show
  1. package/bin/xframe.js +12 -10
  2. package/defaults.json +2 -2
  3. package/package.json +1 -1
  4. package/template/README.md +1 -1
  5. package/template/apps/api/src/main.ts +53 -13
  6. package/template/shared/endpoint/README.md +30 -1
  7. package/template/shared/endpoint/endpoint.ts +55 -0
  8. package/template/shared/endpoint/index.ts +2 -0
  9. package/template/web/admin/src/App.tsx +2 -7
  10. package/template/web/admin/src/main.tsx +12 -1
  11. package/template/web/admin/tsconfig.json +1 -1
  12. package/template/web/admin/vite.config.ts +2 -7
  13. package/template/web/client/src/App.tsx +2 -7
  14. package/template/web/client/src/main.tsx +12 -1
  15. package/template/web/client/tsconfig.json +1 -1
  16. package/template/web/client/vite.config.ts +4 -7
  17. package/template/web/shared/README.md +4 -5
  18. package/template/web/shared/package.json +3 -1
  19. package/template/web/shared/src/api/client.ts +132 -0
  20. package/template/web/shared/src/api/methods/health.method.ts +14 -0
  21. package/template/web/shared/src/api/methods/index.ts +31 -0
  22. package/template/web/shared/src/api/request.ts +65 -0
  23. package/template/web/shared/src/api/types.ts +80 -0
  24. package/template/web/shared/src/context/StoreProvider.tsx +45 -0
  25. package/template/web/shared/src/hooks/useAppStore.ts +23 -0
  26. package/template/web/shared/src/hooks/useHealthStatus.ts +12 -3
  27. package/template/web/shared/src/index.ts +53 -1
  28. package/template/web/shared/src/lib/http.ts +9 -6
  29. package/template/web/shared/src/methods/health.ts +5 -4
  30. package/template/web/shared/src/params/param-store.ts +178 -0
  31. package/template/web/shared/src/params/types.ts +21 -0
  32. package/template/web/shared/src/params/validations.ts +32 -0
  33. package/template/web/shared/src/store/api-cache-store.ts +35 -0
  34. package/template/web/shared/src/store/index.ts +4 -0
  35. package/template/web/shared/src/store/root-store.ts +358 -0
  36. package/template/web/shared/src/store/session-store.ts +28 -0
  37. package/template/web/shared/src/types/ui.ts +37 -0
  38. package/template/web/shared/tsconfig.json +4 -1
  39. package/template/yarn.lock +237 -13
  40. package/template/web/shared/src/stores/appStore.ts +0 -11
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Root store — session, param, cache, indicator, execute/sendMessage (business-promotion 패턴 축소판)
3
+ */
4
+
5
+ import { makeAutoObservable, runInAction } from 'mobx';
6
+ import { setApiHooks } from '../api/client';
7
+ import { sendRequest } from '../api/request';
8
+ import type {
9
+ ApiMethodConfig,
10
+ ApiMethodEndpointCall,
11
+ ApiMethodStore,
12
+ CoreSdkWrapped,
13
+ ExecuteContext,
14
+ } from '../api/types';
15
+ import type { EndpointDef } from '@shared/endpoint';
16
+ import {
17
+ methods,
18
+ screenParamSchemas,
19
+ type MethodName,
20
+ } from '../api/methods';
21
+ import { apiCacheStore } from './api-cache-store';
22
+ import { sessionStore } from './session-store';
23
+ import { ParamStore } from '../params/param-store';
24
+ import type { ApiErrorItem, IndicatorState, SendMessageOptions, ToastItem } from '../types/ui';
25
+ import type { ToastSeverity } from '../types/ui';
26
+
27
+ function genId() {
28
+ return `id_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
29
+ }
30
+
31
+ function unwrapCoreSdk<T>(res: T | CoreSdkWrapped<T>, unwrapDataArray: boolean): T | null {
32
+ if (!unwrapDataArray) return res as T;
33
+ const r = res as CoreSdkWrapped<T>;
34
+ if (r?.data != null) {
35
+ if (Array.isArray(r.data)) return (r.data[0] ?? null) as T | null;
36
+ return r.data as T;
37
+ }
38
+ return res as T;
39
+ }
40
+
41
+ function extractMessageString(value: unknown): string | null {
42
+ if (value == null) return null;
43
+ if (typeof value === 'string') return value;
44
+ if (Array.isArray(value)) {
45
+ const first = value.find((x) => typeof x === 'string');
46
+ return first ?? (value.map((x) => (typeof x === 'string' ? x : String(x))).join(', ') || null);
47
+ }
48
+ if (typeof value === 'object' && 'message' in value) {
49
+ const inner = (value as { message: unknown }).message;
50
+ return extractMessageString(inner);
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function getErrorMessage(e: unknown): string {
56
+ const err = e as { response?: { data?: { message?: unknown; error?: unknown } }; message?: string };
57
+ const raw = err?.response?.data?.message ?? err?.response?.data?.error ?? err?.message;
58
+ const extracted = extractMessageString(raw);
59
+ if (extracted) return extracted;
60
+ if (raw != null && typeof raw === 'string') return raw;
61
+ return (err as Error)?.message ?? 'Request failed';
62
+ }
63
+
64
+ export class RootStore {
65
+ readonly sessionStore = sessionStore;
66
+ readonly apiCacheStore = apiCacheStore;
67
+ readonly paramStore = new ParamStore(screenParamSchemas, {
68
+ getStore: () => this,
69
+ });
70
+
71
+ private _indicator: IndicatorState = { loading: false, methodLoading: null };
72
+ private _errors: ApiErrorItem[] = [];
73
+ private _toasts: ToastItem[] = [];
74
+ private _requestDepth = 0;
75
+ private _multiEndpointBatch = false;
76
+
77
+ /** 데모·useHealthStatus 용 타임스탬프 */
78
+ lastHealthAt: number | null = null;
79
+
80
+ constructor() {
81
+ makeAutoObservable(this);
82
+ this.sessionStore.registerLogoutCallback(() => {
83
+ runInAction(() => {
84
+ this.apiCacheStore.clear();
85
+ });
86
+ });
87
+ setApiHooks({
88
+ onRequestStart: () => {
89
+ if (this._multiEndpointBatch) return;
90
+ runInAction(() => this._startRequest());
91
+ },
92
+ onRequestEnd: () => {
93
+ if (this._multiEndpointBatch) return;
94
+ runInAction(() => this._endRequest());
95
+ },
96
+ onError: () => {},
97
+ });
98
+ }
99
+
100
+ get indicator(): IndicatorState {
101
+ return this._indicator;
102
+ }
103
+
104
+ get errors(): ApiErrorItem[] {
105
+ return this._errors;
106
+ }
107
+
108
+ get toasts(): ToastItem[] {
109
+ return this._toasts;
110
+ }
111
+
112
+ setLastHealthAt(t: number) {
113
+ this.lastHealthAt = t;
114
+ }
115
+
116
+ private _startRequest() {
117
+ this._requestDepth++;
118
+ if (this._requestDepth === 1) {
119
+ this._indicator = { ...this._indicator, loading: true };
120
+ }
121
+ }
122
+
123
+ private _endRequest() {
124
+ this._requestDepth = Math.max(0, this._requestDepth - 1);
125
+ if (this._requestDepth === 0) {
126
+ this._indicator = { ...this._indicator, loading: false };
127
+ }
128
+ }
129
+
130
+ setIndicator(loading: boolean, message?: string) {
131
+ this._indicator = { ...this._indicator, loading, message };
132
+ }
133
+
134
+ setMethodLoading(methodName: string | null) {
135
+ this._indicator = { ...this._indicator, methodLoading: methodName };
136
+ }
137
+
138
+ addError(message: string, code?: string | number) {
139
+ this._errors = [{ id: genId(), message, code, createdAt: Date.now() }, ...this._errors].slice(0, 50);
140
+ }
141
+
142
+ clearError(id: string) {
143
+ this._errors = this._errors.filter((e) => e.id !== id);
144
+ }
145
+
146
+ clearAllErrors() {
147
+ this._errors = [];
148
+ }
149
+
150
+ private static readonly TOAST_AUTO_DISMISS_MS = 2000;
151
+
152
+ addToast(message: string, severity: ToastSeverity = 'info') {
153
+ if (this._toasts.some((t) => t.message === message && t.severity === severity)) return;
154
+ const id = genId();
155
+ this._toasts = [...this._toasts, { id, message, severity, createdAt: Date.now() }].slice(-20);
156
+ setTimeout(() => {
157
+ runInAction(() => this.removeToast(id));
158
+ }, RootStore.TOAST_AUTO_DISMISS_MS);
159
+ }
160
+
161
+ removeToast(id: string) {
162
+ this._toasts = this._toasts.filter((t) => t.id !== id);
163
+ }
164
+
165
+ async execute<TPayload = unknown, TResult = unknown>(
166
+ config: ApiMethodConfig<TPayload, TResult>,
167
+ payload?: TPayload,
168
+ ctx?: ExecuteContext,
169
+ ): Promise<TResult> {
170
+ const store = this as unknown as ApiMethodStore;
171
+ const ok = config.validate ? config.validate(store, payload) : true;
172
+ if (!ok) throw new Error(config.ui?.errorMessage ?? 'Validation failed');
173
+
174
+ const ui = config.ui ?? {};
175
+ const showIndicator = ui.showIndicator ?? true;
176
+ const showErrorToast = ui.showErrorToast ?? true;
177
+ const showSuccessToast = ui.showSuccessToast ?? false;
178
+ const unwrapDataArray = ui.unwrapDataArray ?? true;
179
+ const startTime = Date.now();
180
+ const MIN_DELAY_MS = 300;
181
+
182
+ const useMulti = Array.isArray(config.endpoints) && config.endpoints.length > 0;
183
+
184
+ if (useMulti) {
185
+ this._multiEndpointBatch = true;
186
+ if (showIndicator) this.setIndicator(true, ui.indicatorMessage);
187
+ try {
188
+ const results: unknown[] = [];
189
+ for (const call of config.endpoints as ApiMethodEndpointCall<TPayload, ApiMethodStore>[]) {
190
+ const body = call.body
191
+ ? (call.body(store, payload) as unknown)
192
+ : config.body
193
+ ? (config.body(store, payload) as unknown)
194
+ : (payload as unknown);
195
+ const query = call.query ? call.query(store, payload) : config.query?.(store, payload);
196
+ const params = call.params ? call.params(store, payload) : config.params?.(store, payload);
197
+ const res = await sendRequest<unknown, unknown>(call.endpoint, {
198
+ body,
199
+ query,
200
+ params,
201
+ });
202
+ const unwrapped = unwrapCoreSdk<unknown>(res, unwrapDataArray);
203
+ if (unwrapped == null) throw new Error(ui.errorMessage ?? 'Request failed');
204
+ results.push(unwrapped);
205
+ }
206
+ const elapsed = Date.now() - startTime;
207
+ if (elapsed < MIN_DELAY_MS) {
208
+ await new Promise((r) => setTimeout(r, MIN_DELAY_MS - elapsed));
209
+ }
210
+ const result = results as TResult;
211
+ if (showSuccessToast && ui.successMessage) {
212
+ runInAction(() => this.addToast(ui.successMessage!, 'success'));
213
+ }
214
+ if (config.post) runInAction(() => config.post!(result, store, payload));
215
+ const effectiveCtx: ExecuteContext = {
216
+ redirectTo: ctx?.redirectTo,
217
+ navigate: ctx?.navigate,
218
+ };
219
+ if (config.redirectPath && effectiveCtx.navigate) {
220
+ const path =
221
+ typeof config.redirectPath === 'function'
222
+ ? config.redirectPath(result, payload, effectiveCtx)
223
+ : config.redirectPath;
224
+ effectiveCtx.navigate(path, { replace: true });
225
+ }
226
+ return result;
227
+ } catch (e) {
228
+ if (showErrorToast) {
229
+ runInAction(() => this.addToast(getErrorMessage(e), 'error'));
230
+ }
231
+ throw e;
232
+ } finally {
233
+ this._multiEndpointBatch = false;
234
+ if (showIndicator) this.setIndicator(false);
235
+ }
236
+ }
237
+
238
+ const ep = config.endpoint;
239
+ if (!ep) throw new Error('ApiMethodConfig: endpoint or endpoints required');
240
+
241
+ const res = await this.sendMessage<unknown, TResult | CoreSdkWrapped<TResult>>(ep, {
242
+ params: config.params ? config.params(store, payload) : undefined,
243
+ body: config.body ? (config.body(store, payload) as unknown) : (payload as unknown),
244
+ query: config.query ? config.query(store, payload) : undefined,
245
+ showIndicator,
246
+ indicatorMessage: ui.indicatorMessage,
247
+ showErrorToast,
248
+ showSuccessToast,
249
+ successMessage: ui.successMessage,
250
+ });
251
+
252
+ const elapsed = Date.now() - startTime;
253
+ if (elapsed < MIN_DELAY_MS) {
254
+ await new Promise((r) => setTimeout(r, MIN_DELAY_MS - elapsed));
255
+ }
256
+
257
+ const result = unwrapCoreSdk<TResult>(res, unwrapDataArray);
258
+ if (result == null && unwrapDataArray) throw new Error(ui.errorMessage ?? 'Request failed');
259
+ const finalResult = (unwrapDataArray ? result : res) as TResult;
260
+ if (config.post) runInAction(() => config.post!(finalResult, store, payload));
261
+
262
+ const effectiveCtx: ExecuteContext = {
263
+ redirectTo: ctx?.redirectTo,
264
+ navigate: ctx?.navigate,
265
+ };
266
+
267
+ if (config.redirectPath && effectiveCtx.navigate) {
268
+ const path =
269
+ typeof config.redirectPath === 'function'
270
+ ? config.redirectPath(finalResult, payload, effectiveCtx)
271
+ : config.redirectPath;
272
+ effectiveCtx.navigate(path, { replace: true });
273
+ }
274
+ return finalResult;
275
+ }
276
+
277
+ async executeMethod<K extends MethodName>(
278
+ methodName: K,
279
+ payload?: (typeof methods)[K] extends ApiMethodConfig<infer P, unknown> ? P : never,
280
+ ctx?: ExecuteContext,
281
+ ): Promise<
282
+ (typeof methods)[K] extends ApiMethodConfig<unknown, infer R> ? R : never
283
+ > {
284
+ const config = methods[methodName] as unknown as ApiMethodConfig<unknown, unknown>;
285
+ runInAction(() => this.setMethodLoading(methodName as string));
286
+ try {
287
+ return (await this.execute(config, payload, ctx)) as (typeof methods)[K] extends ApiMethodConfig<
288
+ unknown,
289
+ infer R
290
+ >
291
+ ? R
292
+ : never;
293
+ } finally {
294
+ runInAction(() => this.setMethodLoading(null));
295
+ }
296
+ }
297
+
298
+ async getCachedOrExecute<K extends MethodName>(
299
+ cacheKey: string,
300
+ methodName: K,
301
+ ttlMs?: number,
302
+ payload?: (typeof methods)[K] extends ApiMethodConfig<infer P, unknown> ? P : never,
303
+ ctx?: ExecuteContext,
304
+ ): Promise<(typeof methods)[K] extends ApiMethodConfig<unknown, infer R> ? R : never> {
305
+ const cached = this.apiCacheStore.get<
306
+ (typeof methods)[K] extends ApiMethodConfig<unknown, infer R> ? R : never
307
+ >(cacheKey);
308
+ if (cached !== undefined) return cached;
309
+ const result = await this.executeMethod(methodName, payload, ctx);
310
+ this.apiCacheStore.set(cacheKey, result, ttlMs);
311
+ return result;
312
+ }
313
+
314
+ /**
315
+ * `EndpointDef.post`·`timeoutMs` 는 `sendRequest` 에서 적용.
316
+ * `showIndicator` 는 options → `endpoint.showIndicator` → `true` 순으로 결정.
317
+ */
318
+ async sendMessage<T = unknown, R = unknown>(endpoint: EndpointDef, options: SendMessageOptions<T> = {}): Promise<R> {
319
+ const {
320
+ params,
321
+ query,
322
+ body,
323
+ showIndicator: showIndicatorOpt,
324
+ indicatorMessage,
325
+ showErrorToast = true,
326
+ showSuccessToast = false,
327
+ successMessage,
328
+ } = options;
329
+
330
+ const showIndicator =
331
+ showIndicatorOpt !== undefined ? showIndicatorOpt : (endpoint.showIndicator ?? true);
332
+
333
+ if (showIndicator) this.setIndicator(true, indicatorMessage);
334
+ try {
335
+ const data = await sendRequest<T, R>(endpoint, {
336
+ params,
337
+ query,
338
+ body,
339
+ skipGlobalIndicator: !showIndicator,
340
+ });
341
+ runInAction(() => {
342
+ if (showSuccessToast && successMessage) this.addToast(successMessage, 'success');
343
+ });
344
+ return data;
345
+ } catch (e) {
346
+ runInAction(() => {
347
+ if (showErrorToast) this.addToast(getErrorMessage(e), 'error');
348
+ });
349
+ throw e;
350
+ } finally {
351
+ runInAction(() => {
352
+ if (showIndicator) this.setIndicator(false);
353
+ });
354
+ }
355
+ }
356
+ }
357
+
358
+ export const rootStore = new RootStore();
@@ -0,0 +1,28 @@
1
+ import { makeAutoObservable } from 'mobx';
2
+
3
+ /** Bearer 토큰 등 — api/client 가 Authorization 헤더에 반영 */
4
+ export class SessionStore {
5
+ token: string | null = null;
6
+ private _onLogout?: () => void;
7
+
8
+ constructor() {
9
+ makeAutoObservable(this);
10
+ }
11
+
12
+ /** RootStore 등에서 한 번 등록 — 토큰 제거 시 캐시 비우기 등 */
13
+ registerLogoutCallback(cb: () => void) {
14
+ this._onLogout = cb;
15
+ }
16
+
17
+ setToken(token: string | null) {
18
+ const had = this.token != null;
19
+ this.token = token;
20
+ if (had && token == null) this._onLogout?.();
21
+ }
22
+
23
+ logout() {
24
+ this.setToken(null);
25
+ }
26
+ }
27
+
28
+ export const sessionStore = new SessionStore();
@@ -0,0 +1,37 @@
1
+ /**
2
+ * indicator / toast / sendMessage 옵션 — RootStore·api/client 와 공유
3
+ */
4
+
5
+ export type ToastSeverity = 'info' | 'success' | 'warn' | 'error';
6
+
7
+ export interface ToastItem {
8
+ id: string;
9
+ message: string;
10
+ severity: ToastSeverity;
11
+ createdAt: number;
12
+ }
13
+
14
+ export interface ApiErrorItem {
15
+ id: string;
16
+ message: string;
17
+ code?: string | number;
18
+ createdAt: number;
19
+ }
20
+
21
+ export interface IndicatorState {
22
+ loading: boolean;
23
+ message?: string;
24
+ methodLoading?: string | null;
25
+ }
26
+
27
+ export interface SendMessageOptions<T = unknown> {
28
+ params?: Record<string, string>;
29
+ query?: Record<string, string | number | boolean | undefined>;
30
+ body?: T;
31
+ /** 미지정 시 `EndpointDef.showIndicator`, 그다음 기본 `true` */
32
+ showIndicator?: boolean;
33
+ indicatorMessage?: string;
34
+ showErrorToast?: boolean;
35
+ showSuccessToast?: boolean;
36
+ successMessage?: string;
37
+ }
@@ -10,7 +10,10 @@
10
10
  "declaration": true,
11
11
  "declarationMap": true,
12
12
  "outDir": "dist",
13
- "rootDir": "src"
13
+ "baseUrl": ".",
14
+ "paths": {
15
+ "@shared/*": ["../../shared/*"]
16
+ }
14
17
  },
15
18
  "include": ["src/**/*"]
16
19
  }