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