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/README.md +565 -0
- package/dist/connect-base.umd.js +3 -0
- package/dist/index.d.mts +3346 -0
- package/dist/index.d.ts +3346 -0
- package/dist/index.js +4577 -0
- package/dist/index.mjs +4543 -0
- package/package.json +50 -0
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
|
+
});
|