connectbase-client 0.1.0

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/dist/index.mjs ADDED
@@ -0,0 +1,4543 @@
1
+ // src/types/error.ts
2
+ var ApiError = class extends Error {
3
+ constructor(statusCode, message) {
4
+ super(message);
5
+ this.statusCode = statusCode;
6
+ this.name = "ApiError";
7
+ }
8
+ };
9
+ var AuthError = class extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "AuthError";
13
+ }
14
+ };
15
+
16
+ // src/core/http.ts
17
+ var HttpClient = class {
18
+ constructor(config) {
19
+ this.isRefreshing = false;
20
+ this.refreshPromise = null;
21
+ this.config = config;
22
+ }
23
+ updateConfig(config) {
24
+ this.config = { ...this.config, ...config };
25
+ }
26
+ setTokens(accessToken, refreshToken) {
27
+ this.config.accessToken = accessToken;
28
+ this.config.refreshToken = refreshToken;
29
+ }
30
+ clearTokens() {
31
+ this.config.accessToken = void 0;
32
+ this.config.refreshToken = void 0;
33
+ }
34
+ /**
35
+ * API Key가 설정되어 있는지 확인
36
+ */
37
+ hasApiKey() {
38
+ return !!this.config.apiKey;
39
+ }
40
+ /**
41
+ * API Key 반환
42
+ */
43
+ getApiKey() {
44
+ return this.config.apiKey;
45
+ }
46
+ /**
47
+ * Access Token 반환
48
+ */
49
+ getAccessToken() {
50
+ return this.config.accessToken;
51
+ }
52
+ /**
53
+ * Base URL 반환
54
+ */
55
+ getBaseUrl() {
56
+ return this.config.baseUrl;
57
+ }
58
+ async refreshAccessToken() {
59
+ if (this.isRefreshing) {
60
+ return this.refreshPromise;
61
+ }
62
+ this.isRefreshing = true;
63
+ if (!this.config.refreshToken) {
64
+ this.isRefreshing = false;
65
+ const error = new AuthError("Refresh token is missing. Please login again.");
66
+ this.config.onAuthError?.(error);
67
+ throw error;
68
+ }
69
+ this.refreshPromise = (async () => {
70
+ try {
71
+ const response = await fetch(`${this.config.baseUrl}/v1/auth/re-issue`, {
72
+ method: "POST",
73
+ headers: {
74
+ "Content-Type": "application/json",
75
+ "Authorization": `Bearer ${this.config.refreshToken}`
76
+ }
77
+ });
78
+ if (!response.ok) {
79
+ throw new Error("Token refresh failed");
80
+ }
81
+ const data = await response.json();
82
+ this.config.accessToken = data.access_token;
83
+ this.config.refreshToken = data.refresh_token;
84
+ this.config.onTokenRefresh?.({
85
+ accessToken: data.access_token,
86
+ refreshToken: data.refresh_token
87
+ });
88
+ return data.access_token;
89
+ } catch {
90
+ this.clearTokens();
91
+ const error = new AuthError("Token refresh failed. Please login again.");
92
+ this.config.onAuthError?.(error);
93
+ throw error;
94
+ } finally {
95
+ this.isRefreshing = false;
96
+ this.refreshPromise = null;
97
+ }
98
+ })();
99
+ return this.refreshPromise;
100
+ }
101
+ isTokenExpired(token) {
102
+ try {
103
+ const payload = JSON.parse(atob(token.split(".")[1]));
104
+ const currentTime = Date.now() / 1e3;
105
+ return payload.exp < currentTime + 300;
106
+ } catch {
107
+ return true;
108
+ }
109
+ }
110
+ async prepareHeaders(config) {
111
+ const headers = new Headers();
112
+ headers.set("Content-Type", "application/json");
113
+ if (this.config.apiKey) {
114
+ headers.set("X-API-Key", this.config.apiKey);
115
+ }
116
+ if (!config?.skipAuth && this.config.accessToken) {
117
+ let token = this.config.accessToken;
118
+ if (this.isTokenExpired(token) && this.config.refreshToken) {
119
+ const newToken = await this.refreshAccessToken();
120
+ if (newToken) {
121
+ token = newToken;
122
+ }
123
+ }
124
+ headers.set("Authorization", `Bearer ${token}`);
125
+ }
126
+ if (config?.headers) {
127
+ Object.entries(config.headers).forEach(([key, value]) => {
128
+ headers.set(key, value);
129
+ });
130
+ }
131
+ return headers;
132
+ }
133
+ async handleResponse(response) {
134
+ if (!response.ok) {
135
+ const errorData = await response.json().catch(() => ({
136
+ message: response.statusText
137
+ }));
138
+ throw new ApiError(response.status, errorData.message || errorData.error || "Unknown error");
139
+ }
140
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
141
+ return {};
142
+ }
143
+ return response.json();
144
+ }
145
+ async get(url, config) {
146
+ const headers = await this.prepareHeaders(config);
147
+ const response = await fetch(`${this.config.baseUrl}${url}`, {
148
+ method: "GET",
149
+ headers
150
+ });
151
+ return this.handleResponse(response);
152
+ }
153
+ async post(url, data, config) {
154
+ const headers = await this.prepareHeaders(config);
155
+ if (data instanceof FormData) {
156
+ headers.delete("Content-Type");
157
+ }
158
+ const response = await fetch(`${this.config.baseUrl}${url}`, {
159
+ method: "POST",
160
+ headers,
161
+ body: data instanceof FormData ? data : JSON.stringify(data)
162
+ });
163
+ return this.handleResponse(response);
164
+ }
165
+ async put(url, data, config) {
166
+ const headers = await this.prepareHeaders(config);
167
+ const response = await fetch(`${this.config.baseUrl}${url}`, {
168
+ method: "PUT",
169
+ headers,
170
+ body: JSON.stringify(data)
171
+ });
172
+ return this.handleResponse(response);
173
+ }
174
+ async patch(url, data, config) {
175
+ const headers = await this.prepareHeaders(config);
176
+ const response = await fetch(`${this.config.baseUrl}${url}`, {
177
+ method: "PATCH",
178
+ headers,
179
+ body: JSON.stringify(data)
180
+ });
181
+ return this.handleResponse(response);
182
+ }
183
+ async delete(url, config) {
184
+ const headers = await this.prepareHeaders(config);
185
+ const response = await fetch(`${this.config.baseUrl}${url}`, {
186
+ method: "DELETE",
187
+ headers
188
+ });
189
+ return this.handleResponse(response);
190
+ }
191
+ };
192
+
193
+ // src/api/auth.ts
194
+ var ANONYMOUS_UID_KEY_PREFIX = "cb_anon_";
195
+ var GUEST_MEMBER_TOKEN_KEY_PREFIX = "cb_guest_";
196
+ function simpleHash(str) {
197
+ let hash = 0;
198
+ for (let i = 0; i < str.length; i++) {
199
+ const char = str.charCodeAt(i);
200
+ hash = (hash << 5) - hash + char;
201
+ hash = hash & hash;
202
+ }
203
+ return Math.abs(hash).toString(36);
204
+ }
205
+ var AuthAPI = class {
206
+ constructor(http) {
207
+ this.http = http;
208
+ this.anonymousLoginPromise = null;
209
+ this.guestMemberLoginPromise = null;
210
+ this.cachedAnonymousUIDKey = null;
211
+ this.cachedGuestMemberTokenKey = null;
212
+ }
213
+ /**
214
+ * 앱의 인증 설정 조회
215
+ * 어떤 로그인 방식이 허용되는지 확인합니다.
216
+ *
217
+ * @example
218
+ * ```typescript
219
+ * const settings = await client.auth.getAuthSettings()
220
+ * if (settings.allow_guest_login) {
221
+ * await client.auth.signInAsGuestMember()
222
+ * } else if (settings.allow_id_password_login) {
223
+ * // 로그인 폼 표시
224
+ * } else if (settings.enabled_oauth_providers.includes('GOOGLE')) {
225
+ * // 구글 소셜 로그인 버튼 표시
226
+ * }
227
+ * ```
228
+ */
229
+ async getAuthSettings() {
230
+ return this.http.get(
231
+ "/v1/public/auth-settings",
232
+ { skipAuth: true }
233
+ );
234
+ }
235
+ /**
236
+ * 현재 앱의 anonymous_uid 스토리지 키 생성
237
+ * apiKey를 해시하여 앱별 고유 키 생성 (원본 apiKey 노출 방지)
238
+ * 성능 최적화: 키를 캐싱하여 반복 해시 계산 방지
239
+ */
240
+ getAnonymousUIDKey() {
241
+ if (this.cachedAnonymousUIDKey) {
242
+ return this.cachedAnonymousUIDKey;
243
+ }
244
+ const apiKey = this.http.getApiKey();
245
+ if (!apiKey) {
246
+ this.cachedAnonymousUIDKey = `${ANONYMOUS_UID_KEY_PREFIX}default`;
247
+ } else {
248
+ const keyHash = simpleHash(apiKey);
249
+ this.cachedAnonymousUIDKey = `${ANONYMOUS_UID_KEY_PREFIX}${keyHash}`;
250
+ }
251
+ return this.cachedAnonymousUIDKey;
252
+ }
253
+ /**
254
+ * 회원가입
255
+ */
256
+ async signUp(data) {
257
+ const response = await this.http.post(
258
+ "/v1/auth/signup",
259
+ data,
260
+ { skipAuth: true }
261
+ );
262
+ this.http.setTokens(response.access_token, response.refresh_token);
263
+ return response;
264
+ }
265
+ /**
266
+ * 로그인
267
+ */
268
+ async signIn(data) {
269
+ const response = await this.http.post(
270
+ "/v1/auth/signin",
271
+ data,
272
+ { skipAuth: true }
273
+ );
274
+ this.http.setTokens(response.access_token, response.refresh_token);
275
+ return response;
276
+ }
277
+ /**
278
+ * 익명 로그인
279
+ * 계정 없이 게스트로 앱을 사용할 수 있습니다.
280
+ * 로컬 스토리지에 저장된 anonymous_uid가 있으면 기존 계정으로 재로그인을 시도합니다.
281
+ * 동시 호출 시 중복 요청 방지 (race condition 방지)
282
+ */
283
+ async signInAnonymously() {
284
+ if (this.anonymousLoginPromise) {
285
+ return this.anonymousLoginPromise;
286
+ }
287
+ this.anonymousLoginPromise = this.executeAnonymousLogin();
288
+ try {
289
+ return await this.anonymousLoginPromise;
290
+ } finally {
291
+ this.anonymousLoginPromise = null;
292
+ }
293
+ }
294
+ /**
295
+ * 실제 익명 로그인 실행 (내부 메서드)
296
+ */
297
+ async executeAnonymousLogin() {
298
+ const storedAnonymousUID = this.getStoredAnonymousUID();
299
+ const response = await this.http.post(
300
+ "/v1/auth/signin/anonymous",
301
+ storedAnonymousUID ? { anonymous_uid: storedAnonymousUID } : {},
302
+ { skipAuth: true }
303
+ );
304
+ this.http.setTokens(response.access_token, response.refresh_token);
305
+ if (response.anonymous_uid) {
306
+ this.storeAnonymousUID(response.anonymous_uid);
307
+ }
308
+ return response;
309
+ }
310
+ /**
311
+ * 저장된 anonymous_uid 조회
312
+ */
313
+ getStoredAnonymousUID() {
314
+ if (typeof localStorage === "undefined") return null;
315
+ return localStorage.getItem(this.getAnonymousUIDKey());
316
+ }
317
+ /**
318
+ * anonymous_uid 저장
319
+ */
320
+ storeAnonymousUID(uid) {
321
+ if (typeof localStorage === "undefined") return;
322
+ localStorage.setItem(this.getAnonymousUIDKey(), uid);
323
+ }
324
+ /**
325
+ * 저장된 anonymous_uid 삭제
326
+ */
327
+ clearAnonymousUID() {
328
+ if (typeof localStorage === "undefined") return;
329
+ localStorage.removeItem(this.getAnonymousUIDKey());
330
+ }
331
+ /**
332
+ * 로그아웃
333
+ */
334
+ async signOut() {
335
+ try {
336
+ await this.http.post("/v1/auth/logout");
337
+ } finally {
338
+ this.http.clearTokens();
339
+ }
340
+ }
341
+ /**
342
+ * 현재 사용자 정보 조회
343
+ * 토큰이 없거나 유효하지 않으면 자동으로 익명 계정을 생성합니다.
344
+ * @param autoAnonymous - 자동 익명 로그인 활성화 (기본값: false, 명시적으로 활성화 필요)
345
+ */
346
+ async getCurrentUser(autoAnonymous = false) {
347
+ try {
348
+ return await this.http.get("/v1/auth");
349
+ } catch (error) {
350
+ const isAuthError = error instanceof ApiError && (error.statusCode === 401 || error.statusCode === 403) || error instanceof AuthError;
351
+ if (autoAnonymous && isAuthError) {
352
+ await this.signInAnonymously();
353
+ const userInfo = await this.http.get("/v1/auth");
354
+ return { ...userInfo, is_anonymous: true };
355
+ }
356
+ throw error;
357
+ }
358
+ }
359
+ /**
360
+ * 이메일 인증 메일 재발송
361
+ */
362
+ async resendVerificationEmail() {
363
+ await this.http.post("/v1/auth/re-send-activate-email");
364
+ }
365
+ /**
366
+ * 앱 멤버 게스트 가입
367
+ * API Key로 인증된 앱에 게스트 멤버로 가입합니다.
368
+ * 실시간 채팅 등에서 JWT 토큰 기반 인증이 필요할 때 사용합니다.
369
+ * 로컬 스토리지에 저장된 토큰이 있으면 기존 계정으로 재로그인을 시도합니다.
370
+ * 동시 호출 시 중복 요청 방지 (race condition 방지)
371
+ *
372
+ * @example
373
+ * ```typescript
374
+ * // 게스트로 가입하고 실시간 연결
375
+ * const guest = await client.auth.signInAsGuestMember()
376
+ * await client.realtime.connect({ accessToken: guest.access_token })
377
+ * ```
378
+ */
379
+ async signInAsGuestMember() {
380
+ if (this.guestMemberLoginPromise) {
381
+ return this.guestMemberLoginPromise;
382
+ }
383
+ this.guestMemberLoginPromise = this.executeGuestMemberLogin();
384
+ try {
385
+ return await this.guestMemberLoginPromise;
386
+ } finally {
387
+ this.guestMemberLoginPromise = null;
388
+ }
389
+ }
390
+ /**
391
+ * 실제 게스트 멤버 로그인 실행 (내부 메서드)
392
+ */
393
+ async executeGuestMemberLogin() {
394
+ const storedData = this.getStoredGuestMemberTokens();
395
+ if (storedData) {
396
+ if (!this.isTokenExpired(storedData.accessToken)) {
397
+ try {
398
+ this.http.setTokens(storedData.accessToken, storedData.refreshToken);
399
+ const memberInfo = await this.http.get(
400
+ "/v1/public/app-members/me"
401
+ );
402
+ if (memberInfo.is_active) {
403
+ return {
404
+ member_id: memberInfo.member_id,
405
+ access_token: storedData.accessToken,
406
+ refresh_token: storedData.refreshToken
407
+ };
408
+ }
409
+ this.clearGuestMemberTokens();
410
+ } catch {
411
+ this.http.clearTokens();
412
+ }
413
+ }
414
+ if (storedData.refreshToken && !this.isTokenExpired(storedData.refreshToken)) {
415
+ try {
416
+ const refreshed = await this.http.post(
417
+ "/v1/auth/re-issue",
418
+ {},
419
+ { headers: { "Authorization": `Bearer ${storedData.refreshToken}` }, skipAuth: true }
420
+ );
421
+ this.http.setTokens(refreshed.access_token, refreshed.refresh_token);
422
+ this.storeGuestMemberTokens(refreshed.access_token, refreshed.refresh_token, storedData.memberId);
423
+ return {
424
+ member_id: storedData.memberId,
425
+ access_token: refreshed.access_token,
426
+ refresh_token: refreshed.refresh_token
427
+ };
428
+ } catch {
429
+ this.clearGuestMemberTokens();
430
+ }
431
+ } else {
432
+ this.clearGuestMemberTokens();
433
+ }
434
+ }
435
+ const response = await this.http.post(
436
+ "/v1/public/app-members",
437
+ {},
438
+ { skipAuth: true }
439
+ );
440
+ this.http.setTokens(response.access_token, response.refresh_token);
441
+ this.storeGuestMemberTokens(response.access_token, response.refresh_token, response.member_id);
442
+ return response;
443
+ }
444
+ /**
445
+ * JWT 토큰 만료 여부 확인 (로컬 검증)
446
+ */
447
+ isTokenExpired(token) {
448
+ try {
449
+ const payload = JSON.parse(atob(token.split(".")[1]));
450
+ const currentTime = Date.now() / 1e3;
451
+ return payload.exp < currentTime;
452
+ } catch {
453
+ return true;
454
+ }
455
+ }
456
+ /**
457
+ * 현재 앱의 guest_member 토큰 스토리지 키 생성
458
+ */
459
+ getGuestMemberTokenKey() {
460
+ if (this.cachedGuestMemberTokenKey) {
461
+ return this.cachedGuestMemberTokenKey;
462
+ }
463
+ const apiKey = this.http.getApiKey();
464
+ if (!apiKey) {
465
+ this.cachedGuestMemberTokenKey = `${GUEST_MEMBER_TOKEN_KEY_PREFIX}default`;
466
+ } else {
467
+ const keyHash = simpleHash(apiKey);
468
+ this.cachedGuestMemberTokenKey = `${GUEST_MEMBER_TOKEN_KEY_PREFIX}${keyHash}`;
469
+ }
470
+ return this.cachedGuestMemberTokenKey;
471
+ }
472
+ /**
473
+ * 저장된 게스트 멤버 토큰 조회
474
+ */
475
+ getStoredGuestMemberTokens() {
476
+ if (typeof localStorage === "undefined") return null;
477
+ const stored = localStorage.getItem(this.getGuestMemberTokenKey());
478
+ if (!stored) return null;
479
+ try {
480
+ return JSON.parse(stored);
481
+ } catch {
482
+ return null;
483
+ }
484
+ }
485
+ /**
486
+ * 게스트 멤버 토큰 저장
487
+ */
488
+ storeGuestMemberTokens(accessToken, refreshToken, memberId) {
489
+ if (typeof localStorage === "undefined") return;
490
+ localStorage.setItem(this.getGuestMemberTokenKey(), JSON.stringify({ accessToken, refreshToken, memberId }));
491
+ }
492
+ /**
493
+ * 저장된 게스트 멤버 토큰 삭제
494
+ */
495
+ clearGuestMemberTokens() {
496
+ if (typeof localStorage === "undefined") return;
497
+ localStorage.removeItem(this.getGuestMemberTokenKey());
498
+ }
499
+ /**
500
+ * 앱 멤버 회원가입 (이메일/ID 기반)
501
+ * 앱에 새로운 멤버를 등록합니다.
502
+ *
503
+ * @example
504
+ * ```typescript
505
+ * const result = await client.auth.signUpMember({
506
+ * login_id: 'user@example.com',
507
+ * password: 'password123',
508
+ * nickname: 'John'
509
+ * })
510
+ * console.log('가입 완료:', result.member_id)
511
+ * ```
512
+ */
513
+ async signUpMember(data) {
514
+ const response = await this.http.post(
515
+ "/v1/public/app-members/signup",
516
+ data,
517
+ { skipAuth: true }
518
+ );
519
+ this.http.setTokens(response.access_token, response.refresh_token);
520
+ return response;
521
+ }
522
+ /**
523
+ * 앱 멤버 로그인 (이메일/ID 기반)
524
+ * 기존 멤버로 로그인합니다.
525
+ *
526
+ * @example
527
+ * ```typescript
528
+ * const result = await client.auth.signInMember({
529
+ * login_id: 'user@example.com',
530
+ * password: 'password123'
531
+ * })
532
+ * console.log('로그인 성공:', result.member_id)
533
+ * ```
534
+ */
535
+ async signInMember(data) {
536
+ const response = await this.http.post(
537
+ "/v1/public/app-members/signin",
538
+ data,
539
+ { skipAuth: true }
540
+ );
541
+ this.http.setTokens(response.access_token, response.refresh_token);
542
+ return response;
543
+ }
544
+ };
545
+
546
+ // src/api/database.ts
547
+ var DatabaseAPI = class {
548
+ constructor(http) {
549
+ this.http = http;
550
+ }
551
+ /**
552
+ * API Key 인증 시 /v1/public 접두사 반환
553
+ */
554
+ getPublicPrefix() {
555
+ return this.http.hasApiKey() ? "/v1/public" : "/v1";
556
+ }
557
+ // ============ Table Methods ============
558
+ /**
559
+ * 테이블 목록 조회
560
+ */
561
+ async getTables(databaseId) {
562
+ const response = await this.http.get(
563
+ `/v1/databases/json-databases/${databaseId}/tables`
564
+ );
565
+ return response.tables;
566
+ }
567
+ /**
568
+ * 테이블 생성
569
+ */
570
+ async createTable(databaseId, data) {
571
+ return this.http.post(
572
+ `/v1/databases/json-databases/${databaseId}/tables`,
573
+ data
574
+ );
575
+ }
576
+ /**
577
+ * 테이블 삭제
578
+ */
579
+ async deleteTable(databaseId, tableId) {
580
+ await this.http.delete(
581
+ `/v1/databases/json-databases/${databaseId}/tables/${tableId}`
582
+ );
583
+ }
584
+ // ============ Column Methods ============
585
+ /**
586
+ * 컬럼 목록 조회
587
+ */
588
+ async getColumns(tableId) {
589
+ const response = await this.http.get(
590
+ `/v1/tables/${tableId}/columns`
591
+ );
592
+ return response.columns;
593
+ }
594
+ /**
595
+ * 컬럼 생성
596
+ */
597
+ async createColumn(tableId, data) {
598
+ return this.http.post(
599
+ `/v1/tables/${tableId}/columns`,
600
+ data
601
+ );
602
+ }
603
+ /**
604
+ * 컬럼 수정
605
+ */
606
+ async updateColumn(tableId, columnId, data) {
607
+ return this.http.patch(
608
+ `/v1/tables/${tableId}/columns/${columnId}`,
609
+ data
610
+ );
611
+ }
612
+ /**
613
+ * 컬럼 삭제
614
+ */
615
+ async deleteColumn(tableId, columnId) {
616
+ await this.http.delete(`/v1/tables/${tableId}/columns/${columnId}`);
617
+ }
618
+ // ============ Data Methods ============
619
+ /**
620
+ * 데이터 조회 (페이지네이션)
621
+ */
622
+ async getData(tableId, options) {
623
+ const prefix = this.getPublicPrefix();
624
+ if (options?.where) {
625
+ return this.queryData(tableId, options);
626
+ }
627
+ const params = new URLSearchParams();
628
+ if (options?.limit) params.append("limit", options.limit.toString());
629
+ if (options?.offset) params.append("offset", options.offset.toString());
630
+ const queryString = params.toString();
631
+ const url = queryString ? `${prefix}/tables/${tableId}/data?${queryString}` : `${prefix}/tables/${tableId}/data`;
632
+ return this.http.get(url);
633
+ }
634
+ /**
635
+ * 조건부 데이터 조회 (Where, OrderBy)
636
+ */
637
+ async queryData(tableId, options) {
638
+ const prefix = this.getPublicPrefix();
639
+ return this.http.post(
640
+ `${prefix}/tables/${tableId}/data/query`,
641
+ {
642
+ where: options.where,
643
+ order_by: options.orderBy,
644
+ order_direction: options.orderDirection,
645
+ limit: options.limit,
646
+ offset: options.offset
647
+ }
648
+ );
649
+ }
650
+ /**
651
+ * 단일 데이터 조회
652
+ */
653
+ async getDataById(tableId, dataId) {
654
+ const prefix = this.getPublicPrefix();
655
+ return this.http.get(`${prefix}/tables/${tableId}/data/${dataId}`);
656
+ }
657
+ /**
658
+ * 데이터 생성
659
+ */
660
+ async createData(tableId, data) {
661
+ const prefix = this.getPublicPrefix();
662
+ return this.http.post(`${prefix}/tables/${tableId}/data`, data);
663
+ }
664
+ /**
665
+ * 데이터 수정
666
+ */
667
+ async updateData(tableId, dataId, data) {
668
+ const prefix = this.getPublicPrefix();
669
+ return this.http.put(`${prefix}/tables/${tableId}/data/${dataId}`, data);
670
+ }
671
+ /**
672
+ * 데이터 삭제
673
+ */
674
+ async deleteData(tableId, dataId) {
675
+ const prefix = this.getPublicPrefix();
676
+ await this.http.delete(`${prefix}/tables/${tableId}/data/${dataId}`);
677
+ }
678
+ /**
679
+ * 여러 데이터 한번에 생성 (Bulk Create)
680
+ */
681
+ async createMany(tableId, items) {
682
+ const prefix = this.getPublicPrefix();
683
+ return this.http.post(
684
+ `${prefix}/tables/${tableId}/data/bulk`,
685
+ { data: items.map((item) => item.data) }
686
+ );
687
+ }
688
+ /**
689
+ * 조건에 맞는 데이터 삭제
690
+ */
691
+ async deleteWhere(tableId, where) {
692
+ const prefix = this.getPublicPrefix();
693
+ return this.http.post(
694
+ `${prefix}/tables/${tableId}/data/delete-where`,
695
+ { where }
696
+ );
697
+ }
698
+ };
699
+
700
+ // src/api/storage.ts
701
+ var StorageAPI = class {
702
+ constructor(http) {
703
+ this.http = http;
704
+ }
705
+ /**
706
+ * API Key 인증 시 /v1/public 접두사 반환
707
+ */
708
+ getPublicPrefix() {
709
+ return this.http.hasApiKey() ? "/v1/public" : "/v1";
710
+ }
711
+ /**
712
+ * 파일 목록 조회
713
+ */
714
+ async getFiles(storageId) {
715
+ const prefix = this.getPublicPrefix();
716
+ const response = await this.http.get(
717
+ `${prefix}/storages/files/${storageId}/items`
718
+ );
719
+ return response.files;
720
+ }
721
+ /**
722
+ * 파일 업로드
723
+ */
724
+ async uploadFile(storageId, file, parentId) {
725
+ const prefix = this.getPublicPrefix();
726
+ const formData = new FormData();
727
+ formData.append("file", file);
728
+ if (parentId) {
729
+ formData.append("parent_id", parentId);
730
+ }
731
+ return this.http.post(
732
+ `${prefix}/storages/files/${storageId}/upload`,
733
+ formData
734
+ );
735
+ }
736
+ /**
737
+ * 여러 파일 업로드
738
+ */
739
+ async uploadFiles(storageId, files, parentId) {
740
+ const results = [];
741
+ for (const file of files) {
742
+ const result = await this.uploadFile(storageId, file, parentId);
743
+ results.push(result);
744
+ }
745
+ return results;
746
+ }
747
+ /**
748
+ * 폴더 생성
749
+ */
750
+ async createFolder(storageId, data) {
751
+ const prefix = this.getPublicPrefix();
752
+ return this.http.post(
753
+ `${prefix}/storages/files/${storageId}/folders`,
754
+ data
755
+ );
756
+ }
757
+ /**
758
+ * 파일/폴더 삭제
759
+ */
760
+ async deleteFile(storageId, fileId) {
761
+ const prefix = this.getPublicPrefix();
762
+ await this.http.delete(`${prefix}/storages/files/${storageId}/items/${fileId}`);
763
+ }
764
+ /**
765
+ * 파일/폴더 이동
766
+ */
767
+ async moveFile(storageId, fileId, data) {
768
+ const prefix = this.getPublicPrefix();
769
+ await this.http.post(
770
+ `${prefix}/storages/files/${storageId}/items/${fileId}/move`,
771
+ data
772
+ );
773
+ }
774
+ /**
775
+ * 파일/폴더 이름 변경
776
+ */
777
+ async renameFile(storageId, fileId, data) {
778
+ const prefix = this.getPublicPrefix();
779
+ return this.http.patch(
780
+ `${prefix}/storages/files/${storageId}/items/${fileId}/rename`,
781
+ data
782
+ );
783
+ }
784
+ /**
785
+ * 파일 URL 가져오기
786
+ */
787
+ getFileUrl(file) {
788
+ return file.url || null;
789
+ }
790
+ /**
791
+ * 이미지 파일 여부 확인
792
+ */
793
+ isImageFile(file) {
794
+ return file.mime_type?.startsWith("image/") || false;
795
+ }
796
+ };
797
+
798
+ // src/api/api-key.ts
799
+ var ApiKeyAPI = class {
800
+ constructor(http) {
801
+ this.http = http;
802
+ }
803
+ /**
804
+ * 앱의 API Key 목록을 조회합니다
805
+ * @param appId 앱 ID
806
+ */
807
+ async getApiKeys(appId) {
808
+ return this.http.get(`/v1/apps/${appId}/api-keys`);
809
+ }
810
+ /**
811
+ * 새 API Key를 생성합니다
812
+ *
813
+ * **중요**: 반환되는 `key` 값은 이 응답에서만 볼 수 있습니다.
814
+ * 안전한 곳에 저장하세요.
815
+ *
816
+ * @param appId 앱 ID
817
+ * @param data 생성할 API Key 정보
818
+ */
819
+ async createApiKey(appId, data) {
820
+ return this.http.post(`/v1/apps/${appId}/api-keys`, data);
821
+ }
822
+ /**
823
+ * API Key를 수정합니다 (이름 변경, 활성화/비활성화)
824
+ * @param appId 앱 ID
825
+ * @param keyId API Key ID
826
+ * @param data 수정할 정보
827
+ */
828
+ async updateApiKey(appId, keyId, data) {
829
+ return this.http.patch(`/v1/apps/${appId}/api-keys/${keyId}`, data);
830
+ }
831
+ /**
832
+ * API Key를 삭제합니다
833
+ * @param appId 앱 ID
834
+ * @param keyId API Key ID
835
+ */
836
+ async deleteApiKey(appId, keyId) {
837
+ await this.http.delete(`/v1/apps/${appId}/api-keys/${keyId}`);
838
+ }
839
+ };
840
+
841
+ // src/api/functions.ts
842
+ var FunctionsAPI = class {
843
+ constructor(http) {
844
+ this.http = http;
845
+ }
846
+ /**
847
+ * API Key 인증 시 /v1/public 접두사 반환
848
+ */
849
+ getPublicPrefix() {
850
+ return this.http.hasApiKey() ? "/v1/public" : "/v1";
851
+ }
852
+ /**
853
+ * 서버리스 함수 실행
854
+ *
855
+ * @param functionId - 함수 ID
856
+ * @param payload - 함수에 전달할 데이터 (선택)
857
+ * @param timeout - 실행 타임아웃 (초, 선택)
858
+ * @returns 함수 실행 결과
859
+ *
860
+ * @example
861
+ * ```typescript
862
+ * // 기본 실행
863
+ * const result = await cb.functions.invoke('function-id')
864
+ *
865
+ * // 데이터와 함께 실행
866
+ * const result = await cb.functions.invoke('function-id', {
867
+ * name: 'John',
868
+ * age: 30
869
+ * })
870
+ *
871
+ * // 타임아웃 설정
872
+ * const result = await cb.functions.invoke('function-id', { data: 'test' }, 60)
873
+ * ```
874
+ */
875
+ async invoke(functionId, payload, timeout) {
876
+ const prefix = this.getPublicPrefix();
877
+ const request = {};
878
+ if (payload !== void 0) {
879
+ request.payload = payload;
880
+ }
881
+ if (timeout !== void 0) {
882
+ request.timeout = timeout;
883
+ }
884
+ return this.http.post(
885
+ `${prefix}/functions/${functionId}/invoke`,
886
+ request
887
+ );
888
+ }
889
+ /**
890
+ * 서버리스 함수 실행 (비동기 래퍼)
891
+ * 결과만 반환하고 메타데이터는 제외
892
+ *
893
+ * @param functionId - 함수 ID
894
+ * @param payload - 함수에 전달할 데이터 (선택)
895
+ * @returns 함수 실행 결과 데이터
896
+ *
897
+ * @example
898
+ * ```typescript
899
+ * const data = await cb.functions.call('function-id', { name: 'John' })
900
+ * console.log(data) // 함수가 반환한 데이터
901
+ * ```
902
+ */
903
+ async call(functionId, payload) {
904
+ const response = await this.invoke(functionId, payload);
905
+ if (!response.success) {
906
+ throw new Error(response.error || "Function execution failed");
907
+ }
908
+ return response.result;
909
+ }
910
+ };
911
+
912
+ // src/api/realtime.ts
913
+ var RealtimeAPI = class {
914
+ constructor(http, socketUrl) {
915
+ this.ws = null;
916
+ this.state = "disconnected";
917
+ this._connectionId = null;
918
+ this._appId = null;
919
+ this.options = {
920
+ maxRetries: 5,
921
+ retryInterval: 1e3,
922
+ userId: "",
923
+ accessToken: ""
924
+ };
925
+ this.retryCount = 0;
926
+ this.pendingRequests = /* @__PURE__ */ new Map();
927
+ this.subscriptions = /* @__PURE__ */ new Map();
928
+ this.stateHandlers = [];
929
+ this.errorHandlers = [];
930
+ this.http = http;
931
+ this.socketUrl = socketUrl;
932
+ this.clientId = this.generateClientId();
933
+ }
934
+ /** 현재 연결 ID */
935
+ get connectionId() {
936
+ return this._connectionId;
937
+ }
938
+ /** 현재 연결된 앱 ID */
939
+ get appId() {
940
+ return this._appId;
941
+ }
942
+ /**
943
+ * WebSocket 연결
944
+ * @param options 연결 옵션
945
+ * - accessToken: JWT 토큰으로 인증 (앱 멤버용, API Key보다 우선)
946
+ * - userId: 사용자 식별자 (표시용)
947
+ */
948
+ async connect(options = {}) {
949
+ if (this.state === "connected" || this.state === "connecting") {
950
+ return;
951
+ }
952
+ this.options = { ...this.options, ...options };
953
+ if (options.userId) {
954
+ this.userId = options.userId;
955
+ }
956
+ return this.doConnect();
957
+ }
958
+ /**
959
+ * 연결 해제
960
+ */
961
+ disconnect() {
962
+ this.state = "disconnected";
963
+ this.notifyStateChange();
964
+ if (this.ws) {
965
+ this.ws.close();
966
+ this.ws = null;
967
+ }
968
+ this.pendingRequests.forEach((req) => {
969
+ clearTimeout(req.timeout);
970
+ req.reject(new Error("Connection closed"));
971
+ });
972
+ this.pendingRequests.clear();
973
+ this.subscriptions.clear();
974
+ }
975
+ /**
976
+ * 카테고리 구독
977
+ */
978
+ async subscribe(category, options = {}) {
979
+ if (this.state !== "connected") {
980
+ throw new Error("Not connected. Call connect() first.");
981
+ }
982
+ const requestId = this.generateRequestId();
983
+ const response = await this.sendRequest({
984
+ category,
985
+ action: "subscribe",
986
+ request_id: requestId
987
+ });
988
+ const info = {
989
+ category: response.category,
990
+ persist: response.persist,
991
+ historyCount: response.history_count,
992
+ readReceipt: response.read_receipt
993
+ };
994
+ const handlers = [];
995
+ this.subscriptions.set(category, { info, handlers });
996
+ const subscription = {
997
+ info,
998
+ send: async (data, sendOptions) => {
999
+ await this.sendMessage(category, data, sendOptions);
1000
+ },
1001
+ getHistory: async (limit) => {
1002
+ return this.getHistory(category, limit ?? options.historyLimit);
1003
+ },
1004
+ unsubscribe: async () => {
1005
+ await this.unsubscribe(category);
1006
+ },
1007
+ onMessage: (handler) => {
1008
+ handlers.push(handler);
1009
+ }
1010
+ };
1011
+ return subscription;
1012
+ }
1013
+ /**
1014
+ * 구독 해제
1015
+ */
1016
+ async unsubscribe(category) {
1017
+ if (this.state !== "connected") {
1018
+ return;
1019
+ }
1020
+ const requestId = this.generateRequestId();
1021
+ await this.sendRequest({
1022
+ category,
1023
+ action: "unsubscribe",
1024
+ request_id: requestId
1025
+ });
1026
+ this.subscriptions.delete(category);
1027
+ }
1028
+ /**
1029
+ * 메시지 전송
1030
+ * @param category 카테고리 이름
1031
+ * @param data 전송할 데이터
1032
+ * @param options 전송 옵션 (includeSelf: 발신자도 메시지 수신 여부, 기본값 true)
1033
+ */
1034
+ async sendMessage(category, data, options = {}) {
1035
+ if (this.state !== "connected") {
1036
+ throw new Error("Not connected");
1037
+ }
1038
+ const includeSelf = options.includeSelf !== false;
1039
+ const broadcast = includeSelf;
1040
+ const requestId = this.generateRequestId();
1041
+ await this.sendRequest({
1042
+ category,
1043
+ action: "send",
1044
+ data: { data, broadcast },
1045
+ request_id: requestId
1046
+ });
1047
+ }
1048
+ /**
1049
+ * 히스토리 조회
1050
+ */
1051
+ async getHistory(category, limit) {
1052
+ if (this.state !== "connected") {
1053
+ throw new Error("Not connected");
1054
+ }
1055
+ const requestId = this.generateRequestId();
1056
+ const response = await this.sendRequest({
1057
+ category,
1058
+ action: "history",
1059
+ data: limit ? { limit } : void 0,
1060
+ request_id: requestId
1061
+ });
1062
+ return {
1063
+ category: response.category,
1064
+ messages: response.messages.map((m) => ({
1065
+ id: m.id,
1066
+ category: m.category,
1067
+ from: m.from,
1068
+ data: m.data,
1069
+ sentAt: m.sent_at
1070
+ })),
1071
+ total: response.total
1072
+ };
1073
+ }
1074
+ /**
1075
+ * 연결 상태
1076
+ */
1077
+ getState() {
1078
+ return this.state;
1079
+ }
1080
+ /**
1081
+ * 상태 변경 핸들러 등록
1082
+ */
1083
+ onStateChange(handler) {
1084
+ this.stateHandlers.push(handler);
1085
+ return () => {
1086
+ const idx = this.stateHandlers.indexOf(handler);
1087
+ if (idx > -1) this.stateHandlers.splice(idx, 1);
1088
+ };
1089
+ }
1090
+ /**
1091
+ * 에러 핸들러 등록
1092
+ */
1093
+ onError(handler) {
1094
+ this.errorHandlers.push(handler);
1095
+ return () => {
1096
+ const idx = this.errorHandlers.indexOf(handler);
1097
+ if (idx > -1) this.errorHandlers.splice(idx, 1);
1098
+ };
1099
+ }
1100
+ // Private methods
1101
+ async doConnect() {
1102
+ return new Promise((resolve, reject) => {
1103
+ this.state = "connecting";
1104
+ this.notifyStateChange();
1105
+ const wsUrl = this.socketUrl.replace(/^http/, "ws");
1106
+ let url;
1107
+ if (this.options.accessToken) {
1108
+ url = `${wsUrl}/v1/realtime/auth?access_token=${encodeURIComponent(this.options.accessToken)}&client_id=${this.clientId}`;
1109
+ } else {
1110
+ const apiKey = this.http.getApiKey();
1111
+ if (!apiKey) {
1112
+ reject(new Error("API Key or accessToken is required for realtime connection"));
1113
+ return;
1114
+ }
1115
+ url = `${wsUrl}/v1/realtime/auth?api_key=${encodeURIComponent(apiKey)}&client_id=${this.clientId}`;
1116
+ }
1117
+ if (this.userId) {
1118
+ url += `&user_id=${encodeURIComponent(this.userId)}`;
1119
+ }
1120
+ try {
1121
+ this.ws = new WebSocket(url);
1122
+ this.ws.onopen = () => {
1123
+ };
1124
+ this.ws.onmessage = (event) => {
1125
+ const messages = event.data.split("\n").filter((line) => line.trim());
1126
+ for (const line of messages) {
1127
+ try {
1128
+ const msg = JSON.parse(line);
1129
+ this.handleServerMessage(msg, resolve);
1130
+ } catch (e) {
1131
+ console.error("[Realtime] Failed to parse message:", line, e);
1132
+ }
1133
+ }
1134
+ };
1135
+ this.ws.onclose = () => {
1136
+ if (this.state === "connected" || this.state === "connecting") {
1137
+ this.handleDisconnect();
1138
+ }
1139
+ };
1140
+ this.ws.onerror = (error) => {
1141
+ console.error("[Realtime] WebSocket error:", error);
1142
+ this.notifyError(new Error("WebSocket connection error"));
1143
+ if (this.state === "connecting") {
1144
+ reject(new Error("Failed to connect"));
1145
+ }
1146
+ };
1147
+ } catch (e) {
1148
+ reject(e);
1149
+ }
1150
+ });
1151
+ }
1152
+ handleServerMessage(msg, connectResolve) {
1153
+ switch (msg.event) {
1154
+ case "connected": {
1155
+ const data = msg.data;
1156
+ this._connectionId = data.connection_id;
1157
+ this._appId = data.app_id;
1158
+ this.state = "connected";
1159
+ this.retryCount = 0;
1160
+ this.notifyStateChange();
1161
+ if (connectResolve) connectResolve();
1162
+ break;
1163
+ }
1164
+ case "subscribed":
1165
+ case "unsubscribed":
1166
+ case "sent":
1167
+ case "result":
1168
+ case "history": {
1169
+ if (msg.request_id) {
1170
+ const pending = this.pendingRequests.get(msg.request_id);
1171
+ if (pending) {
1172
+ clearTimeout(pending.timeout);
1173
+ pending.resolve(msg.data);
1174
+ this.pendingRequests.delete(msg.request_id);
1175
+ }
1176
+ }
1177
+ break;
1178
+ }
1179
+ case "message": {
1180
+ const data = msg.data;
1181
+ const sub = this.subscriptions.get(data.category);
1182
+ if (sub) {
1183
+ const message = {
1184
+ id: data.id,
1185
+ category: data.category,
1186
+ from: data.from,
1187
+ data: data.data,
1188
+ sentAt: data.sent_at
1189
+ };
1190
+ sub.handlers.forEach((h) => h(message));
1191
+ }
1192
+ break;
1193
+ }
1194
+ case "error": {
1195
+ if (msg.request_id) {
1196
+ const pending = this.pendingRequests.get(msg.request_id);
1197
+ if (pending) {
1198
+ clearTimeout(pending.timeout);
1199
+ pending.reject(new Error(msg.error || "Unknown error"));
1200
+ this.pendingRequests.delete(msg.request_id);
1201
+ }
1202
+ } else {
1203
+ this.notifyError(new Error(msg.error || "Unknown error"));
1204
+ }
1205
+ break;
1206
+ }
1207
+ case "pong":
1208
+ break;
1209
+ }
1210
+ }
1211
+ handleDisconnect() {
1212
+ this.ws = null;
1213
+ this._connectionId = null;
1214
+ if (this.retryCount < this.options.maxRetries) {
1215
+ this.state = "reconnecting";
1216
+ this.notifyStateChange();
1217
+ this.retryCount++;
1218
+ setTimeout(() => {
1219
+ this.doConnect().catch((e) => {
1220
+ console.error("[Realtime] Reconnect failed:", e);
1221
+ });
1222
+ }, this.options.retryInterval * this.retryCount);
1223
+ } else {
1224
+ this.state = "disconnected";
1225
+ this.notifyStateChange();
1226
+ this.notifyError(new Error("Connection lost. Max retries exceeded."));
1227
+ }
1228
+ }
1229
+ sendRequest(message) {
1230
+ return new Promise((resolve, reject) => {
1231
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1232
+ reject(new Error("Not connected"));
1233
+ return;
1234
+ }
1235
+ const timeout = setTimeout(() => {
1236
+ this.pendingRequests.delete(message.request_id);
1237
+ reject(new Error("Request timeout"));
1238
+ }, 3e4);
1239
+ this.pendingRequests.set(message.request_id, {
1240
+ resolve,
1241
+ reject,
1242
+ timeout
1243
+ });
1244
+ this.ws.send(JSON.stringify(message));
1245
+ });
1246
+ }
1247
+ notifyStateChange() {
1248
+ this.stateHandlers.forEach((h) => h(this.state));
1249
+ }
1250
+ notifyError(error) {
1251
+ this.errorHandlers.forEach((h) => h(error));
1252
+ }
1253
+ generateClientId() {
1254
+ return "cb_" + Math.random().toString(36).substring(2, 15);
1255
+ }
1256
+ generateRequestId() {
1257
+ return "req_" + Date.now() + "_" + Math.random().toString(36).substring(2, 9);
1258
+ }
1259
+ };
1260
+
1261
+ // src/api/webrtc.ts
1262
+ var WebRTCAPI = class {
1263
+ constructor(http, webrtcUrl) {
1264
+ this.ws = null;
1265
+ this.state = "disconnected";
1266
+ this.stateListeners = [];
1267
+ this.errorListeners = [];
1268
+ this.peerJoinedListeners = [];
1269
+ this.peerLeftListeners = [];
1270
+ this.remoteStreamListeners = [];
1271
+ this.reconnectAttempts = 0;
1272
+ this.maxReconnectAttempts = 5;
1273
+ this.reconnectTimeout = null;
1274
+ // 현재 연결 정보
1275
+ this.currentRoomId = null;
1276
+ this.currentPeerId = null;
1277
+ this.currentUserId = null;
1278
+ this.isBroadcaster = false;
1279
+ this.localStream = null;
1280
+ this.channelType = "interactive";
1281
+ // 피어 연결 관리
1282
+ this.peerConnections = /* @__PURE__ */ new Map();
1283
+ this.remoteStreams = /* @__PURE__ */ new Map();
1284
+ this.iceServers = [];
1285
+ this.http = http;
1286
+ this.webrtcUrl = webrtcUrl;
1287
+ }
1288
+ /**
1289
+ * ICE 서버 목록 조회
1290
+ */
1291
+ async getICEServers() {
1292
+ const response = await this.http.get("/v1/ice-servers");
1293
+ this.iceServers = response.ice_servers;
1294
+ return response.ice_servers;
1295
+ }
1296
+ /**
1297
+ * WebRTC 시그널링 서버에 연결
1298
+ *
1299
+ * @example
1300
+ * ```typescript
1301
+ * // 로컬 스트림 가져오기
1302
+ * const localStream = await navigator.mediaDevices.getUserMedia({
1303
+ * video: true,
1304
+ * audio: true
1305
+ * })
1306
+ *
1307
+ * // WebRTC 연결
1308
+ * await cb.webrtc.connect({
1309
+ * roomId: 'live:room-123',
1310
+ * userId: 'user-456',
1311
+ * isBroadcaster: true,
1312
+ * localStream
1313
+ * })
1314
+ * ```
1315
+ */
1316
+ async connect(options) {
1317
+ if (this.state === "connected" || this.state === "connecting") {
1318
+ throw new Error("\uC774\uBBF8 \uC5F0\uACB0\uB418\uC5B4 \uC788\uAC70\uB098 \uC5F0\uACB0 \uC911\uC785\uB2C8\uB2E4");
1319
+ }
1320
+ this.setState("connecting");
1321
+ this.currentRoomId = options.roomId;
1322
+ this.currentUserId = options.userId || null;
1323
+ this.isBroadcaster = options.isBroadcaster || false;
1324
+ this.localStream = options.localStream || null;
1325
+ if (this.iceServers.length === 0) {
1326
+ try {
1327
+ await this.getICEServers();
1328
+ } catch {
1329
+ this.iceServers = [{ urls: "stun:stun.l.google.com:19302" }];
1330
+ }
1331
+ }
1332
+ return this.connectWebSocket();
1333
+ }
1334
+ connectWebSocket() {
1335
+ return new Promise((resolve, reject) => {
1336
+ const wsUrl = this.buildWebSocketUrl();
1337
+ this.ws = new WebSocket(wsUrl);
1338
+ const timeout = setTimeout(() => {
1339
+ if (this.state === "connecting") {
1340
+ this.ws?.close();
1341
+ reject(new Error("\uC5F0\uACB0 \uC2DC\uAC04 \uCD08\uACFC"));
1342
+ }
1343
+ }, 1e4);
1344
+ this.ws.onopen = () => {
1345
+ clearTimeout(timeout);
1346
+ this.reconnectAttempts = 0;
1347
+ this.sendSignaling({
1348
+ type: "join",
1349
+ room_id: this.currentRoomId,
1350
+ data: {
1351
+ user_id: this.currentUserId,
1352
+ is_broadcaster: this.isBroadcaster
1353
+ }
1354
+ });
1355
+ };
1356
+ this.ws.onmessage = async (event) => {
1357
+ try {
1358
+ const msg = JSON.parse(event.data);
1359
+ await this.handleSignalingMessage(msg, resolve, reject);
1360
+ } catch (error) {
1361
+ console.error("Failed to parse signaling message:", error);
1362
+ }
1363
+ };
1364
+ this.ws.onerror = (event) => {
1365
+ clearTimeout(timeout);
1366
+ console.error("WebSocket error:", event);
1367
+ this.emitError(new Error("WebSocket \uC5F0\uACB0 \uC624\uB958"));
1368
+ };
1369
+ this.ws.onclose = (event) => {
1370
+ clearTimeout(timeout);
1371
+ if (this.state === "connecting") {
1372
+ reject(new Error("\uC5F0\uACB0\uC774 \uC885\uB8CC\uB418\uC5C8\uC2B5\uB2C8\uB2E4"));
1373
+ }
1374
+ this.handleDisconnect(event);
1375
+ };
1376
+ });
1377
+ }
1378
+ buildWebSocketUrl() {
1379
+ let wsBase = this.webrtcUrl.replace("https://", "wss://").replace("http://", "ws://");
1380
+ const apiKey = this.http.getApiKey();
1381
+ const accessToken = this.http.getAccessToken();
1382
+ let authParam = "";
1383
+ if (accessToken) {
1384
+ authParam = `access_token=${encodeURIComponent(accessToken)}`;
1385
+ } else if (apiKey) {
1386
+ authParam = `api_key=${encodeURIComponent(apiKey)}`;
1387
+ }
1388
+ return `${wsBase}/v1/signaling?${authParam}`;
1389
+ }
1390
+ async handleSignalingMessage(msg, resolve, reject) {
1391
+ switch (msg.type) {
1392
+ case "joined":
1393
+ this.setState("connected");
1394
+ this.currentPeerId = msg.peer_id || null;
1395
+ if (msg.data && typeof msg.data === "object") {
1396
+ const data = msg.data;
1397
+ if (data.channel_type) {
1398
+ this.channelType = data.channel_type;
1399
+ }
1400
+ const peers = data.peers || [];
1401
+ for (const peer of peers) {
1402
+ if (peer.peer_id !== this.currentPeerId) {
1403
+ await this.createPeerConnection(peer.peer_id, true);
1404
+ }
1405
+ }
1406
+ }
1407
+ resolve?.();
1408
+ break;
1409
+ case "peer_joined":
1410
+ if (msg.peer_id && msg.peer_id !== this.currentPeerId) {
1411
+ const peerInfo = {
1412
+ peer_id: msg.peer_id,
1413
+ ...typeof msg.data === "object" ? msg.data : {}
1414
+ };
1415
+ this.emitPeerJoined(msg.peer_id, peerInfo);
1416
+ await this.createPeerConnection(msg.peer_id, false);
1417
+ }
1418
+ break;
1419
+ case "peer_left":
1420
+ if (msg.peer_id) {
1421
+ this.closePeerConnection(msg.peer_id);
1422
+ this.emitPeerLeft(msg.peer_id);
1423
+ }
1424
+ break;
1425
+ case "offer":
1426
+ if (msg.peer_id && msg.sdp) {
1427
+ await this.handleOffer(msg.peer_id, msg.sdp);
1428
+ }
1429
+ break;
1430
+ case "answer":
1431
+ if (msg.peer_id && msg.sdp) {
1432
+ await this.handleAnswer(msg.peer_id, msg.sdp);
1433
+ }
1434
+ break;
1435
+ case "ice_candidate":
1436
+ if (msg.peer_id && msg.candidate) {
1437
+ await this.handleICECandidate(msg.peer_id, msg.candidate);
1438
+ }
1439
+ break;
1440
+ case "error":
1441
+ const errorMsg = typeof msg.data === "string" ? msg.data : "Unknown error";
1442
+ const error = new Error(errorMsg);
1443
+ this.emitError(error);
1444
+ reject?.(error);
1445
+ break;
1446
+ }
1447
+ }
1448
+ async createPeerConnection(remotePeerId, isInitiator) {
1449
+ this.closePeerConnection(remotePeerId);
1450
+ const config = {
1451
+ iceServers: this.iceServers.map((server) => ({
1452
+ urls: server.urls,
1453
+ username: server.username,
1454
+ credential: server.credential
1455
+ }))
1456
+ };
1457
+ const pc = new RTCPeerConnection(config);
1458
+ this.peerConnections.set(remotePeerId, pc);
1459
+ if (this.localStream) {
1460
+ this.localStream.getTracks().forEach((track) => {
1461
+ pc.addTrack(track, this.localStream);
1462
+ });
1463
+ }
1464
+ pc.onicecandidate = (event) => {
1465
+ if (event.candidate) {
1466
+ this.sendSignaling({
1467
+ type: "ice_candidate",
1468
+ target_id: remotePeerId,
1469
+ candidate: event.candidate.toJSON()
1470
+ });
1471
+ }
1472
+ };
1473
+ pc.ontrack = (event) => {
1474
+ const [stream] = event.streams;
1475
+ if (stream) {
1476
+ this.remoteStreams.set(remotePeerId, stream);
1477
+ this.emitRemoteStream(remotePeerId, stream);
1478
+ }
1479
+ };
1480
+ pc.onconnectionstatechange = () => {
1481
+ if (pc.connectionState === "failed") {
1482
+ console.warn(`Peer connection failed: ${remotePeerId}`);
1483
+ this.closePeerConnection(remotePeerId);
1484
+ }
1485
+ };
1486
+ if (isInitiator) {
1487
+ const offer = await pc.createOffer();
1488
+ await pc.setLocalDescription(offer);
1489
+ this.sendSignaling({
1490
+ type: "offer",
1491
+ target_id: remotePeerId,
1492
+ sdp: offer.sdp
1493
+ });
1494
+ }
1495
+ return pc;
1496
+ }
1497
+ async handleOffer(remotePeerId, sdp) {
1498
+ let pc = this.peerConnections.get(remotePeerId);
1499
+ if (!pc) {
1500
+ pc = await this.createPeerConnection(remotePeerId, false);
1501
+ }
1502
+ await pc.setRemoteDescription(new RTCSessionDescription({ type: "offer", sdp }));
1503
+ const answer = await pc.createAnswer();
1504
+ await pc.setLocalDescription(answer);
1505
+ this.sendSignaling({
1506
+ type: "answer",
1507
+ target_id: remotePeerId,
1508
+ sdp: answer.sdp
1509
+ });
1510
+ }
1511
+ async handleAnswer(remotePeerId, sdp) {
1512
+ const pc = this.peerConnections.get(remotePeerId);
1513
+ if (pc) {
1514
+ await pc.setRemoteDescription(new RTCSessionDescription({ type: "answer", sdp }));
1515
+ }
1516
+ }
1517
+ async handleICECandidate(remotePeerId, candidate) {
1518
+ const pc = this.peerConnections.get(remotePeerId);
1519
+ if (pc) {
1520
+ try {
1521
+ await pc.addIceCandidate(new RTCIceCandidate(candidate));
1522
+ } catch (error) {
1523
+ console.warn("Failed to add ICE candidate:", error);
1524
+ }
1525
+ }
1526
+ }
1527
+ closePeerConnection(remotePeerId) {
1528
+ const pc = this.peerConnections.get(remotePeerId);
1529
+ if (pc) {
1530
+ pc.close();
1531
+ this.peerConnections.delete(remotePeerId);
1532
+ }
1533
+ this.remoteStreams.delete(remotePeerId);
1534
+ }
1535
+ sendSignaling(msg) {
1536
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1537
+ this.ws.send(JSON.stringify(msg));
1538
+ }
1539
+ }
1540
+ handleDisconnect(event) {
1541
+ const wasConnected = this.state === "connected";
1542
+ this.setState("disconnected");
1543
+ this.peerConnections.forEach((pc, peerId) => {
1544
+ pc.close();
1545
+ this.emitPeerLeft(peerId);
1546
+ });
1547
+ this.peerConnections.clear();
1548
+ this.remoteStreams.clear();
1549
+ if (wasConnected && event.code !== 1e3 && this.reconnectAttempts < this.maxReconnectAttempts) {
1550
+ this.attemptReconnect();
1551
+ }
1552
+ }
1553
+ attemptReconnect() {
1554
+ this.reconnectAttempts++;
1555
+ this.setState("reconnecting");
1556
+ const delay = Math.min(5e3 * Math.pow(2, this.reconnectAttempts - 1), 3e4);
1557
+ this.reconnectTimeout = setTimeout(async () => {
1558
+ try {
1559
+ await this.connectWebSocket();
1560
+ } catch (error) {
1561
+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
1562
+ this.attemptReconnect();
1563
+ } else {
1564
+ this.setState("failed");
1565
+ this.emitError(new Error("\uC7AC\uC5F0\uACB0 \uC2E4\uD328: \uCD5C\uB300 \uC2DC\uB3C4 \uD69F\uC218 \uCD08\uACFC"));
1566
+ }
1567
+ }
1568
+ }, delay);
1569
+ }
1570
+ /**
1571
+ * WebRTC 연결 해제
1572
+ */
1573
+ disconnect() {
1574
+ if (this.reconnectTimeout) {
1575
+ clearTimeout(this.reconnectTimeout);
1576
+ this.reconnectTimeout = null;
1577
+ }
1578
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1579
+ this.sendSignaling({ type: "leave" });
1580
+ this.ws.close(1e3, "User disconnected");
1581
+ }
1582
+ this.peerConnections.forEach((pc) => pc.close());
1583
+ this.peerConnections.clear();
1584
+ this.remoteStreams.clear();
1585
+ this.ws = null;
1586
+ this.currentRoomId = null;
1587
+ this.currentPeerId = null;
1588
+ this.localStream = null;
1589
+ this.setState("disconnected");
1590
+ }
1591
+ /**
1592
+ * 현재 연결 상태 조회
1593
+ */
1594
+ getState() {
1595
+ return this.state;
1596
+ }
1597
+ /**
1598
+ * 현재 룸 ID 조회
1599
+ */
1600
+ getRoomId() {
1601
+ return this.currentRoomId;
1602
+ }
1603
+ /**
1604
+ * 현재 피어 ID 조회
1605
+ */
1606
+ getPeerId() {
1607
+ return this.currentPeerId;
1608
+ }
1609
+ /**
1610
+ * 현재 채널 타입 조회
1611
+ */
1612
+ getChannelType() {
1613
+ return this.channelType;
1614
+ }
1615
+ /**
1616
+ * 원격 스트림 조회
1617
+ */
1618
+ getRemoteStream(peerId) {
1619
+ return this.remoteStreams.get(peerId);
1620
+ }
1621
+ /**
1622
+ * 모든 원격 스트림 조회
1623
+ */
1624
+ getAllRemoteStreams() {
1625
+ return new Map(this.remoteStreams);
1626
+ }
1627
+ /**
1628
+ * 로컬 스트림 교체
1629
+ */
1630
+ replaceLocalStream(newStream) {
1631
+ this.localStream = newStream;
1632
+ this.peerConnections.forEach((pc) => {
1633
+ const senders = pc.getSenders();
1634
+ newStream.getTracks().forEach((track) => {
1635
+ const sender = senders.find((s) => s.track?.kind === track.kind);
1636
+ if (sender) {
1637
+ sender.replaceTrack(track);
1638
+ } else {
1639
+ pc.addTrack(track, newStream);
1640
+ }
1641
+ });
1642
+ });
1643
+ }
1644
+ /**
1645
+ * 오디오 음소거/해제
1646
+ */
1647
+ setAudioEnabled(enabled) {
1648
+ if (this.localStream) {
1649
+ this.localStream.getAudioTracks().forEach((track) => {
1650
+ track.enabled = enabled;
1651
+ });
1652
+ }
1653
+ }
1654
+ /**
1655
+ * 비디오 켜기/끄기
1656
+ */
1657
+ setVideoEnabled(enabled) {
1658
+ if (this.localStream) {
1659
+ this.localStream.getVideoTracks().forEach((track) => {
1660
+ track.enabled = enabled;
1661
+ });
1662
+ }
1663
+ }
1664
+ // =====================
1665
+ // 이벤트 리스너
1666
+ // =====================
1667
+ /**
1668
+ * 연결 상태 변경 이벤트
1669
+ */
1670
+ onStateChange(callback) {
1671
+ this.stateListeners.push(callback);
1672
+ return () => {
1673
+ this.stateListeners = this.stateListeners.filter((cb) => cb !== callback);
1674
+ };
1675
+ }
1676
+ /**
1677
+ * 에러 이벤트
1678
+ */
1679
+ onError(callback) {
1680
+ this.errorListeners.push(callback);
1681
+ return () => {
1682
+ this.errorListeners = this.errorListeners.filter((cb) => cb !== callback);
1683
+ };
1684
+ }
1685
+ /**
1686
+ * 피어 참가 이벤트
1687
+ */
1688
+ onPeerJoined(callback) {
1689
+ this.peerJoinedListeners.push(callback);
1690
+ return () => {
1691
+ this.peerJoinedListeners = this.peerJoinedListeners.filter((cb) => cb !== callback);
1692
+ };
1693
+ }
1694
+ /**
1695
+ * 피어 퇴장 이벤트
1696
+ */
1697
+ onPeerLeft(callback) {
1698
+ this.peerLeftListeners.push(callback);
1699
+ return () => {
1700
+ this.peerLeftListeners = this.peerLeftListeners.filter((cb) => cb !== callback);
1701
+ };
1702
+ }
1703
+ /**
1704
+ * 원격 스트림 수신 이벤트
1705
+ */
1706
+ onRemoteStream(callback) {
1707
+ this.remoteStreamListeners.push(callback);
1708
+ return () => {
1709
+ this.remoteStreamListeners = this.remoteStreamListeners.filter((cb) => cb !== callback);
1710
+ };
1711
+ }
1712
+ // =====================
1713
+ // Private: 이벤트 발행
1714
+ // =====================
1715
+ setState(state) {
1716
+ if (this.state !== state) {
1717
+ this.state = state;
1718
+ this.stateListeners.forEach((cb) => cb(state));
1719
+ }
1720
+ }
1721
+ emitError(error) {
1722
+ this.errorListeners.forEach((cb) => cb(error));
1723
+ }
1724
+ emitPeerJoined(peerId, info) {
1725
+ this.peerJoinedListeners.forEach((cb) => cb(peerId, info));
1726
+ }
1727
+ emitPeerLeft(peerId) {
1728
+ this.peerLeftListeners.forEach((cb) => cb(peerId));
1729
+ }
1730
+ emitRemoteStream(peerId, stream) {
1731
+ this.remoteStreamListeners.forEach((cb) => cb(peerId, stream));
1732
+ }
1733
+ // =====================
1734
+ // REST API (통계/관리)
1735
+ // =====================
1736
+ /**
1737
+ * 앱의 WebRTC 통계 조회
1738
+ */
1739
+ async getStats(appID) {
1740
+ return this.http.get(`/v1/apps/${appID}/webrtc/stats`);
1741
+ }
1742
+ /**
1743
+ * 앱의 활성 룸 목록 조회
1744
+ */
1745
+ async getRooms(appID) {
1746
+ return this.http.get(`/v1/apps/${appID}/webrtc/rooms`);
1747
+ }
1748
+ };
1749
+
1750
+ // src/api/error-tracker.ts
1751
+ var ErrorTrackerAPI = class {
1752
+ constructor(http, config = {}) {
1753
+ this.storageWebId = null;
1754
+ this.errorQueue = [];
1755
+ this.batchTimer = null;
1756
+ this.isInitialized = false;
1757
+ // 원본 이벤트 핸들러 저장
1758
+ this.originalOnError = null;
1759
+ this.originalOnUnhandledRejection = null;
1760
+ this.http = http;
1761
+ this.config = {
1762
+ autoCapture: config.autoCapture ?? true,
1763
+ captureTypes: config.captureTypes ?? ["error", "unhandledrejection"],
1764
+ batchInterval: config.batchInterval ?? 5e3,
1765
+ maxBatchSize: config.maxBatchSize ?? 10,
1766
+ beforeSend: config.beforeSend ?? ((e) => e),
1767
+ debug: config.debug ?? false
1768
+ };
1769
+ }
1770
+ /**
1771
+ * 에러 트래커 초기화
1772
+ * @param storageWebId 웹 스토리지 ID
1773
+ */
1774
+ init(storageWebId) {
1775
+ if (this.isInitialized) {
1776
+ this.log("ErrorTracker already initialized");
1777
+ return;
1778
+ }
1779
+ if (typeof window === "undefined") {
1780
+ this.log("ErrorTracker only works in browser environment");
1781
+ return;
1782
+ }
1783
+ this.storageWebId = storageWebId;
1784
+ this.isInitialized = true;
1785
+ if (this.config.autoCapture) {
1786
+ this.setupAutoCapture();
1787
+ }
1788
+ this.startBatchTimer();
1789
+ this.log("ErrorTracker initialized", { storageWebId });
1790
+ }
1791
+ /**
1792
+ * 에러 트래커 정리 (언마운트 시 호출)
1793
+ */
1794
+ destroy() {
1795
+ this.stopBatchTimer();
1796
+ this.removeAutoCapture();
1797
+ this.flushQueue();
1798
+ this.isInitialized = false;
1799
+ this.log("ErrorTracker destroyed");
1800
+ }
1801
+ /**
1802
+ * 수동으로 에러 리포트
1803
+ */
1804
+ async captureError(error, extra) {
1805
+ const report = this.createErrorReport(error, extra);
1806
+ if (report) {
1807
+ this.queueError(report);
1808
+ }
1809
+ }
1810
+ /**
1811
+ * 커스텀 에러 리포트
1812
+ */
1813
+ async captureMessage(message, extra) {
1814
+ const report = {
1815
+ message,
1816
+ error_type: "custom",
1817
+ url: typeof window !== "undefined" ? window.location.href : void 0,
1818
+ referrer: typeof document !== "undefined" ? document.referrer : void 0,
1819
+ ...extra
1820
+ };
1821
+ this.queueError(report);
1822
+ }
1823
+ /**
1824
+ * 큐에 있는 에러들 즉시 전송
1825
+ */
1826
+ async flush() {
1827
+ await this.flushQueue();
1828
+ }
1829
+ // Private methods
1830
+ log(...args) {
1831
+ if (this.config.debug) {
1832
+ console.log("[ErrorTracker]", ...args);
1833
+ }
1834
+ }
1835
+ setupAutoCapture() {
1836
+ if (typeof window === "undefined") return;
1837
+ if (this.config.captureTypes.includes("error")) {
1838
+ this.originalOnError = window.onerror;
1839
+ window.onerror = (message, source, lineno, colno, error) => {
1840
+ this.handleGlobalError(message, source, lineno, colno, error);
1841
+ if (this.originalOnError) {
1842
+ return this.originalOnError(message, source, lineno, colno, error);
1843
+ }
1844
+ return false;
1845
+ };
1846
+ }
1847
+ if (this.config.captureTypes.includes("unhandledrejection")) {
1848
+ this.originalOnUnhandledRejection = window.onunhandledrejection;
1849
+ window.onunhandledrejection = (event) => {
1850
+ this.handleUnhandledRejection(event);
1851
+ if (this.originalOnUnhandledRejection) {
1852
+ this.originalOnUnhandledRejection(event);
1853
+ }
1854
+ };
1855
+ }
1856
+ this.log("Auto capture enabled", { types: this.config.captureTypes });
1857
+ }
1858
+ removeAutoCapture() {
1859
+ if (typeof window === "undefined") return;
1860
+ if (this.originalOnError !== null) {
1861
+ window.onerror = this.originalOnError;
1862
+ }
1863
+ if (this.originalOnUnhandledRejection !== null) {
1864
+ window.onunhandledrejection = this.originalOnUnhandledRejection;
1865
+ }
1866
+ }
1867
+ handleGlobalError(message, source, lineno, colno, error) {
1868
+ const report = {
1869
+ message: typeof message === "string" ? message : message.type || "Unknown error",
1870
+ source: source || void 0,
1871
+ lineno: lineno || void 0,
1872
+ colno: colno || void 0,
1873
+ stack: error?.stack,
1874
+ error_type: "error",
1875
+ url: typeof window !== "undefined" ? window.location.href : void 0,
1876
+ referrer: typeof document !== "undefined" ? document.referrer : void 0
1877
+ };
1878
+ this.queueError(report);
1879
+ }
1880
+ handleUnhandledRejection(event) {
1881
+ const reason = event.reason;
1882
+ let message = "Unhandled Promise Rejection";
1883
+ let stack;
1884
+ if (reason instanceof Error) {
1885
+ message = reason.message;
1886
+ stack = reason.stack;
1887
+ } else if (typeof reason === "string") {
1888
+ message = reason;
1889
+ } else if (reason && typeof reason === "object") {
1890
+ message = JSON.stringify(reason);
1891
+ }
1892
+ const report = {
1893
+ message,
1894
+ stack,
1895
+ error_type: "unhandledrejection",
1896
+ url: typeof window !== "undefined" ? window.location.href : void 0,
1897
+ referrer: typeof document !== "undefined" ? document.referrer : void 0
1898
+ };
1899
+ this.queueError(report);
1900
+ }
1901
+ createErrorReport(error, extra) {
1902
+ let report;
1903
+ if (error instanceof Error) {
1904
+ report = {
1905
+ message: error.message,
1906
+ stack: error.stack,
1907
+ error_type: "error",
1908
+ url: typeof window !== "undefined" ? window.location.href : void 0,
1909
+ referrer: typeof document !== "undefined" ? document.referrer : void 0,
1910
+ ...extra
1911
+ };
1912
+ } else {
1913
+ report = {
1914
+ message: error,
1915
+ error_type: "custom",
1916
+ url: typeof window !== "undefined" ? window.location.href : void 0,
1917
+ referrer: typeof document !== "undefined" ? document.referrer : void 0,
1918
+ ...extra
1919
+ };
1920
+ }
1921
+ const filtered = this.config.beforeSend(report);
1922
+ if (filtered === false || filtered === null) {
1923
+ this.log("Error filtered out by beforeSend");
1924
+ return null;
1925
+ }
1926
+ return filtered;
1927
+ }
1928
+ queueError(report) {
1929
+ this.errorQueue.push(report);
1930
+ this.log("Error queued", { message: report.message, queueSize: this.errorQueue.length });
1931
+ if (this.errorQueue.length >= this.config.maxBatchSize) {
1932
+ this.flushQueue();
1933
+ }
1934
+ }
1935
+ startBatchTimer() {
1936
+ if (this.batchTimer) return;
1937
+ this.batchTimer = setInterval(() => {
1938
+ this.flushQueue();
1939
+ }, this.config.batchInterval);
1940
+ }
1941
+ stopBatchTimer() {
1942
+ if (this.batchTimer) {
1943
+ clearInterval(this.batchTimer);
1944
+ this.batchTimer = null;
1945
+ }
1946
+ }
1947
+ async flushQueue() {
1948
+ if (!this.storageWebId || this.errorQueue.length === 0) return;
1949
+ const errors = [...this.errorQueue];
1950
+ this.errorQueue = [];
1951
+ try {
1952
+ if (errors.length === 1) {
1953
+ await this.http.post(
1954
+ `/v1/public/storages/web/${this.storageWebId}/errors/report`,
1955
+ errors[0]
1956
+ );
1957
+ } else {
1958
+ await this.http.post(
1959
+ `/v1/public/storages/web/${this.storageWebId}/errors/batch`,
1960
+ {
1961
+ errors,
1962
+ user_agent: typeof navigator !== "undefined" ? navigator.userAgent : void 0
1963
+ }
1964
+ );
1965
+ }
1966
+ this.log("Errors sent", { count: errors.length });
1967
+ } catch (err) {
1968
+ const remainingCapacity = this.config.maxBatchSize - this.errorQueue.length;
1969
+ if (remainingCapacity > 0) {
1970
+ this.errorQueue.unshift(...errors.slice(0, remainingCapacity));
1971
+ }
1972
+ this.log("Failed to send errors, re-queued", { error: err });
1973
+ }
1974
+ }
1975
+ };
1976
+
1977
+ // src/api/oauth.ts
1978
+ var OAuthAPI = class {
1979
+ constructor(http) {
1980
+ this.http = http;
1981
+ }
1982
+ /**
1983
+ * 활성화된 OAuth 프로바이더 목록 조회
1984
+ * 콘솔에서 설정된 소셜 로그인 프로바이더 목록을 반환합니다.
1985
+ *
1986
+ * @example
1987
+ * ```typescript
1988
+ * const { providers } = await cb.oauth.getEnabledProviders()
1989
+ * // [{ provider: 'google', client_id: '...' }, { provider: 'kakao', client_id: '...' }]
1990
+ * ```
1991
+ */
1992
+ async getEnabledProviders() {
1993
+ return this.http.get("/v1/public/oauth/providers");
1994
+ }
1995
+ /**
1996
+ * OAuth 인증 URL 조회
1997
+ * 사용자를 소셜 로그인 페이지로 리다이렉트할 URL을 반환합니다.
1998
+ *
1999
+ * @param provider - OAuth 프로바이더 (google, naver, github, discord)
2000
+ * @param options - redirect_uri와 선택적 state 파라미터
2001
+ * @returns 인증 URL
2002
+ *
2003
+ * @example
2004
+ * ```typescript
2005
+ * const { authorization_url } = await cb.oauth.getAuthorizationURL('google', {
2006
+ * redirect_uri: 'https://myapp.com/auth/callback'
2007
+ * })
2008
+ *
2009
+ * // 사용자를 OAuth 페이지로 리다이렉트
2010
+ * window.location.href = authorization_url
2011
+ * ```
2012
+ */
2013
+ async getAuthorizationURL(provider, options) {
2014
+ const params = new URLSearchParams({
2015
+ redirect_uri: options.redirect_uri
2016
+ });
2017
+ if (options.state) {
2018
+ params.append("state", options.state);
2019
+ }
2020
+ return this.http.get(
2021
+ `/v1/public/oauth/${provider}/authorize?${params.toString()}`
2022
+ );
2023
+ }
2024
+ /**
2025
+ * OAuth 콜백 처리
2026
+ * 소셜 로그인 후 콜백으로 받은 code를 사용하여 로그인을 완료합니다.
2027
+ * 성공 시 자동으로 토큰이 저장됩니다.
2028
+ *
2029
+ * @param provider - OAuth 프로바이더
2030
+ * @param data - code와 redirect_uri
2031
+ * @returns 로그인 결과 (member_id, 토큰, 신규 회원 여부)
2032
+ *
2033
+ * @example
2034
+ * ```typescript
2035
+ * // 콜백 페이지에서 URL 파라미터로 code 추출
2036
+ * const urlParams = new URLSearchParams(window.location.search)
2037
+ * const code = urlParams.get('code')
2038
+ *
2039
+ * const result = await cb.oauth.handleCallback('google', {
2040
+ * code: code,
2041
+ * redirect_uri: 'https://myapp.com/auth/callback'
2042
+ * })
2043
+ *
2044
+ * if (result.is_new_member) {
2045
+ * console.log('신규 회원입니다!')
2046
+ * }
2047
+ * ```
2048
+ */
2049
+ async handleCallback(provider, data) {
2050
+ const response = await this.http.post(
2051
+ `/v1/public/oauth/${provider}/callback`,
2052
+ data
2053
+ );
2054
+ this.http.setTokens(response.access_token, response.refresh_token);
2055
+ return response;
2056
+ }
2057
+ /**
2058
+ * 소셜 로그인 전체 플로우 (팝업 방식)
2059
+ * 새 창에서 소셜 로그인을 처리하고 결과를 Promise로 반환합니다.
2060
+ *
2061
+ * @param provider - OAuth 프로바이더
2062
+ * @param redirectUri - 콜백 URL (이 페이지에서 postMessage로 결과 전달 필요)
2063
+ * @returns 로그인 결과
2064
+ *
2065
+ * @example
2066
+ * ```typescript
2067
+ * // 로그인 버튼 클릭 시
2068
+ * const result = await cb.oauth.signInWithPopup('google', 'https://myapp.com/auth/popup-callback')
2069
+ * console.log('로그인 성공:', result.member_id)
2070
+ * ```
2071
+ *
2072
+ * @example
2073
+ * ```html
2074
+ * <!-- popup-callback.html -->
2075
+ * <script>
2076
+ * const urlParams = new URLSearchParams(window.location.search)
2077
+ * const code = urlParams.get('code')
2078
+ * const error = urlParams.get('error')
2079
+ *
2080
+ * window.opener.postMessage({
2081
+ * type: 'oauth-callback',
2082
+ * code,
2083
+ * error
2084
+ * }, '*')
2085
+ * window.close()
2086
+ * </script>
2087
+ * ```
2088
+ */
2089
+ async signInWithPopup(provider, redirectUri) {
2090
+ const { authorization_url } = await this.getAuthorizationURL(provider, {
2091
+ redirect_uri: redirectUri
2092
+ });
2093
+ const width = 500;
2094
+ const height = 600;
2095
+ const left = window.screenX + (window.outerWidth - width) / 2;
2096
+ const top = window.screenY + (window.outerHeight - height) / 2;
2097
+ const popup = window.open(
2098
+ authorization_url,
2099
+ "oauth-popup",
2100
+ `width=${width},height=${height},left=${left},top=${top}`
2101
+ );
2102
+ if (!popup) {
2103
+ throw new Error("\uD31D\uC5C5\uC774 \uCC28\uB2E8\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uD31D\uC5C5 \uCC28\uB2E8\uC744 \uD574\uC81C\uD574\uC8FC\uC138\uC694.");
2104
+ }
2105
+ return new Promise((resolve, reject) => {
2106
+ const handleMessage = async (event) => {
2107
+ if (event.data?.type !== "oauth-callback") return;
2108
+ window.removeEventListener("message", handleMessage);
2109
+ if (event.data.error) {
2110
+ reject(new Error(event.data.error));
2111
+ return;
2112
+ }
2113
+ if (!event.data.code) {
2114
+ reject(new Error("\uC778\uC99D \uCF54\uB4DC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4."));
2115
+ return;
2116
+ }
2117
+ try {
2118
+ const result = await this.handleCallback(provider, {
2119
+ code: event.data.code,
2120
+ redirect_uri: redirectUri
2121
+ });
2122
+ resolve(result);
2123
+ } catch (error) {
2124
+ reject(error);
2125
+ }
2126
+ };
2127
+ window.addEventListener("message", handleMessage);
2128
+ const checkClosed = setInterval(() => {
2129
+ if (popup.closed) {
2130
+ clearInterval(checkClosed);
2131
+ window.removeEventListener("message", handleMessage);
2132
+ reject(new Error("\uB85C\uADF8\uC778\uC774 \uCDE8\uC18C\uB418\uC5C8\uC2B5\uB2C8\uB2E4."));
2133
+ }
2134
+ }, 500);
2135
+ });
2136
+ }
2137
+ /**
2138
+ * 소셜 로그인 (리다이렉트 방식)
2139
+ * 현재 페이지를 소셜 로그인 페이지로 리다이렉트합니다.
2140
+ * 콜백 URL에서 handleCallback()을 호출하여 로그인을 완료해야 합니다.
2141
+ *
2142
+ * @param provider - OAuth 프로바이더
2143
+ * @param redirectUri - 콜백 URL
2144
+ *
2145
+ * @example
2146
+ * ```typescript
2147
+ * // 로그인 버튼 클릭 시
2148
+ * await cb.oauth.signInWithRedirect('google', 'https://myapp.com/auth/callback')
2149
+ * // 페이지가 Google 로그인으로 리다이렉트됨
2150
+ * ```
2151
+ *
2152
+ * @example
2153
+ * ```typescript
2154
+ * // callback 페이지에서
2155
+ * const urlParams = new URLSearchParams(window.location.search)
2156
+ * const code = urlParams.get('code')
2157
+ *
2158
+ * if (code) {
2159
+ * const result = await cb.oauth.handleCallback('google', {
2160
+ * code,
2161
+ * redirect_uri: 'https://myapp.com/auth/callback'
2162
+ * })
2163
+ * // 로그인 완료, 메인 페이지로 이동
2164
+ * window.location.href = '/'
2165
+ * }
2166
+ * ```
2167
+ */
2168
+ async signInWithRedirect(provider, redirectUri) {
2169
+ const { authorization_url } = await this.getAuthorizationURL(provider, {
2170
+ redirect_uri: redirectUri
2171
+ });
2172
+ window.location.href = authorization_url;
2173
+ }
2174
+ };
2175
+
2176
+ // src/api/payment.ts
2177
+ var PaymentAPI = class {
2178
+ constructor(http) {
2179
+ this.http = http;
2180
+ }
2181
+ /**
2182
+ * API Key 인증 시 /v1/public 접두사 반환
2183
+ */
2184
+ getPublicPrefix() {
2185
+ return this.http.hasApiKey() ? "/v1/public" : "/v1";
2186
+ }
2187
+ /**
2188
+ * 결제 준비
2189
+ * 토스 결제 위젯에 필요한 정보를 반환합니다.
2190
+ *
2191
+ * @param data - 결제 준비 정보 (금액, 상품명, 고객 정보 등)
2192
+ * @returns 토스 클라이언트 키, 고객 키, 성공/실패 URL 등
2193
+ *
2194
+ * @example
2195
+ * ```typescript
2196
+ * const prepareResult = await client.payment.prepare({
2197
+ * amount: 10000,
2198
+ * order_name: '테스트 상품',
2199
+ * customer_email: 'test@example.com',
2200
+ * customer_name: '홍길동'
2201
+ * })
2202
+ *
2203
+ * // 토스 결제 위젯 초기화
2204
+ * const tossPayments = TossPayments(prepareResult.toss_client_key)
2205
+ * await tossPayments.requestPayment('카드', {
2206
+ * amount: prepareResult.amount,
2207
+ * orderId: prepareResult.order_id,
2208
+ * orderName: prepareResult.order_name,
2209
+ * customerKey: prepareResult.customer_key,
2210
+ * successUrl: prepareResult.success_url,
2211
+ * failUrl: prepareResult.fail_url
2212
+ * })
2213
+ * ```
2214
+ */
2215
+ async prepare(data) {
2216
+ const prefix = this.getPublicPrefix();
2217
+ return this.http.post(`${prefix}/payments/prepare`, data);
2218
+ }
2219
+ /**
2220
+ * 결제 승인
2221
+ * 토스에서 결제 완료 후 콜백으로 받은 정보로 결제를 최종 승인합니다.
2222
+ *
2223
+ * @param data - 결제 승인 정보 (paymentKey, orderId, amount)
2224
+ * @returns 승인된 결제 정보
2225
+ *
2226
+ * @example
2227
+ * ```typescript
2228
+ * // 토스 결제 성공 콜백에서 호출
2229
+ * const result = await client.payment.confirm({
2230
+ * payment_key: 'tgen_20230101000000...',
2231
+ * order_id: 'order_123',
2232
+ * amount: 10000
2233
+ * })
2234
+ *
2235
+ * if (result.status === 'done') {
2236
+ * console.log('결제 완료!')
2237
+ * }
2238
+ * ```
2239
+ */
2240
+ async confirm(data) {
2241
+ const prefix = this.getPublicPrefix();
2242
+ return this.http.post(`${prefix}/payments/confirm`, data);
2243
+ }
2244
+ /**
2245
+ * 결제 취소
2246
+ * 완료된 결제를 취소합니다. 부분 취소도 가능합니다.
2247
+ *
2248
+ * @param paymentId - 결제 ID (Connect Base에서 발급한 ID)
2249
+ * @param data - 취소 정보 (사유, 부분 취소 금액)
2250
+ * @returns 취소된 결제 정보
2251
+ *
2252
+ * @example
2253
+ * ```typescript
2254
+ * // 전액 취소
2255
+ * const result = await client.payment.cancel(paymentId, {
2256
+ * cancel_reason: '고객 요청'
2257
+ * })
2258
+ *
2259
+ * // 부분 취소
2260
+ * const result = await client.payment.cancel(paymentId, {
2261
+ * cancel_reason: '부분 환불',
2262
+ * cancel_amount: 5000
2263
+ * })
2264
+ * ```
2265
+ */
2266
+ async cancel(paymentId, data) {
2267
+ const prefix = this.getPublicPrefix();
2268
+ return this.http.post(`${prefix}/payments/${paymentId}/cancel`, data);
2269
+ }
2270
+ /**
2271
+ * 주문 ID로 결제 조회
2272
+ * 특정 주문의 결제 상태를 조회합니다.
2273
+ *
2274
+ * @param orderId - 주문 ID
2275
+ * @returns 결제 상세 정보
2276
+ *
2277
+ * @example
2278
+ * ```typescript
2279
+ * const payment = await client.payment.getByOrderId('order_123')
2280
+ * console.log(`결제 상태: ${payment.status}`)
2281
+ * console.log(`결제 금액: ${payment.amount}원`)
2282
+ * ```
2283
+ */
2284
+ async getByOrderId(orderId) {
2285
+ const prefix = this.getPublicPrefix();
2286
+ return this.http.get(`${prefix}/payments/orders/${orderId}`);
2287
+ }
2288
+ };
2289
+
2290
+ // src/api/subscription.ts
2291
+ var SubscriptionAPI = class {
2292
+ constructor(http) {
2293
+ this.http = http;
2294
+ }
2295
+ /**
2296
+ * API Key 인증 시 /v1/public 접두사 반환
2297
+ */
2298
+ getPublicPrefix() {
2299
+ return this.http.hasApiKey() ? "/v1/public" : "/v1";
2300
+ }
2301
+ // =====================
2302
+ // Billing Key APIs
2303
+ // =====================
2304
+ /**
2305
+ * 빌링키 발급 시작
2306
+ * 토스 카드 등록 위젯에 필요한 정보를 반환합니다.
2307
+ *
2308
+ * @returns 토스 클라이언트 키, 고객 키 등
2309
+ *
2310
+ * @example
2311
+ * ```typescript
2312
+ * const result = await client.subscription.issueBillingKey()
2313
+ *
2314
+ * // 토스 카드 등록 위젯
2315
+ * const tossPayments = TossPayments(result.toss_client_key)
2316
+ * await tossPayments.requestBillingAuth('카드', {
2317
+ * customerKey: result.customer_key,
2318
+ * successUrl: 'https://mysite.com/billing/success',
2319
+ * failUrl: 'https://mysite.com/billing/fail'
2320
+ * })
2321
+ * ```
2322
+ */
2323
+ async issueBillingKey() {
2324
+ const prefix = this.getPublicPrefix();
2325
+ return this.http.post(`${prefix}/subscriptions/billing-keys/issue`, {});
2326
+ }
2327
+ /**
2328
+ * 빌링키 등록 확인
2329
+ * 토스에서 카드 등록 완료 후 빌링키를 확정합니다.
2330
+ *
2331
+ * @param data - 등록 확인 정보
2332
+ * @returns 등록된 빌링키 정보
2333
+ *
2334
+ * @example
2335
+ * ```typescript
2336
+ * const billingKey = await client.subscription.confirmBillingKey({
2337
+ * customer_key: 'cust_xxxxx',
2338
+ * auth_key: 'auth_xxxxx',
2339
+ * customer_email: 'test@example.com',
2340
+ * customer_name: '홍길동',
2341
+ * is_default: true
2342
+ * })
2343
+ * ```
2344
+ */
2345
+ async confirmBillingKey(data) {
2346
+ const prefix = this.getPublicPrefix();
2347
+ return this.http.post(`${prefix}/subscriptions/billing-keys/confirm`, data);
2348
+ }
2349
+ /**
2350
+ * 빌링키 목록 조회
2351
+ *
2352
+ * @param customerId - 특정 고객의 빌링키만 조회 (선택)
2353
+ * @returns 빌링키 목록
2354
+ *
2355
+ * @example
2356
+ * ```typescript
2357
+ * const { billing_keys } = await client.subscription.listBillingKeys()
2358
+ * console.log(`등록된 카드: ${billing_keys.length}개`)
2359
+ * ```
2360
+ */
2361
+ async listBillingKeys(customerId) {
2362
+ const prefix = this.getPublicPrefix();
2363
+ const query = customerId ? `?customer_id=${customerId}` : "";
2364
+ return this.http.get(`${prefix}/subscriptions/billing-keys${query}`);
2365
+ }
2366
+ /**
2367
+ * 빌링키 상세 조회
2368
+ *
2369
+ * @param billingKeyId - 빌링키 ID
2370
+ * @returns 빌링키 상세 정보
2371
+ */
2372
+ async getBillingKey(billingKeyId) {
2373
+ const prefix = this.getPublicPrefix();
2374
+ return this.http.get(`${prefix}/subscriptions/billing-keys/${billingKeyId}`);
2375
+ }
2376
+ /**
2377
+ * 빌링키 수정
2378
+ *
2379
+ * @param billingKeyId - 빌링키 ID
2380
+ * @param data - 수정 정보
2381
+ * @returns 수정된 빌링키 정보
2382
+ *
2383
+ * @example
2384
+ * ```typescript
2385
+ * await client.subscription.updateBillingKey(billingKeyId, {
2386
+ * card_nickname: '주 결제 카드',
2387
+ * is_default: true
2388
+ * })
2389
+ * ```
2390
+ */
2391
+ async updateBillingKey(billingKeyId, data) {
2392
+ const prefix = this.getPublicPrefix();
2393
+ return this.http.patch(`${prefix}/subscriptions/billing-keys/${billingKeyId}`, data);
2394
+ }
2395
+ /**
2396
+ * 빌링키 삭제
2397
+ *
2398
+ * @param billingKeyId - 빌링키 ID
2399
+ */
2400
+ async deleteBillingKey(billingKeyId) {
2401
+ const prefix = this.getPublicPrefix();
2402
+ return this.http.delete(`${prefix}/subscriptions/billing-keys/${billingKeyId}`);
2403
+ }
2404
+ // =====================
2405
+ // Subscription APIs
2406
+ // =====================
2407
+ /**
2408
+ * 구독 생성
2409
+ * 정기결제 구독을 생성합니다.
2410
+ *
2411
+ * @param data - 구독 정보
2412
+ * @returns 생성된 구독 정보
2413
+ *
2414
+ * @example
2415
+ * ```typescript
2416
+ * const subscription = await client.subscription.create({
2417
+ * billing_key_id: 'bk_xxxxx',
2418
+ * plan_name: '프리미엄 플랜',
2419
+ * amount: 9900,
2420
+ * billing_cycle: 'monthly',
2421
+ * billing_day: 15, // 매월 15일 결제
2422
+ * trial_days: 7 // 7일 무료 체험
2423
+ * })
2424
+ * ```
2425
+ */
2426
+ async create(data) {
2427
+ const prefix = this.getPublicPrefix();
2428
+ return this.http.post(`${prefix}/subscriptions`, data);
2429
+ }
2430
+ /**
2431
+ * 구독 목록 조회
2432
+ *
2433
+ * @param params - 조회 옵션
2434
+ * @returns 구독 목록
2435
+ *
2436
+ * @example
2437
+ * ```typescript
2438
+ * const { subscriptions, total } = await client.subscription.list({
2439
+ * status: 'active',
2440
+ * limit: 10
2441
+ * })
2442
+ * ```
2443
+ */
2444
+ async list(params) {
2445
+ const prefix = this.getPublicPrefix();
2446
+ const query = new URLSearchParams();
2447
+ if (params?.status) query.set("status", params.status);
2448
+ if (params?.limit) query.set("limit", String(params.limit));
2449
+ if (params?.offset) query.set("offset", String(params.offset));
2450
+ const queryString = query.toString();
2451
+ return this.http.get(`${prefix}/subscriptions${queryString ? "?" + queryString : ""}`);
2452
+ }
2453
+ /**
2454
+ * 구독 상세 조회
2455
+ *
2456
+ * @param subscriptionId - 구독 ID
2457
+ * @returns 구독 상세 정보
2458
+ */
2459
+ async get(subscriptionId) {
2460
+ const prefix = this.getPublicPrefix();
2461
+ return this.http.get(`${prefix}/subscriptions/${subscriptionId}`);
2462
+ }
2463
+ /**
2464
+ * 구독 수정
2465
+ *
2466
+ * @param subscriptionId - 구독 ID
2467
+ * @param data - 수정 정보
2468
+ * @returns 수정된 구독 정보
2469
+ *
2470
+ * @example
2471
+ * ```typescript
2472
+ * await client.subscription.update(subscriptionId, {
2473
+ * plan_name: '엔터프라이즈 플랜',
2474
+ * amount: 29900
2475
+ * })
2476
+ * ```
2477
+ */
2478
+ async update(subscriptionId, data) {
2479
+ const prefix = this.getPublicPrefix();
2480
+ return this.http.patch(`${prefix}/subscriptions/${subscriptionId}`, data);
2481
+ }
2482
+ /**
2483
+ * 구독 일시정지
2484
+ *
2485
+ * @param subscriptionId - 구독 ID
2486
+ * @param data - 일시정지 정보
2487
+ * @returns 일시정지된 구독 정보
2488
+ *
2489
+ * @example
2490
+ * ```typescript
2491
+ * await client.subscription.pause(subscriptionId, {
2492
+ * reason: '고객 요청'
2493
+ * })
2494
+ * ```
2495
+ */
2496
+ async pause(subscriptionId, data) {
2497
+ const prefix = this.getPublicPrefix();
2498
+ return this.http.post(`${prefix}/subscriptions/${subscriptionId}/pause`, data || {});
2499
+ }
2500
+ /**
2501
+ * 구독 재개
2502
+ *
2503
+ * @param subscriptionId - 구독 ID
2504
+ * @returns 재개된 구독 정보
2505
+ *
2506
+ * @example
2507
+ * ```typescript
2508
+ * await client.subscription.resume(subscriptionId)
2509
+ * ```
2510
+ */
2511
+ async resume(subscriptionId) {
2512
+ const prefix = this.getPublicPrefix();
2513
+ return this.http.post(`${prefix}/subscriptions/${subscriptionId}/resume`, {});
2514
+ }
2515
+ /**
2516
+ * 구독 취소
2517
+ *
2518
+ * @param subscriptionId - 구독 ID
2519
+ * @param data - 취소 정보
2520
+ * @returns 취소된 구독 정보
2521
+ *
2522
+ * @example
2523
+ * ```typescript
2524
+ * // 현재 기간 종료 후 취소
2525
+ * await client.subscription.cancel(subscriptionId, {
2526
+ * reason: '서비스 불만족'
2527
+ * })
2528
+ *
2529
+ * // 즉시 취소
2530
+ * await client.subscription.cancel(subscriptionId, {
2531
+ * reason: '즉시 해지 요청',
2532
+ * immediate: true
2533
+ * })
2534
+ * ```
2535
+ */
2536
+ async cancel(subscriptionId, data) {
2537
+ const prefix = this.getPublicPrefix();
2538
+ return this.http.post(`${prefix}/subscriptions/${subscriptionId}/cancel`, data);
2539
+ }
2540
+ // =====================
2541
+ // Subscription Payment APIs
2542
+ // =====================
2543
+ /**
2544
+ * 구독 결제 이력 조회
2545
+ *
2546
+ * @param subscriptionId - 구독 ID
2547
+ * @param params - 조회 옵션
2548
+ * @returns 결제 이력 목록
2549
+ *
2550
+ * @example
2551
+ * ```typescript
2552
+ * const { payments } = await client.subscription.listPayments(subscriptionId, {
2553
+ * limit: 10
2554
+ * })
2555
+ * ```
2556
+ */
2557
+ async listPayments(subscriptionId, params) {
2558
+ const prefix = this.getPublicPrefix();
2559
+ const query = new URLSearchParams();
2560
+ if (params?.status) query.set("status", params.status);
2561
+ if (params?.limit) query.set("limit", String(params.limit));
2562
+ if (params?.offset) query.set("offset", String(params.offset));
2563
+ const queryString = query.toString();
2564
+ return this.http.get(
2565
+ `${prefix}/subscriptions/${subscriptionId}/payments${queryString ? "?" + queryString : ""}`
2566
+ );
2567
+ }
2568
+ // =====================
2569
+ // Direct Charge APIs
2570
+ // =====================
2571
+ /**
2572
+ * 빌링키로 즉시 결제
2573
+ * 구독 없이 빌링키로 일회성 결제를 진행합니다.
2574
+ *
2575
+ * @param data - 결제 정보
2576
+ * @returns 결제 결과
2577
+ *
2578
+ * @example
2579
+ * ```typescript
2580
+ * const result = await client.subscription.chargeWithBillingKey({
2581
+ * billing_key_id: 'bk_xxxxx',
2582
+ * amount: 15000,
2583
+ * order_name: '추가 결제'
2584
+ * })
2585
+ *
2586
+ * if (result.status === 'DONE') {
2587
+ * console.log('결제 완료!')
2588
+ * }
2589
+ * ```
2590
+ */
2591
+ async chargeWithBillingKey(data) {
2592
+ const prefix = this.getPublicPrefix();
2593
+ return this.http.post(`${prefix}/subscriptions/charge`, data);
2594
+ }
2595
+ };
2596
+
2597
+ // src/api/push.ts
2598
+ var PushAPI = class {
2599
+ constructor(http) {
2600
+ this.http = http;
2601
+ }
2602
+ /**
2603
+ * API Key 인증 시 /v1/public 접두사 반환
2604
+ */
2605
+ getPublicPrefix() {
2606
+ return this.http.hasApiKey() ? "/v1/public" : "/v1";
2607
+ }
2608
+ // ============ Device Registration ============
2609
+ /**
2610
+ * 디바이스 등록 (APNS/FCM 토큰)
2611
+ *
2612
+ * @param request 디바이스 등록 정보
2613
+ * @returns 등록된 디바이스 정보
2614
+ *
2615
+ * @example
2616
+ * ```typescript
2617
+ * // iOS 디바이스 등록
2618
+ * const device = await cb.push.registerDevice({
2619
+ * device_token: 'apns-token-here',
2620
+ * platform: 'ios',
2621
+ * device_name: 'iPhone 15 Pro',
2622
+ * device_model: 'iPhone15,2',
2623
+ * os_version: '17.0',
2624
+ * app_version: '1.0.0',
2625
+ * })
2626
+ *
2627
+ * // Android 디바이스 등록
2628
+ * const device = await cb.push.registerDevice({
2629
+ * device_token: 'fcm-token-here',
2630
+ * platform: 'android',
2631
+ * device_name: 'Galaxy S24',
2632
+ * })
2633
+ * ```
2634
+ */
2635
+ async registerDevice(request) {
2636
+ const prefix = this.getPublicPrefix();
2637
+ return this.http.post(`${prefix}/push/devices`, request);
2638
+ }
2639
+ /**
2640
+ * 디바이스 등록 해제
2641
+ *
2642
+ * @param deviceId 디바이스 ID
2643
+ *
2644
+ * @example
2645
+ * ```typescript
2646
+ * await cb.push.unregisterDevice('device-id-here')
2647
+ * ```
2648
+ */
2649
+ async unregisterDevice(deviceId) {
2650
+ const prefix = this.getPublicPrefix();
2651
+ await this.http.delete(`${prefix}/push/devices/${deviceId}`);
2652
+ }
2653
+ /**
2654
+ * 현재 등록된 디바이스 목록 조회
2655
+ *
2656
+ * @returns 디바이스 목록
2657
+ */
2658
+ async getDevices() {
2659
+ const prefix = this.getPublicPrefix();
2660
+ const response = await this.http.get(`${prefix}/push/devices`);
2661
+ return response.devices || [];
2662
+ }
2663
+ // ============ Topic Subscription ============
2664
+ /**
2665
+ * 토픽 구독
2666
+ *
2667
+ * @param topicName 토픽 이름
2668
+ *
2669
+ * @example
2670
+ * ```typescript
2671
+ * // 공지사항 토픽 구독
2672
+ * await cb.push.subscribeTopic('announcements')
2673
+ *
2674
+ * // 마케팅 토픽 구독
2675
+ * await cb.push.subscribeTopic('marketing')
2676
+ * ```
2677
+ */
2678
+ async subscribeTopic(topicName) {
2679
+ const prefix = this.getPublicPrefix();
2680
+ const request = { topic_name: topicName };
2681
+ await this.http.post(`${prefix}/push/topics/subscribe`, request);
2682
+ }
2683
+ /**
2684
+ * 토픽 구독 해제
2685
+ *
2686
+ * @param topicName 토픽 이름
2687
+ *
2688
+ * @example
2689
+ * ```typescript
2690
+ * await cb.push.unsubscribeTopic('marketing')
2691
+ * ```
2692
+ */
2693
+ async unsubscribeTopic(topicName) {
2694
+ const prefix = this.getPublicPrefix();
2695
+ const request = { topic_name: topicName };
2696
+ await this.http.post(`${prefix}/push/topics/unsubscribe`, request);
2697
+ }
2698
+ /**
2699
+ * 구독 중인 토픽 목록 조회
2700
+ *
2701
+ * @returns 구독 중인 토픽 이름 목록
2702
+ */
2703
+ async getSubscribedTopics() {
2704
+ const prefix = this.getPublicPrefix();
2705
+ const response = await this.http.get(`${prefix}/push/topics/subscribed`);
2706
+ return response.topics || [];
2707
+ }
2708
+ // ============ Web Push ============
2709
+ /**
2710
+ * VAPID Public Key 조회 (Web Push용)
2711
+ *
2712
+ * @returns VAPID Public Key
2713
+ *
2714
+ * @example
2715
+ * ```typescript
2716
+ * const vapidKey = await cb.push.getVAPIDPublicKey()
2717
+ *
2718
+ * // Service Worker에서 Push 구독
2719
+ * const registration = await navigator.serviceWorker.ready
2720
+ * const subscription = await registration.pushManager.subscribe({
2721
+ * userVisibleOnly: true,
2722
+ * applicationServerKey: urlBase64ToUint8Array(vapidKey.public_key)
2723
+ * })
2724
+ *
2725
+ * // 구독 정보 등록
2726
+ * await cb.push.registerWebPush(subscription)
2727
+ * ```
2728
+ */
2729
+ async getVAPIDPublicKey() {
2730
+ const prefix = this.getPublicPrefix();
2731
+ return this.http.get(`${prefix}/push/vapid-key`);
2732
+ }
2733
+ /**
2734
+ * Web Push 구독 등록
2735
+ *
2736
+ * @param subscription PushSubscription 객체 또는 WebPushSubscription
2737
+ *
2738
+ * @example
2739
+ * ```typescript
2740
+ * // 브라우저에서 Push 구독 후 등록
2741
+ * const registration = await navigator.serviceWorker.ready
2742
+ * const subscription = await registration.pushManager.subscribe({
2743
+ * userVisibleOnly: true,
2744
+ * applicationServerKey: vapidPublicKey
2745
+ * })
2746
+ *
2747
+ * await cb.push.registerWebPush(subscription)
2748
+ * ```
2749
+ */
2750
+ async registerWebPush(subscription) {
2751
+ const prefix = this.getPublicPrefix();
2752
+ let webPushData;
2753
+ if ("toJSON" in subscription) {
2754
+ const json = subscription.toJSON();
2755
+ webPushData = {
2756
+ endpoint: json.endpoint || "",
2757
+ expirationTime: json.expirationTime,
2758
+ keys: {
2759
+ p256dh: json.keys?.p256dh || "",
2760
+ auth: json.keys?.auth || ""
2761
+ }
2762
+ };
2763
+ } else {
2764
+ webPushData = subscription;
2765
+ }
2766
+ const request = {
2767
+ device_token: webPushData.endpoint,
2768
+ platform: "web",
2769
+ device_id: this.generateDeviceId(),
2770
+ device_name: this.getBrowserName(),
2771
+ os_version: this.getOSInfo()
2772
+ };
2773
+ return this.http.post(`${prefix}/push/devices/web`, {
2774
+ ...request,
2775
+ web_push_subscription: webPushData
2776
+ });
2777
+ }
2778
+ /**
2779
+ * Web Push 구독 해제
2780
+ */
2781
+ async unregisterWebPush() {
2782
+ const prefix = this.getPublicPrefix();
2783
+ await this.http.delete(`${prefix}/push/devices/web`);
2784
+ }
2785
+ // ============ Helper Methods ============
2786
+ /**
2787
+ * 브라우저 고유 ID 생성 (localStorage에 저장)
2788
+ */
2789
+ generateDeviceId() {
2790
+ if (typeof window === "undefined" || typeof localStorage === "undefined") {
2791
+ return `device_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
2792
+ }
2793
+ const storageKey = "cb_push_device_id";
2794
+ let deviceId = localStorage.getItem(storageKey);
2795
+ if (!deviceId) {
2796
+ deviceId = `web_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
2797
+ localStorage.setItem(storageKey, deviceId);
2798
+ }
2799
+ return deviceId;
2800
+ }
2801
+ /**
2802
+ * 브라우저 이름 감지
2803
+ */
2804
+ getBrowserName() {
2805
+ if (typeof navigator === "undefined") {
2806
+ return "Unknown Browser";
2807
+ }
2808
+ const userAgent = navigator.userAgent;
2809
+ if (userAgent.includes("Chrome") && !userAgent.includes("Edg")) {
2810
+ return "Chrome";
2811
+ } else if (userAgent.includes("Safari") && !userAgent.includes("Chrome")) {
2812
+ return "Safari";
2813
+ } else if (userAgent.includes("Firefox")) {
2814
+ return "Firefox";
2815
+ } else if (userAgent.includes("Edg")) {
2816
+ return "Edge";
2817
+ } else if (userAgent.includes("Opera") || userAgent.includes("OPR")) {
2818
+ return "Opera";
2819
+ }
2820
+ return "Unknown Browser";
2821
+ }
2822
+ /**
2823
+ * OS 정보 감지
2824
+ */
2825
+ getOSInfo() {
2826
+ if (typeof navigator === "undefined") {
2827
+ return "Unknown OS";
2828
+ }
2829
+ const userAgent = navigator.userAgent;
2830
+ if (userAgent.includes("Windows")) {
2831
+ return "Windows";
2832
+ } else if (userAgent.includes("Mac OS")) {
2833
+ return "macOS";
2834
+ } else if (userAgent.includes("Linux")) {
2835
+ return "Linux";
2836
+ } else if (userAgent.includes("Android")) {
2837
+ return "Android";
2838
+ } else if (userAgent.includes("iOS") || userAgent.includes("iPhone") || userAgent.includes("iPad")) {
2839
+ return "iOS";
2840
+ }
2841
+ return "Unknown OS";
2842
+ }
2843
+ };
2844
+
2845
+ // src/api/video.ts
2846
+ var DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
2847
+ var VideoProcessingError = class extends Error {
2848
+ constructor(message, video) {
2849
+ super(message);
2850
+ this.name = "VideoProcessingError";
2851
+ this.video = video;
2852
+ }
2853
+ };
2854
+ var VideoAPI = class {
2855
+ constructor(http, videoBaseUrl) {
2856
+ this.http = http;
2857
+ this.videoBaseUrl = videoBaseUrl || this.getDefaultVideoUrl();
2858
+ }
2859
+ getDefaultVideoUrl() {
2860
+ if (typeof window !== "undefined") {
2861
+ const hostname = window.location.hostname;
2862
+ if (hostname === "localhost" || hostname === "127.0.0.1") {
2863
+ return "http://localhost:8089";
2864
+ }
2865
+ }
2866
+ return "https://video.connectbase.world";
2867
+ }
2868
+ getPublicPrefix() {
2869
+ return this.http.hasApiKey() ? "/v1/public" : "/v1";
2870
+ }
2871
+ async videoFetch(method, path, body) {
2872
+ const headers = {};
2873
+ const apiKey = this.http.getApiKey();
2874
+ if (apiKey) {
2875
+ headers["X-API-Key"] = apiKey;
2876
+ }
2877
+ const accessToken = this.http.getAccessToken();
2878
+ if (accessToken) {
2879
+ headers["Authorization"] = `Bearer ${accessToken}`;
2880
+ }
2881
+ if (body && !(body instanceof FormData)) {
2882
+ headers["Content-Type"] = "application/json";
2883
+ }
2884
+ const response = await fetch(`${this.videoBaseUrl}${path}`, {
2885
+ method,
2886
+ headers,
2887
+ body: body instanceof FormData ? body : body ? JSON.stringify(body) : void 0
2888
+ });
2889
+ if (!response.ok) {
2890
+ const errorData = await response.json().catch(() => ({
2891
+ message: response.statusText
2892
+ }));
2893
+ throw new ApiError(response.status, errorData.message || "Unknown error");
2894
+ }
2895
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
2896
+ return {};
2897
+ }
2898
+ return response.json();
2899
+ }
2900
+ // ========== Video Operations ==========
2901
+ /**
2902
+ * Upload a video file with chunked upload support
2903
+ */
2904
+ async upload(file, options) {
2905
+ const prefix = this.getPublicPrefix();
2906
+ const session = await this.videoFetch("POST", `${prefix}/uploads`, {
2907
+ filename: file.name,
2908
+ size: file.size,
2909
+ mime_type: file.type,
2910
+ title: options.title,
2911
+ description: options.description,
2912
+ visibility: options.visibility || "private",
2913
+ tags: options.tags,
2914
+ channel_id: options.channel_id
2915
+ });
2916
+ const chunkSize = session.chunk_size || DEFAULT_CHUNK_SIZE;
2917
+ const totalChunks = Math.ceil(file.size / chunkSize);
2918
+ let uploadedChunks = 0;
2919
+ const startTime = Date.now();
2920
+ let lastProgressTime = startTime;
2921
+ let lastUploadedBytes = 0;
2922
+ for (let i = 0; i < totalChunks; i++) {
2923
+ const start = i * chunkSize;
2924
+ const end = Math.min(start + chunkSize, file.size);
2925
+ const chunk = file.slice(start, end);
2926
+ const formData = new FormData();
2927
+ formData.append("chunk", chunk);
2928
+ formData.append("chunk_index", String(i));
2929
+ await this.videoFetch(
2930
+ "POST",
2931
+ `${prefix}/uploads/${session.session_id}/chunks`,
2932
+ formData
2933
+ );
2934
+ uploadedChunks++;
2935
+ const now = Date.now();
2936
+ const timeDiff = (now - lastProgressTime) / 1e3;
2937
+ const uploadedBytes = end;
2938
+ const bytesDiff = uploadedBytes - lastUploadedBytes;
2939
+ const currentSpeed = timeDiff > 0 ? bytesDiff / timeDiff : 0;
2940
+ lastProgressTime = now;
2941
+ lastUploadedBytes = uploadedBytes;
2942
+ if (options.onProgress) {
2943
+ options.onProgress({
2944
+ phase: "uploading",
2945
+ uploadedChunks,
2946
+ totalChunks,
2947
+ percentage: Math.round(uploadedChunks / totalChunks * 100),
2948
+ currentSpeed
2949
+ });
2950
+ }
2951
+ }
2952
+ const result = await this.videoFetch(
2953
+ "POST",
2954
+ `${prefix}/uploads/${session.session_id}/complete`,
2955
+ {}
2956
+ );
2957
+ return result.video;
2958
+ }
2959
+ /**
2960
+ * Wait for video processing to complete
2961
+ */
2962
+ async waitForReady(videoId, options) {
2963
+ const timeout = options?.timeout || 30 * 60 * 1e3;
2964
+ const interval = options?.interval || 5e3;
2965
+ const startTime = Date.now();
2966
+ const prefix = this.getPublicPrefix();
2967
+ while (Date.now() - startTime < timeout) {
2968
+ const video = await this.videoFetch("GET", `${prefix}/videos/${videoId}`);
2969
+ if (video.status === "ready") {
2970
+ return video;
2971
+ }
2972
+ if (video.status === "failed") {
2973
+ throw new VideoProcessingError("Video processing failed", video);
2974
+ }
2975
+ if (options?.onProgress) {
2976
+ const readyQualities = video.qualities.filter((q) => q.status === "ready").length;
2977
+ const totalQualities = video.qualities.length || 1;
2978
+ options.onProgress({
2979
+ phase: "processing",
2980
+ uploadedChunks: 0,
2981
+ totalChunks: 0,
2982
+ percentage: Math.round(readyQualities / totalQualities * 100)
2983
+ });
2984
+ }
2985
+ await new Promise((resolve) => setTimeout(resolve, interval));
2986
+ }
2987
+ throw new VideoProcessingError("Timeout waiting for video to be ready");
2988
+ }
2989
+ /**
2990
+ * List videos
2991
+ */
2992
+ async list(options) {
2993
+ const prefix = this.getPublicPrefix();
2994
+ const params = new URLSearchParams();
2995
+ if (options?.status) params.set("status", options.status);
2996
+ if (options?.visibility) params.set("visibility", options.visibility);
2997
+ if (options?.search) params.set("search", options.search);
2998
+ if (options?.channel_id) params.set("channel_id", options.channel_id);
2999
+ if (options?.page) params.set("page", String(options.page));
3000
+ if (options?.limit) params.set("limit", String(options.limit));
3001
+ const query = params.toString();
3002
+ return this.videoFetch("GET", `${prefix}/videos${query ? `?${query}` : ""}`);
3003
+ }
3004
+ /**
3005
+ * Get a single video
3006
+ */
3007
+ async get(videoId) {
3008
+ const prefix = this.getPublicPrefix();
3009
+ return this.videoFetch("GET", `${prefix}/videos/${videoId}`);
3010
+ }
3011
+ /**
3012
+ * Update video details
3013
+ */
3014
+ async update(videoId, data) {
3015
+ const prefix = this.getPublicPrefix();
3016
+ return this.videoFetch("PATCH", `${prefix}/videos/${videoId}`, data);
3017
+ }
3018
+ /**
3019
+ * Delete a video
3020
+ */
3021
+ async delete(videoId) {
3022
+ const prefix = this.getPublicPrefix();
3023
+ await this.videoFetch("DELETE", `${prefix}/videos/${videoId}`);
3024
+ }
3025
+ /**
3026
+ * Get streaming URL for a video
3027
+ */
3028
+ async getStreamUrl(videoId, quality) {
3029
+ const prefix = this.getPublicPrefix();
3030
+ const params = quality ? `?quality=${quality}` : "";
3031
+ return this.videoFetch("GET", `${prefix}/videos/${videoId}/stream-url${params}`);
3032
+ }
3033
+ /**
3034
+ * Get available thumbnails for a video
3035
+ */
3036
+ async getThumbnails(videoId) {
3037
+ const prefix = this.getPublicPrefix();
3038
+ const response = await this.videoFetch(
3039
+ "GET",
3040
+ `${prefix}/videos/${videoId}/thumbnails`
3041
+ );
3042
+ return response.thumbnails;
3043
+ }
3044
+ /**
3045
+ * Get transcode status
3046
+ */
3047
+ async getTranscodeStatus(videoId) {
3048
+ const prefix = this.getPublicPrefix();
3049
+ return this.videoFetch("GET", `${prefix}/videos/${videoId}/transcode/status`);
3050
+ }
3051
+ /**
3052
+ * Retry failed transcoding
3053
+ */
3054
+ async retryTranscode(videoId) {
3055
+ const prefix = this.getPublicPrefix();
3056
+ await this.videoFetch("POST", `${prefix}/videos/${videoId}/transcode/retry`, {});
3057
+ }
3058
+ // ========== Channel Operations ==========
3059
+ /**
3060
+ * Create a channel
3061
+ */
3062
+ async createChannel(data) {
3063
+ const prefix = this.getPublicPrefix();
3064
+ return this.videoFetch("POST", `${prefix}/channels`, data);
3065
+ }
3066
+ /**
3067
+ * Get a channel
3068
+ */
3069
+ async getChannel(channelId) {
3070
+ const prefix = this.getPublicPrefix();
3071
+ return this.videoFetch("GET", `${prefix}/channels/${channelId}`);
3072
+ }
3073
+ /**
3074
+ * Get channel by handle
3075
+ */
3076
+ async getChannelByHandle(handle) {
3077
+ const prefix = this.getPublicPrefix();
3078
+ return this.videoFetch("GET", `${prefix}/channels/handle/${handle}`);
3079
+ }
3080
+ /**
3081
+ * Update channel
3082
+ */
3083
+ async updateChannel(channelId, data) {
3084
+ const prefix = this.getPublicPrefix();
3085
+ return this.videoFetch("PATCH", `${prefix}/channels/${channelId}`, data);
3086
+ }
3087
+ /**
3088
+ * Subscribe to a channel
3089
+ */
3090
+ async subscribeChannel(channelId) {
3091
+ const prefix = this.getPublicPrefix();
3092
+ await this.videoFetch("POST", `${prefix}/channels/${channelId}/subscribe`, {});
3093
+ }
3094
+ /**
3095
+ * Unsubscribe from a channel
3096
+ */
3097
+ async unsubscribeChannel(channelId) {
3098
+ const prefix = this.getPublicPrefix();
3099
+ await this.videoFetch("DELETE", `${prefix}/channels/${channelId}/subscribe`);
3100
+ }
3101
+ // ========== Playlist Operations ==========
3102
+ /**
3103
+ * Create a playlist
3104
+ */
3105
+ async createPlaylist(channelId, data) {
3106
+ const prefix = this.getPublicPrefix();
3107
+ return this.videoFetch("POST", `${prefix}/channels/${channelId}/playlists`, data);
3108
+ }
3109
+ /**
3110
+ * Get playlists for a channel
3111
+ */
3112
+ async getPlaylists(channelId) {
3113
+ const prefix = this.getPublicPrefix();
3114
+ const response = await this.videoFetch(
3115
+ "GET",
3116
+ `${prefix}/channels/${channelId}/playlists`
3117
+ );
3118
+ return response.playlists;
3119
+ }
3120
+ /**
3121
+ * Get playlist items
3122
+ */
3123
+ async getPlaylistItems(playlistId) {
3124
+ const prefix = this.getPublicPrefix();
3125
+ const response = await this.videoFetch(
3126
+ "GET",
3127
+ `${prefix}/playlists/${playlistId}/items`
3128
+ );
3129
+ return response.items;
3130
+ }
3131
+ /**
3132
+ * Add video to playlist
3133
+ */
3134
+ async addToPlaylist(playlistId, videoId, position) {
3135
+ const prefix = this.getPublicPrefix();
3136
+ return this.videoFetch("POST", `${prefix}/playlists/${playlistId}/items`, {
3137
+ video_id: videoId,
3138
+ position
3139
+ });
3140
+ }
3141
+ /**
3142
+ * Remove video from playlist
3143
+ */
3144
+ async removeFromPlaylist(playlistId, itemId) {
3145
+ const prefix = this.getPublicPrefix();
3146
+ await this.videoFetch("DELETE", `${prefix}/playlists/${playlistId}/items/${itemId}`);
3147
+ }
3148
+ // ========== Shorts Operations ==========
3149
+ /**
3150
+ * Get shorts feed
3151
+ */
3152
+ async getShortsFeed(options) {
3153
+ const prefix = this.getPublicPrefix();
3154
+ const params = new URLSearchParams();
3155
+ if (options?.cursor) params.set("cursor", options.cursor);
3156
+ if (options?.limit) params.set("limit", String(options.limit));
3157
+ const query = params.toString();
3158
+ return this.videoFetch("GET", `${prefix}/shorts${query ? `?${query}` : ""}`);
3159
+ }
3160
+ /**
3161
+ * Get trending shorts
3162
+ */
3163
+ async getTrendingShorts(limit) {
3164
+ const prefix = this.getPublicPrefix();
3165
+ const params = limit ? `?limit=${limit}` : "";
3166
+ return this.videoFetch("GET", `${prefix}/shorts/trending${params}`);
3167
+ }
3168
+ /**
3169
+ * Get a single shorts
3170
+ */
3171
+ async getShorts(shortsId) {
3172
+ const prefix = this.getPublicPrefix();
3173
+ return this.videoFetch("GET", `${prefix}/shorts/${shortsId}`);
3174
+ }
3175
+ // ========== Comment Operations ==========
3176
+ /**
3177
+ * Get comments for a video
3178
+ */
3179
+ async getComments(videoId, options) {
3180
+ const prefix = this.getPublicPrefix();
3181
+ const params = new URLSearchParams();
3182
+ if (options?.cursor) params.set("cursor", options.cursor);
3183
+ if (options?.limit) params.set("limit", String(options.limit));
3184
+ if (options?.sort) params.set("sort", options.sort);
3185
+ const query = params.toString();
3186
+ return this.videoFetch(
3187
+ "GET",
3188
+ `${prefix}/videos/${videoId}/comments${query ? `?${query}` : ""}`
3189
+ );
3190
+ }
3191
+ /**
3192
+ * Post a comment
3193
+ */
3194
+ async postComment(videoId, content, parentId) {
3195
+ const prefix = this.getPublicPrefix();
3196
+ return this.videoFetch("POST", `${prefix}/videos/${videoId}/comments`, {
3197
+ content,
3198
+ parent_id: parentId
3199
+ });
3200
+ }
3201
+ /**
3202
+ * Delete a comment
3203
+ */
3204
+ async deleteComment(commentId) {
3205
+ const prefix = this.getPublicPrefix();
3206
+ await this.videoFetch("DELETE", `${prefix}/comments/${commentId}`);
3207
+ }
3208
+ // ========== Like Operations ==========
3209
+ /**
3210
+ * Like a video
3211
+ */
3212
+ async likeVideo(videoId) {
3213
+ const prefix = this.getPublicPrefix();
3214
+ await this.videoFetch("POST", `${prefix}/videos/${videoId}/like`, {});
3215
+ }
3216
+ /**
3217
+ * Unlike a video
3218
+ */
3219
+ async unlikeVideo(videoId) {
3220
+ const prefix = this.getPublicPrefix();
3221
+ await this.videoFetch("DELETE", `${prefix}/videos/${videoId}/like`);
3222
+ }
3223
+ // ========== Watch History Operations ==========
3224
+ /**
3225
+ * Get watch history
3226
+ */
3227
+ async getWatchHistory(options) {
3228
+ const prefix = this.getPublicPrefix();
3229
+ const params = new URLSearchParams();
3230
+ if (options?.cursor) params.set("cursor", options.cursor);
3231
+ if (options?.limit) params.set("limit", String(options.limit));
3232
+ const query = params.toString();
3233
+ return this.videoFetch(
3234
+ "GET",
3235
+ `${prefix}/watch-history${query ? `?${query}` : ""}`
3236
+ );
3237
+ }
3238
+ /**
3239
+ * Clear watch history
3240
+ */
3241
+ async clearWatchHistory() {
3242
+ const prefix = this.getPublicPrefix();
3243
+ await this.videoFetch("DELETE", `${prefix}/watch-history`);
3244
+ }
3245
+ /**
3246
+ * Report watch progress
3247
+ */
3248
+ async reportWatchProgress(videoId, position, duration) {
3249
+ const prefix = this.getPublicPrefix();
3250
+ await this.videoFetch("POST", `${prefix}/videos/${videoId}/watch-progress`, {
3251
+ position,
3252
+ duration
3253
+ });
3254
+ }
3255
+ // ========== Membership Operations ==========
3256
+ /**
3257
+ * Get membership tiers for a channel
3258
+ */
3259
+ async getMembershipTiers(channelId) {
3260
+ const prefix = this.getPublicPrefix();
3261
+ const response = await this.videoFetch(
3262
+ "GET",
3263
+ `${prefix}/channels/${channelId}/memberships/tiers`
3264
+ );
3265
+ return response.tiers;
3266
+ }
3267
+ /**
3268
+ * Join a membership tier
3269
+ */
3270
+ async joinMembership(channelId, tierId) {
3271
+ const prefix = this.getPublicPrefix();
3272
+ return this.videoFetch(
3273
+ "POST",
3274
+ `${prefix}/channels/${channelId}/memberships/${tierId}/join`,
3275
+ {}
3276
+ );
3277
+ }
3278
+ /**
3279
+ * Cancel membership
3280
+ */
3281
+ async cancelMembership(channelId, membershipId) {
3282
+ const prefix = this.getPublicPrefix();
3283
+ await this.videoFetch(
3284
+ "POST",
3285
+ `${prefix}/channels/${channelId}/memberships/${membershipId}/cancel`,
3286
+ {}
3287
+ );
3288
+ }
3289
+ // ========== Super Chat Operations ==========
3290
+ /**
3291
+ * Send a super chat
3292
+ */
3293
+ async sendSuperChat(videoId, amount, message, currency) {
3294
+ const prefix = this.getPublicPrefix();
3295
+ return this.videoFetch("POST", `${prefix}/videos/${videoId}/super-chats`, {
3296
+ amount,
3297
+ message,
3298
+ currency: currency || "USD"
3299
+ });
3300
+ }
3301
+ /**
3302
+ * Get super chats for a video
3303
+ */
3304
+ async getSuperChats(videoId) {
3305
+ const prefix = this.getPublicPrefix();
3306
+ const response = await this.videoFetch(
3307
+ "GET",
3308
+ `${prefix}/videos/${videoId}/super-chats`
3309
+ );
3310
+ return response.super_chats;
3311
+ }
3312
+ // ========== Recommendation Operations ==========
3313
+ /**
3314
+ * Get recommended videos
3315
+ */
3316
+ async getRecommendations(limit) {
3317
+ const prefix = this.getPublicPrefix();
3318
+ const params = limit ? `?limit=${limit}` : "";
3319
+ const response = await this.videoFetch(
3320
+ "GET",
3321
+ `${prefix}/recommendations${params}`
3322
+ );
3323
+ return response.videos;
3324
+ }
3325
+ /**
3326
+ * Get home feed
3327
+ */
3328
+ async getHomeFeed(limit) {
3329
+ const prefix = this.getPublicPrefix();
3330
+ const params = limit ? `?limit=${limit}` : "";
3331
+ const response = await this.videoFetch(
3332
+ "GET",
3333
+ `${prefix}/recommendations/home${params}`
3334
+ );
3335
+ return response.videos;
3336
+ }
3337
+ /**
3338
+ * Get related videos
3339
+ */
3340
+ async getRelatedVideos(videoId, limit) {
3341
+ const prefix = this.getPublicPrefix();
3342
+ const params = limit ? `?limit=${limit}` : "";
3343
+ const response = await this.videoFetch(
3344
+ "GET",
3345
+ `${prefix}/recommendations/related/${videoId}${params}`
3346
+ );
3347
+ return response.videos;
3348
+ }
3349
+ /**
3350
+ * Get trending videos
3351
+ */
3352
+ async getTrendingVideos(limit) {
3353
+ const prefix = this.getPublicPrefix();
3354
+ const params = limit ? `?limit=${limit}` : "";
3355
+ const response = await this.videoFetch(
3356
+ "GET",
3357
+ `${prefix}/recommendations/trending${params}`
3358
+ );
3359
+ return response.videos;
3360
+ }
3361
+ /**
3362
+ * Submit recommendation feedback
3363
+ */
3364
+ async submitFeedback(videoId, feedback) {
3365
+ const prefix = this.getPublicPrefix();
3366
+ await this.videoFetch("POST", `${prefix}/recommendations/feedback`, {
3367
+ video_id: videoId,
3368
+ feedback
3369
+ });
3370
+ }
3371
+ };
3372
+
3373
+ // src/api/game.ts
3374
+ var getDefaultGameServerUrl = () => {
3375
+ if (typeof window !== "undefined") {
3376
+ const hostname = window.location.hostname;
3377
+ if (hostname === "localhost" || hostname === "127.0.0.1") {
3378
+ return "ws://localhost:8087";
3379
+ }
3380
+ }
3381
+ return "wss://game.connectbase.world";
3382
+ };
3383
+ var GameRoom = class {
3384
+ constructor(config) {
3385
+ this.ws = null;
3386
+ this.handlers = {};
3387
+ this.reconnectAttempts = 0;
3388
+ this.reconnectTimer = null;
3389
+ this.pingInterval = null;
3390
+ this.actionSequence = 0;
3391
+ this._roomId = null;
3392
+ this._state = null;
3393
+ this._isConnected = false;
3394
+ this.config = {
3395
+ gameServerUrl: getDefaultGameServerUrl(),
3396
+ autoReconnect: true,
3397
+ maxReconnectAttempts: 5,
3398
+ reconnectInterval: 1e3,
3399
+ ...config
3400
+ };
3401
+ }
3402
+ /**
3403
+ * 현재 룸 ID
3404
+ */
3405
+ get roomId() {
3406
+ return this._roomId;
3407
+ }
3408
+ /**
3409
+ * 현재 게임 상태
3410
+ */
3411
+ get state() {
3412
+ return this._state;
3413
+ }
3414
+ /**
3415
+ * 연결 상태
3416
+ */
3417
+ get isConnected() {
3418
+ return this._isConnected;
3419
+ }
3420
+ /**
3421
+ * 이벤트 핸들러 등록
3422
+ */
3423
+ on(event, handler) {
3424
+ this.handlers[event] = handler;
3425
+ return this;
3426
+ }
3427
+ /**
3428
+ * 게임 서버에 연결
3429
+ */
3430
+ connect(roomId) {
3431
+ return new Promise((resolve, reject) => {
3432
+ if (this.ws?.readyState === WebSocket.OPEN) {
3433
+ resolve();
3434
+ return;
3435
+ }
3436
+ const url = this.buildConnectionUrl(roomId);
3437
+ this.ws = new WebSocket(url);
3438
+ const onOpen = () => {
3439
+ this._isConnected = true;
3440
+ this.reconnectAttempts = 0;
3441
+ this.startPingInterval();
3442
+ this.handlers.onConnect?.();
3443
+ resolve();
3444
+ };
3445
+ const onClose = (event) => {
3446
+ this._isConnected = false;
3447
+ this.stopPingInterval();
3448
+ this.handlers.onDisconnect?.(event);
3449
+ if (this.config.autoReconnect && event.code !== 1e3) {
3450
+ this.scheduleReconnect(roomId);
3451
+ }
3452
+ };
3453
+ const onError = (event) => {
3454
+ this.handlers.onError?.(event);
3455
+ reject(new Error("WebSocket connection failed"));
3456
+ };
3457
+ const onMessage = (event) => {
3458
+ this.handleMessage(event.data);
3459
+ };
3460
+ this.ws.addEventListener("open", onOpen, { once: true });
3461
+ this.ws.addEventListener("close", onClose);
3462
+ this.ws.addEventListener("error", onError, { once: true });
3463
+ this.ws.addEventListener("message", onMessage);
3464
+ });
3465
+ }
3466
+ /**
3467
+ * 연결 해제
3468
+ */
3469
+ disconnect() {
3470
+ this.stopPingInterval();
3471
+ if (this.reconnectTimer) {
3472
+ clearTimeout(this.reconnectTimer);
3473
+ this.reconnectTimer = null;
3474
+ }
3475
+ if (this.ws) {
3476
+ this.ws.close(1e3, "Client disconnected");
3477
+ this.ws = null;
3478
+ }
3479
+ this._isConnected = false;
3480
+ this._roomId = null;
3481
+ }
3482
+ /**
3483
+ * 룸 생성
3484
+ */
3485
+ createRoom(config = {}) {
3486
+ return new Promise((resolve, reject) => {
3487
+ const handler = (msg) => {
3488
+ if (msg.type === "room_created") {
3489
+ const data = msg.data;
3490
+ this._roomId = data.room_id;
3491
+ this._state = data.initial_state;
3492
+ resolve(data.initial_state);
3493
+ } else if (msg.type === "error") {
3494
+ reject(new Error(msg.data.message));
3495
+ }
3496
+ };
3497
+ this.sendWithHandler("create_room", config, handler);
3498
+ });
3499
+ }
3500
+ /**
3501
+ * 룸 참가
3502
+ */
3503
+ joinRoom(roomId, metadata) {
3504
+ return new Promise((resolve, reject) => {
3505
+ const handler = (msg) => {
3506
+ if (msg.type === "room_joined") {
3507
+ const data = msg.data;
3508
+ this._roomId = data.room_id;
3509
+ this._state = data.initial_state;
3510
+ resolve(data.initial_state);
3511
+ } else if (msg.type === "error") {
3512
+ reject(new Error(msg.data.message));
3513
+ }
3514
+ };
3515
+ this.sendWithHandler("join_room", { room_id: roomId, metadata }, handler);
3516
+ });
3517
+ }
3518
+ /**
3519
+ * 룸 퇴장
3520
+ */
3521
+ leaveRoom() {
3522
+ return new Promise((resolve, reject) => {
3523
+ if (!this._roomId) {
3524
+ reject(new Error("Not in a room"));
3525
+ return;
3526
+ }
3527
+ const handler = (msg) => {
3528
+ if (msg.type === "room_left") {
3529
+ this._roomId = null;
3530
+ this._state = null;
3531
+ resolve();
3532
+ } else if (msg.type === "error") {
3533
+ reject(new Error(msg.data.message));
3534
+ }
3535
+ };
3536
+ this.sendWithHandler("leave_room", {}, handler);
3537
+ });
3538
+ }
3539
+ /**
3540
+ * 게임 액션 전송
3541
+ */
3542
+ sendAction(action) {
3543
+ if (!this._roomId) {
3544
+ throw new Error("Not in a room");
3545
+ }
3546
+ this.send("action", {
3547
+ type: action.type,
3548
+ data: action.data,
3549
+ client_timestamp: action.clientTimestamp ?? Date.now(),
3550
+ sequence: this.actionSequence++
3551
+ });
3552
+ }
3553
+ /**
3554
+ * 채팅 메시지 전송
3555
+ */
3556
+ sendChat(message) {
3557
+ if (!this._roomId) {
3558
+ throw new Error("Not in a room");
3559
+ }
3560
+ this.send("chat", { message });
3561
+ }
3562
+ /**
3563
+ * 현재 상태 요청
3564
+ */
3565
+ requestState() {
3566
+ return new Promise((resolve, reject) => {
3567
+ if (!this._roomId) {
3568
+ reject(new Error("Not in a room"));
3569
+ return;
3570
+ }
3571
+ const handler = (msg) => {
3572
+ if (msg.type === "state") {
3573
+ const state = msg.data;
3574
+ this._state = state;
3575
+ resolve(state);
3576
+ } else if (msg.type === "error") {
3577
+ reject(new Error(msg.data.message));
3578
+ }
3579
+ };
3580
+ this.sendWithHandler("get_state", {}, handler);
3581
+ });
3582
+ }
3583
+ /**
3584
+ * 룸 목록 조회
3585
+ */
3586
+ listRooms() {
3587
+ return new Promise((resolve, reject) => {
3588
+ const handler = (msg) => {
3589
+ if (msg.type === "room_list") {
3590
+ const data = msg.data;
3591
+ resolve(data.rooms);
3592
+ } else if (msg.type === "error") {
3593
+ reject(new Error(msg.data.message));
3594
+ }
3595
+ };
3596
+ this.sendWithHandler("list_rooms", {}, handler);
3597
+ });
3598
+ }
3599
+ /**
3600
+ * Ping 전송 (RTT 측정용)
3601
+ */
3602
+ ping() {
3603
+ return new Promise((resolve, reject) => {
3604
+ const timestamp = Date.now();
3605
+ const handler = (msg) => {
3606
+ if (msg.type === "pong") {
3607
+ const pong = msg.data;
3608
+ const rtt = Date.now() - pong.clientTimestamp;
3609
+ this.handlers.onPong?.(pong);
3610
+ resolve(rtt);
3611
+ } else if (msg.type === "error") {
3612
+ reject(new Error(msg.data.message));
3613
+ }
3614
+ };
3615
+ this.sendWithHandler("ping", { timestamp }, handler);
3616
+ });
3617
+ }
3618
+ // Private methods
3619
+ buildConnectionUrl(roomId) {
3620
+ const baseUrl = this.config.gameServerUrl;
3621
+ const wsUrl = baseUrl.replace(/^http/, "ws");
3622
+ const params = new URLSearchParams();
3623
+ params.set("client_id", this.config.clientId);
3624
+ if (roomId) {
3625
+ params.set("room_id", roomId);
3626
+ }
3627
+ if (this.config.apiKey) {
3628
+ params.set("api_key", this.config.apiKey);
3629
+ }
3630
+ if (this.config.accessToken) {
3631
+ params.set("token", this.config.accessToken);
3632
+ }
3633
+ return `${wsUrl}/v1/game/ws?${params.toString()}`;
3634
+ }
3635
+ send(type, data) {
3636
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
3637
+ throw new Error("WebSocket is not connected");
3638
+ }
3639
+ this.ws.send(JSON.stringify({ type, data }));
3640
+ }
3641
+ sendWithHandler(type, data, handler) {
3642
+ const messageHandler = (event) => {
3643
+ try {
3644
+ const msg = JSON.parse(event.data);
3645
+ handler(msg);
3646
+ this.ws?.removeEventListener("message", messageHandler);
3647
+ } catch {
3648
+ }
3649
+ };
3650
+ this.ws?.addEventListener("message", messageHandler);
3651
+ this.send(type, data);
3652
+ }
3653
+ handleMessage(data) {
3654
+ try {
3655
+ const msg = JSON.parse(data);
3656
+ switch (msg.type) {
3657
+ case "delta":
3658
+ this.handleDelta(msg.data);
3659
+ break;
3660
+ case "state":
3661
+ this._state = msg.data;
3662
+ this.handlers.onStateUpdate?.(this._state);
3663
+ break;
3664
+ case "player_event":
3665
+ this.handlePlayerEvent(msg.data);
3666
+ break;
3667
+ case "chat":
3668
+ this.handlers.onChat?.(msg.data);
3669
+ break;
3670
+ case "error":
3671
+ this.handlers.onError?.(msg.data);
3672
+ break;
3673
+ default:
3674
+ break;
3675
+ }
3676
+ } catch {
3677
+ console.error("Failed to parse game message:", data);
3678
+ }
3679
+ }
3680
+ handleDelta(data) {
3681
+ const delta = data.delta;
3682
+ if (this._state) {
3683
+ for (const change of delta.changes) {
3684
+ this.applyChange(change);
3685
+ }
3686
+ this._state.version = delta.toVersion;
3687
+ }
3688
+ this.handlers.onDelta?.(delta);
3689
+ }
3690
+ applyChange(change) {
3691
+ if (!this._state) return;
3692
+ const path = change.path.split(".");
3693
+ let current = this._state.state;
3694
+ for (let i = 0; i < path.length - 1; i++) {
3695
+ const key = path[i];
3696
+ if (!(key in current)) {
3697
+ current[key] = {};
3698
+ }
3699
+ current = current[key];
3700
+ }
3701
+ const lastKey = path[path.length - 1];
3702
+ if (change.operation === "delete") {
3703
+ delete current[lastKey];
3704
+ } else {
3705
+ current[lastKey] = change.value;
3706
+ }
3707
+ }
3708
+ handlePlayerEvent(event) {
3709
+ if (event.event === "joined") {
3710
+ this.handlers.onPlayerJoined?.(event.player);
3711
+ } else if (event.event === "left") {
3712
+ this.handlers.onPlayerLeft?.(event.player);
3713
+ }
3714
+ }
3715
+ scheduleReconnect(roomId) {
3716
+ if (this.reconnectAttempts >= (this.config.maxReconnectAttempts ?? 5)) {
3717
+ console.error("Max reconnect attempts reached");
3718
+ return;
3719
+ }
3720
+ const delay = Math.min(
3721
+ (this.config.reconnectInterval ?? 1e3) * Math.pow(2, this.reconnectAttempts),
3722
+ 3e4
3723
+ // Max 30초
3724
+ );
3725
+ this.reconnectAttempts++;
3726
+ this.reconnectTimer = setTimeout(() => {
3727
+ console.log(`Reconnecting... (attempt ${this.reconnectAttempts})`);
3728
+ this.connect(roomId || this._roomId || void 0).catch(() => {
3729
+ });
3730
+ }, delay);
3731
+ }
3732
+ startPingInterval() {
3733
+ this.pingInterval = setInterval(() => {
3734
+ this.ping().catch(() => {
3735
+ });
3736
+ }, 3e4);
3737
+ }
3738
+ stopPingInterval() {
3739
+ if (this.pingInterval) {
3740
+ clearInterval(this.pingInterval);
3741
+ this.pingInterval = null;
3742
+ }
3743
+ }
3744
+ };
3745
+ var GameAPI = class {
3746
+ constructor(http, gameServerUrl) {
3747
+ this.http = http;
3748
+ this.gameServerUrl = gameServerUrl || getDefaultGameServerUrl().replace(/^ws/, "http");
3749
+ }
3750
+ /**
3751
+ * 게임 룸 클라이언트 생성
3752
+ */
3753
+ createClient(config) {
3754
+ return new GameRoom({
3755
+ ...config,
3756
+ gameServerUrl: this.gameServerUrl.replace(/^http/, "ws"),
3757
+ apiKey: this.http.getApiKey(),
3758
+ accessToken: this.http.getAccessToken()
3759
+ });
3760
+ }
3761
+ /**
3762
+ * 룸 목록 조회 (HTTP)
3763
+ */
3764
+ async listRooms(appId) {
3765
+ const response = await fetch(`${this.gameServerUrl}/v1/game/rooms${appId ? `?app_id=${appId}` : ""}`, {
3766
+ headers: this.getHeaders()
3767
+ });
3768
+ if (!response.ok) {
3769
+ throw new Error(`Failed to list rooms: ${response.statusText}`);
3770
+ }
3771
+ const data = await response.json();
3772
+ return data.rooms;
3773
+ }
3774
+ /**
3775
+ * 룸 상세 조회 (HTTP)
3776
+ */
3777
+ async getRoom(roomId) {
3778
+ const response = await fetch(`${this.gameServerUrl}/v1/game/rooms/${roomId}`, {
3779
+ headers: this.getHeaders()
3780
+ });
3781
+ if (!response.ok) {
3782
+ throw new Error(`Failed to get room: ${response.statusText}`);
3783
+ }
3784
+ return response.json();
3785
+ }
3786
+ /**
3787
+ * 룸 생성 (HTTP, gRPC 대안)
3788
+ */
3789
+ async createRoom(appId, config = {}) {
3790
+ const response = await fetch(`${this.gameServerUrl}/v1/game/rooms`, {
3791
+ method: "POST",
3792
+ headers: {
3793
+ ...this.getHeaders(),
3794
+ "Content-Type": "application/json"
3795
+ },
3796
+ body: JSON.stringify({
3797
+ app_id: appId,
3798
+ category_id: config.categoryId,
3799
+ room_id: config.roomId,
3800
+ tick_rate: config.tickRate,
3801
+ max_players: config.maxPlayers,
3802
+ metadata: config.metadata
3803
+ })
3804
+ });
3805
+ if (!response.ok) {
3806
+ throw new Error(`Failed to create room: ${response.statusText}`);
3807
+ }
3808
+ return response.json();
3809
+ }
3810
+ /**
3811
+ * 룸 삭제 (HTTP)
3812
+ */
3813
+ async deleteRoom(roomId) {
3814
+ const response = await fetch(`${this.gameServerUrl}/v1/game/rooms/${roomId}`, {
3815
+ method: "DELETE",
3816
+ headers: this.getHeaders()
3817
+ });
3818
+ if (!response.ok) {
3819
+ throw new Error(`Failed to delete room: ${response.statusText}`);
3820
+ }
3821
+ }
3822
+ getHeaders() {
3823
+ const headers = {};
3824
+ const apiKey = this.http.getApiKey();
3825
+ if (apiKey) {
3826
+ headers["X-API-Key"] = apiKey;
3827
+ }
3828
+ const accessToken = this.http.getAccessToken();
3829
+ if (accessToken) {
3830
+ headers["Authorization"] = `Bearer ${accessToken}`;
3831
+ }
3832
+ return headers;
3833
+ }
3834
+ };
3835
+
3836
+ // src/api/game-transport.ts
3837
+ var WebTransportTransport = class {
3838
+ constructor(config, onMessage, onClose, onError) {
3839
+ this.type = "webtransport";
3840
+ this.transport = null;
3841
+ this.writer = null;
3842
+ this.config = config;
3843
+ this.onMessage = onMessage;
3844
+ this.onClose = onClose;
3845
+ this.onError = onError;
3846
+ }
3847
+ async connect() {
3848
+ const url = this.buildUrl();
3849
+ this.transport = new WebTransport(url);
3850
+ await this.transport.ready;
3851
+ if (this.config.useUnreliableDatagrams !== false) {
3852
+ this.readDatagrams();
3853
+ }
3854
+ const stream = await this.transport.createBidirectionalStream();
3855
+ this.writer = stream.writable.getWriter();
3856
+ this.readStream(stream.readable);
3857
+ this.transport.closed.then(() => {
3858
+ this.onClose();
3859
+ }).catch((error) => {
3860
+ this.onError(error);
3861
+ });
3862
+ }
3863
+ buildUrl() {
3864
+ const baseUrl = this.config.gameServerUrl || "https://game.connectbase.world";
3865
+ const httpsUrl = baseUrl.replace(/^ws/, "http").replace(/^http:/, "https:");
3866
+ const params = new URLSearchParams();
3867
+ params.set("client_id", this.config.clientId);
3868
+ if (this.config.apiKey) {
3869
+ params.set("api_key", this.config.apiKey);
3870
+ }
3871
+ if (this.config.accessToken) {
3872
+ params.set("token", this.config.accessToken);
3873
+ }
3874
+ return `${httpsUrl}/v1/game/webtransport?${params.toString()}`;
3875
+ }
3876
+ async readDatagrams() {
3877
+ if (!this.transport) return;
3878
+ const reader = this.transport.datagrams.readable.getReader();
3879
+ try {
3880
+ while (true) {
3881
+ const { value, done } = await reader.read();
3882
+ if (done) break;
3883
+ this.onMessage(value);
3884
+ }
3885
+ } catch (error) {
3886
+ }
3887
+ }
3888
+ async readStream(readable) {
3889
+ const reader = readable.getReader();
3890
+ let buffer = new Uint8Array(0);
3891
+ try {
3892
+ while (true) {
3893
+ const { value, done } = await reader.read();
3894
+ if (done) break;
3895
+ const newBuffer = new Uint8Array(buffer.length + value.length);
3896
+ newBuffer.set(buffer);
3897
+ newBuffer.set(value, buffer.length);
3898
+ buffer = newBuffer;
3899
+ while (buffer.length >= 4) {
3900
+ const length = new DataView(buffer.buffer).getUint32(0, true);
3901
+ if (buffer.length < 4 + length) break;
3902
+ const message = buffer.slice(4, 4 + length);
3903
+ buffer = buffer.slice(4 + length);
3904
+ this.onMessage(message);
3905
+ }
3906
+ }
3907
+ } catch (error) {
3908
+ }
3909
+ }
3910
+ disconnect() {
3911
+ if (this.transport) {
3912
+ this.transport.close();
3913
+ this.transport = null;
3914
+ this.writer = null;
3915
+ }
3916
+ }
3917
+ send(data, reliable = true) {
3918
+ if (!this.transport) {
3919
+ throw new Error("Not connected");
3920
+ }
3921
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
3922
+ if (reliable) {
3923
+ if (this.writer) {
3924
+ const lengthPrefix = new Uint8Array(4);
3925
+ new DataView(lengthPrefix.buffer).setUint32(0, bytes.length, true);
3926
+ const prefixed = new Uint8Array(4 + bytes.length);
3927
+ prefixed.set(lengthPrefix);
3928
+ prefixed.set(bytes, 4);
3929
+ this.writer.write(prefixed);
3930
+ }
3931
+ } else {
3932
+ const maxSize = this.config.maxDatagramSize || 1200;
3933
+ if (bytes.length <= maxSize) {
3934
+ this.transport.datagrams.writable.getWriter().write(bytes);
3935
+ } else {
3936
+ console.warn("Datagram too large, falling back to reliable stream");
3937
+ this.send(data, true);
3938
+ }
3939
+ }
3940
+ }
3941
+ isConnected() {
3942
+ return this.transport !== null;
3943
+ }
3944
+ };
3945
+ var WebSocketTransport = class {
3946
+ constructor(config, onMessage, onClose, onError) {
3947
+ this.type = "websocket";
3948
+ this.ws = null;
3949
+ this.config = config;
3950
+ this.onMessage = onMessage;
3951
+ this.onClose = onClose;
3952
+ this.onError = onError;
3953
+ }
3954
+ connect() {
3955
+ return new Promise((resolve, reject) => {
3956
+ const url = this.buildUrl();
3957
+ try {
3958
+ this.ws = new WebSocket(url);
3959
+ this.ws.binaryType = "arraybuffer";
3960
+ } catch (error) {
3961
+ reject(error);
3962
+ return;
3963
+ }
3964
+ const onOpen = () => {
3965
+ resolve();
3966
+ };
3967
+ const onClose = () => {
3968
+ this.onClose();
3969
+ };
3970
+ const onError = (_event) => {
3971
+ const error = new Error("WebSocket error");
3972
+ this.onError(error);
3973
+ reject(error);
3974
+ };
3975
+ const onMessage = (event) => {
3976
+ if (event.data instanceof ArrayBuffer) {
3977
+ this.onMessage(new Uint8Array(event.data));
3978
+ } else if (typeof event.data === "string") {
3979
+ this.onMessage(new TextEncoder().encode(event.data));
3980
+ }
3981
+ };
3982
+ this.ws.addEventListener("open", onOpen, { once: true });
3983
+ this.ws.addEventListener("close", onClose);
3984
+ this.ws.addEventListener("error", onError, { once: true });
3985
+ this.ws.addEventListener("message", onMessage);
3986
+ });
3987
+ }
3988
+ buildUrl() {
3989
+ const baseUrl = this.config.gameServerUrl || "wss://game.connectbase.world";
3990
+ const wsUrl = baseUrl.replace(/^http/, "ws");
3991
+ const params = new URLSearchParams();
3992
+ params.set("client_id", this.config.clientId);
3993
+ if (this.config.apiKey) {
3994
+ params.set("api_key", this.config.apiKey);
3995
+ }
3996
+ if (this.config.accessToken) {
3997
+ params.set("token", this.config.accessToken);
3998
+ }
3999
+ return `${wsUrl}/v1/game/ws?${params.toString()}`;
4000
+ }
4001
+ disconnect() {
4002
+ if (this.ws) {
4003
+ this.ws.close(1e3, "Client disconnected");
4004
+ this.ws = null;
4005
+ }
4006
+ }
4007
+ send(data, _reliable) {
4008
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
4009
+ throw new Error("Not connected");
4010
+ }
4011
+ if (typeof data === "string") {
4012
+ this.ws.send(data);
4013
+ } else {
4014
+ this.ws.send(data);
4015
+ }
4016
+ }
4017
+ isConnected() {
4018
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
4019
+ }
4020
+ };
4021
+ function isWebTransportSupported() {
4022
+ return typeof WebTransport !== "undefined";
4023
+ }
4024
+ var GameRoomTransport = class {
4025
+ constructor(config) {
4026
+ this.transport = null;
4027
+ this.handlers = {};
4028
+ this.reconnectAttempts = 0;
4029
+ this.reconnectTimer = null;
4030
+ this.pingInterval = null;
4031
+ this.actionSequence = 0;
4032
+ this._roomId = null;
4033
+ this._state = null;
4034
+ this._isConnected = false;
4035
+ this._connectionStatus = "disconnected";
4036
+ this._lastError = null;
4037
+ this._latency = 0;
4038
+ this._transportType = "websocket";
4039
+ this.decoder = new TextDecoder();
4040
+ this.pendingHandlers = /* @__PURE__ */ new Map();
4041
+ this.messageId = 0;
4042
+ this.config = {
4043
+ gameServerUrl: this.getDefaultGameServerUrl(),
4044
+ autoReconnect: true,
4045
+ maxReconnectAttempts: 5,
4046
+ reconnectInterval: 1e3,
4047
+ connectionTimeout: 1e4,
4048
+ transport: "auto",
4049
+ useUnreliableDatagrams: true,
4050
+ ...config
4051
+ };
4052
+ }
4053
+ getDefaultGameServerUrl() {
4054
+ if (typeof window !== "undefined") {
4055
+ const hostname = window.location.hostname;
4056
+ if (hostname === "localhost" || hostname === "127.0.0.1") {
4057
+ return "ws://localhost:8087";
4058
+ }
4059
+ }
4060
+ return "wss://game.connectbase.world";
4061
+ }
4062
+ /**
4063
+ * Current transport type being used
4064
+ */
4065
+ get transportType() {
4066
+ return this._transportType;
4067
+ }
4068
+ /**
4069
+ * Current room ID
4070
+ */
4071
+ get roomId() {
4072
+ return this._roomId;
4073
+ }
4074
+ /**
4075
+ * Current game state
4076
+ */
4077
+ get state() {
4078
+ return this._state;
4079
+ }
4080
+ /**
4081
+ * Connection status
4082
+ */
4083
+ get isConnected() {
4084
+ return this._isConnected;
4085
+ }
4086
+ /**
4087
+ * Connection state information
4088
+ */
4089
+ get connectionState() {
4090
+ return {
4091
+ status: this._connectionStatus,
4092
+ transport: this._transportType === "auto" ? null : this._transportType,
4093
+ roomId: this._roomId,
4094
+ latency: this._latency,
4095
+ reconnectAttempt: this.reconnectAttempts,
4096
+ lastError: this._lastError || void 0
4097
+ };
4098
+ }
4099
+ /**
4100
+ * Current latency (ms)
4101
+ */
4102
+ get latency() {
4103
+ return this._latency;
4104
+ }
4105
+ /**
4106
+ * Register event handler
4107
+ */
4108
+ on(event, handler) {
4109
+ this.handlers[event] = handler;
4110
+ return this;
4111
+ }
4112
+ /**
4113
+ * Connect to game server
4114
+ */
4115
+ async connect(roomId) {
4116
+ if (this.transport?.isConnected()) {
4117
+ return;
4118
+ }
4119
+ this._connectionStatus = this.reconnectAttempts > 0 ? "reconnecting" : "connecting";
4120
+ const preferredTransport = this.config.transport || "auto";
4121
+ const useWebTransport = (preferredTransport === "webtransport" || preferredTransport === "auto") && isWebTransportSupported();
4122
+ const onMessage = (data) => {
4123
+ this.handleMessage(this.decoder.decode(data));
4124
+ };
4125
+ const onClose = () => {
4126
+ this._isConnected = false;
4127
+ this._connectionStatus = "disconnected";
4128
+ this.stopPingInterval();
4129
+ this.handlers.onDisconnect?.(new CloseEvent("close"));
4130
+ if (this.config.autoReconnect) {
4131
+ this._connectionStatus = "reconnecting";
4132
+ this.scheduleReconnect(roomId);
4133
+ }
4134
+ };
4135
+ const onError = (error) => {
4136
+ this._connectionStatus = "error";
4137
+ this._lastError = error;
4138
+ this.handlers.onError?.(error);
4139
+ };
4140
+ if (useWebTransport) {
4141
+ try {
4142
+ this.transport = new WebTransportTransport(
4143
+ this.config,
4144
+ onMessage,
4145
+ onClose,
4146
+ onError
4147
+ );
4148
+ await this.transport.connect();
4149
+ this._transportType = "webtransport";
4150
+ } catch (error) {
4151
+ console.log("WebTransport failed, falling back to WebSocket");
4152
+ this.transport = new WebSocketTransport(
4153
+ this.config,
4154
+ onMessage,
4155
+ onClose,
4156
+ onError
4157
+ );
4158
+ await this.transport.connect();
4159
+ this._transportType = "websocket";
4160
+ }
4161
+ } else {
4162
+ this.transport = new WebSocketTransport(
4163
+ this.config,
4164
+ onMessage,
4165
+ onClose,
4166
+ onError
4167
+ );
4168
+ await this.transport.connect();
4169
+ this._transportType = "websocket";
4170
+ }
4171
+ this._isConnected = true;
4172
+ this._connectionStatus = "connected";
4173
+ this._lastError = null;
4174
+ this.reconnectAttempts = 0;
4175
+ this.startPingInterval();
4176
+ this.handlers.onConnect?.();
4177
+ if (roomId) {
4178
+ await this.joinRoom(roomId);
4179
+ }
4180
+ }
4181
+ /**
4182
+ * Disconnect from server
4183
+ */
4184
+ disconnect() {
4185
+ this.stopPingInterval();
4186
+ if (this.reconnectTimer) {
4187
+ clearTimeout(this.reconnectTimer);
4188
+ this.reconnectTimer = null;
4189
+ }
4190
+ if (this.transport) {
4191
+ this.transport.disconnect();
4192
+ this.transport = null;
4193
+ }
4194
+ this._isConnected = false;
4195
+ this._connectionStatus = "disconnected";
4196
+ this._roomId = null;
4197
+ this._state = null;
4198
+ }
4199
+ /**
4200
+ * Create a new room
4201
+ */
4202
+ async createRoom(config = {}) {
4203
+ return new Promise((resolve, reject) => {
4204
+ const handler = (msg) => {
4205
+ if (msg.type === "room_created") {
4206
+ const data = msg.data;
4207
+ this._roomId = data.room_id;
4208
+ this._state = data.initial_state;
4209
+ resolve(data.initial_state);
4210
+ } else if (msg.type === "error") {
4211
+ reject(new Error(msg.data.message));
4212
+ }
4213
+ };
4214
+ this.sendWithHandler("create_room", config, handler);
4215
+ });
4216
+ }
4217
+ /**
4218
+ * Join an existing room
4219
+ */
4220
+ async joinRoom(roomId, metadata) {
4221
+ return new Promise((resolve, reject) => {
4222
+ const handler = (msg) => {
4223
+ if (msg.type === "room_joined") {
4224
+ const data = msg.data;
4225
+ this._roomId = data.room_id;
4226
+ this._state = data.initial_state;
4227
+ resolve(data.initial_state);
4228
+ } else if (msg.type === "error") {
4229
+ reject(new Error(msg.data.message));
4230
+ }
4231
+ };
4232
+ this.sendWithHandler("join_room", { room_id: roomId, metadata }, handler);
4233
+ });
4234
+ }
4235
+ /**
4236
+ * Leave current room
4237
+ */
4238
+ async leaveRoom() {
4239
+ return new Promise((resolve, reject) => {
4240
+ if (!this._roomId) {
4241
+ reject(new Error("Not in a room"));
4242
+ return;
4243
+ }
4244
+ const handler = (msg) => {
4245
+ if (msg.type === "room_left") {
4246
+ this._roomId = null;
4247
+ this._state = null;
4248
+ resolve();
4249
+ } else if (msg.type === "error") {
4250
+ reject(new Error(msg.data.message));
4251
+ }
4252
+ };
4253
+ this.sendWithHandler("leave_room", {}, handler);
4254
+ });
4255
+ }
4256
+ /**
4257
+ * Send game action
4258
+ * Uses unreliable transport (datagrams) when WebTransport is available
4259
+ */
4260
+ sendAction(action, reliable = false) {
4261
+ if (!this._roomId) {
4262
+ throw new Error("Not in a room");
4263
+ }
4264
+ const message = JSON.stringify({
4265
+ type: "action",
4266
+ data: {
4267
+ type: action.type,
4268
+ data: action.data,
4269
+ client_timestamp: action.clientTimestamp ?? Date.now(),
4270
+ sequence: this.actionSequence++
4271
+ }
4272
+ });
4273
+ const useReliable = reliable || this._transportType !== "webtransport";
4274
+ this.transport?.send(message, useReliable);
4275
+ }
4276
+ /**
4277
+ * Send chat message
4278
+ */
4279
+ sendChat(message) {
4280
+ if (!this._roomId) {
4281
+ throw new Error("Not in a room");
4282
+ }
4283
+ this.send("chat", { message });
4284
+ }
4285
+ /**
4286
+ * Request current state
4287
+ */
4288
+ async requestState() {
4289
+ return new Promise((resolve, reject) => {
4290
+ if (!this._roomId) {
4291
+ reject(new Error("Not in a room"));
4292
+ return;
4293
+ }
4294
+ const handler = (msg) => {
4295
+ if (msg.type === "state") {
4296
+ const state = msg.data;
4297
+ this._state = state;
4298
+ resolve(state);
4299
+ } else if (msg.type === "error") {
4300
+ reject(new Error(msg.data.message));
4301
+ }
4302
+ };
4303
+ this.sendWithHandler("get_state", {}, handler);
4304
+ });
4305
+ }
4306
+ /**
4307
+ * Ping server for latency measurement
4308
+ */
4309
+ async ping() {
4310
+ return new Promise((resolve, reject) => {
4311
+ const timestamp = Date.now();
4312
+ const handler = (msg) => {
4313
+ if (msg.type === "pong") {
4314
+ const pong = msg.data;
4315
+ const rtt = Date.now() - pong.clientTimestamp;
4316
+ this._latency = rtt;
4317
+ this.handlers.onPong?.(pong);
4318
+ resolve(rtt);
4319
+ } else if (msg.type === "error") {
4320
+ reject(new Error(msg.data.message));
4321
+ }
4322
+ };
4323
+ this.sendWithHandler("ping", { timestamp }, handler);
4324
+ });
4325
+ }
4326
+ // Private methods
4327
+ send(type, data) {
4328
+ if (!this.transport?.isConnected()) {
4329
+ throw new Error("Not connected");
4330
+ }
4331
+ const message = JSON.stringify({ type, data });
4332
+ this.transport.send(message, true);
4333
+ }
4334
+ sendWithHandler(type, data, handler) {
4335
+ const id = `msg_${this.messageId++}`;
4336
+ this.pendingHandlers.set(id, handler);
4337
+ setTimeout(() => {
4338
+ this.pendingHandlers.delete(id);
4339
+ }, 1e4);
4340
+ this.send(type, { ...data, _msg_id: id });
4341
+ }
4342
+ handleMessage(data) {
4343
+ try {
4344
+ const msg = JSON.parse(data);
4345
+ if (msg._msg_id && this.pendingHandlers.has(msg._msg_id)) {
4346
+ const handler = this.pendingHandlers.get(msg._msg_id);
4347
+ this.pendingHandlers.delete(msg._msg_id);
4348
+ handler(msg);
4349
+ return;
4350
+ }
4351
+ switch (msg.type) {
4352
+ case "delta":
4353
+ this.handleDelta(msg.data);
4354
+ break;
4355
+ case "state":
4356
+ this._state = msg.data;
4357
+ this.handlers.onStateUpdate?.(this._state);
4358
+ break;
4359
+ case "player_event":
4360
+ this.handlePlayerEvent(msg.data);
4361
+ break;
4362
+ case "chat":
4363
+ this.handlers.onChat?.(msg.data);
4364
+ break;
4365
+ case "error":
4366
+ this.handlers.onError?.(msg.data);
4367
+ break;
4368
+ }
4369
+ } catch {
4370
+ console.error("Failed to parse game message:", data);
4371
+ }
4372
+ }
4373
+ handleDelta(data) {
4374
+ const delta = data.delta;
4375
+ if (this._state) {
4376
+ for (const change of delta.changes) {
4377
+ this.applyChange(change);
4378
+ }
4379
+ this._state.version = delta.toVersion;
4380
+ }
4381
+ this.handlers.onDelta?.(delta);
4382
+ }
4383
+ applyChange(change) {
4384
+ if (!this._state) return;
4385
+ const path = change.path.split(".");
4386
+ let current = this._state.state;
4387
+ for (let i = 0; i < path.length - 1; i++) {
4388
+ const key = path[i];
4389
+ if (!(key in current)) {
4390
+ current[key] = {};
4391
+ }
4392
+ current = current[key];
4393
+ }
4394
+ const lastKey = path[path.length - 1];
4395
+ if (change.operation === "delete") {
4396
+ delete current[lastKey];
4397
+ } else {
4398
+ current[lastKey] = change.value;
4399
+ }
4400
+ }
4401
+ handlePlayerEvent(event) {
4402
+ if (event.event === "joined") {
4403
+ this.handlers.onPlayerJoined?.(event.player);
4404
+ } else if (event.event === "left") {
4405
+ this.handlers.onPlayerLeft?.(event.player);
4406
+ }
4407
+ }
4408
+ scheduleReconnect(roomId) {
4409
+ if (this.reconnectAttempts >= (this.config.maxReconnectAttempts ?? 5)) {
4410
+ console.error("Max reconnect attempts reached");
4411
+ return;
4412
+ }
4413
+ const delay = Math.min(
4414
+ (this.config.reconnectInterval ?? 1e3) * Math.pow(2, this.reconnectAttempts),
4415
+ 3e4
4416
+ );
4417
+ this.reconnectAttempts++;
4418
+ this.reconnectTimer = setTimeout(() => {
4419
+ console.log(`Reconnecting... (attempt ${this.reconnectAttempts})`);
4420
+ this.connect(roomId || this._roomId || void 0).catch(() => {
4421
+ });
4422
+ }, delay);
4423
+ }
4424
+ startPingInterval() {
4425
+ this.pingInterval = setInterval(() => {
4426
+ this.ping().catch(() => {
4427
+ });
4428
+ }, 3e4);
4429
+ }
4430
+ stopPingInterval() {
4431
+ if (this.pingInterval) {
4432
+ clearInterval(this.pingInterval);
4433
+ this.pingInterval = null;
4434
+ }
4435
+ }
4436
+ };
4437
+
4438
+ // src/index.ts
4439
+ var getDefaultVideoUrl = () => {
4440
+ if (typeof window !== "undefined") {
4441
+ const hostname = window.location.hostname;
4442
+ if (hostname === "localhost" || hostname === "127.0.0.1") {
4443
+ return "http://localhost:8089";
4444
+ }
4445
+ }
4446
+ return "https://video.connectbase.world";
4447
+ };
4448
+ var getDefaultBaseUrl = () => {
4449
+ if (typeof window !== "undefined") {
4450
+ const hostname = window.location.hostname;
4451
+ if (hostname === "localhost" || hostname === "127.0.0.1") {
4452
+ return "http://localhost:8080";
4453
+ }
4454
+ }
4455
+ return "https://api.connectbase.world";
4456
+ };
4457
+ var getDefaultSocketUrl = () => {
4458
+ if (typeof window !== "undefined") {
4459
+ const hostname = window.location.hostname;
4460
+ if (hostname === "localhost" || hostname === "127.0.0.1") {
4461
+ return "http://localhost:8083";
4462
+ }
4463
+ }
4464
+ return "https://socket.connectbase.world";
4465
+ };
4466
+ var getDefaultWebRTCUrl = () => {
4467
+ if (typeof window !== "undefined") {
4468
+ const hostname = window.location.hostname;
4469
+ if (hostname === "localhost" || hostname === "127.0.0.1") {
4470
+ return "http://localhost:8086";
4471
+ }
4472
+ }
4473
+ return "https://webrtc.connectbase.world";
4474
+ };
4475
+ var getDefaultGameUrl = () => {
4476
+ if (typeof window !== "undefined") {
4477
+ const hostname = window.location.hostname;
4478
+ if (hostname === "localhost" || hostname === "127.0.0.1") {
4479
+ return "http://localhost:8087";
4480
+ }
4481
+ }
4482
+ return "https://game.connectbase.world";
4483
+ };
4484
+ var DEFAULT_BASE_URL = getDefaultBaseUrl();
4485
+ var DEFAULT_SOCKET_URL = getDefaultSocketUrl();
4486
+ var DEFAULT_WEBRTC_URL = getDefaultWebRTCUrl();
4487
+ var DEFAULT_VIDEO_URL = getDefaultVideoUrl();
4488
+ var DEFAULT_GAME_URL = getDefaultGameUrl();
4489
+ var ConnectBase = class {
4490
+ constructor(config = {}) {
4491
+ const httpConfig = {
4492
+ baseUrl: config.baseUrl || DEFAULT_BASE_URL,
4493
+ apiKey: config.apiKey,
4494
+ onTokenRefresh: config.onTokenRefresh,
4495
+ onAuthError: config.onAuthError
4496
+ };
4497
+ this.http = new HttpClient(httpConfig);
4498
+ this.auth = new AuthAPI(this.http);
4499
+ this.database = new DatabaseAPI(this.http);
4500
+ this.storage = new StorageAPI(this.http);
4501
+ this.apiKey = new ApiKeyAPI(this.http);
4502
+ this.functions = new FunctionsAPI(this.http);
4503
+ this.realtime = new RealtimeAPI(this.http, config.socketUrl || DEFAULT_SOCKET_URL);
4504
+ this.webrtc = new WebRTCAPI(this.http, config.webrtcUrl || DEFAULT_WEBRTC_URL);
4505
+ this.errorTracker = new ErrorTrackerAPI(this.http, config.errorTracker);
4506
+ this.oauth = new OAuthAPI(this.http);
4507
+ this.payment = new PaymentAPI(this.http);
4508
+ this.subscription = new SubscriptionAPI(this.http);
4509
+ this.push = new PushAPI(this.http);
4510
+ this.video = new VideoAPI(this.http, config.videoUrl || DEFAULT_VIDEO_URL);
4511
+ this.game = new GameAPI(this.http, config.gameUrl || DEFAULT_GAME_URL);
4512
+ }
4513
+ /**
4514
+ * 수동으로 토큰 설정 (기존 토큰으로 세션 복원 시)
4515
+ */
4516
+ setTokens(accessToken, refreshToken) {
4517
+ this.http.setTokens(accessToken, refreshToken);
4518
+ }
4519
+ /**
4520
+ * 토큰 제거
4521
+ */
4522
+ clearTokens() {
4523
+ this.http.clearTokens();
4524
+ }
4525
+ /**
4526
+ * 설정 업데이트
4527
+ */
4528
+ updateConfig(config) {
4529
+ this.http.updateConfig(config);
4530
+ }
4531
+ };
4532
+ var index_default = ConnectBase;
4533
+ export {
4534
+ ApiError,
4535
+ AuthError,
4536
+ ConnectBase,
4537
+ GameAPI,
4538
+ GameRoom,
4539
+ GameRoomTransport,
4540
+ VideoProcessingError,
4541
+ index_default as default,
4542
+ isWebTransportSupported
4543
+ };