connectbase-client 1.8.1 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -53,12 +53,50 @@ var AuthError = class extends Error {
53
53
  }
54
54
  };
55
55
 
56
+ // src/core/abort.ts
57
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
58
+ function createTimeoutController(options = {}) {
59
+ const controller = new AbortController();
60
+ const timeout = options.timeout ?? DEFAULT_REQUEST_TIMEOUT_MS;
61
+ const external = options.signal;
62
+ let timeoutId = null;
63
+ let externalListener = null;
64
+ if (external) {
65
+ if (external.aborted) {
66
+ controller.abort(external.reason);
67
+ } else {
68
+ externalListener = () => controller.abort(external.reason);
69
+ external.addEventListener("abort", externalListener, { once: true });
70
+ }
71
+ }
72
+ if (timeout > 0 && Number.isFinite(timeout)) {
73
+ timeoutId = setTimeout(() => {
74
+ controller.abort(
75
+ new DOMException(`Request timed out after ${timeout}ms`, "TimeoutError")
76
+ );
77
+ }, timeout);
78
+ }
79
+ return {
80
+ signal: controller.signal,
81
+ cleanup: () => {
82
+ if (timeoutId !== null) clearTimeout(timeoutId);
83
+ if (external && externalListener) {
84
+ external.removeEventListener("abort", externalListener);
85
+ }
86
+ }
87
+ };
88
+ }
89
+
56
90
  // src/core/http.ts
57
91
  var TOKEN_STORAGE_KEY = "cb_auth_tokens";
58
92
  var HttpClient = class {
59
93
  constructor(config) {
60
94
  this.isRefreshing = false;
61
95
  this.refreshPromise = null;
96
+ // 연속 refresh 실패 시 지수 백오프 — 같은 세션이 스피너 UI 로 묶였을 때
97
+ // 밀리초 단위로 서버에 재시도 요청이 쏟아지는 것을 차단.
98
+ this.refreshFailureCount = 0;
99
+ this.refreshLockedUntil = 0;
62
100
  this.config = { ...config };
63
101
  this.storageKey = this.buildStorageKey();
64
102
  this.warnIfUnsafePersistence();
@@ -200,22 +238,34 @@ var HttpClient = class {
200
238
  if (this.isRefreshing) {
201
239
  return this.refreshPromise;
202
240
  }
241
+ const now = Date.now();
242
+ if (now < this.refreshLockedUntil) {
243
+ const error = new AuthError("Token refresh locked due to repeated failures. Please login again.");
244
+ this.emitError(error);
245
+ this.config.onAuthError?.(error);
246
+ throw error;
247
+ }
203
248
  this.isRefreshing = true;
204
249
  if (!this.config.refreshToken) {
205
250
  this.isRefreshing = false;
206
251
  this.config.onTokenExpired?.();
207
252
  const error = new AuthError("Refresh token is missing. Please login again.");
253
+ this.emitError(error);
208
254
  this.config.onAuthError?.(error);
209
255
  throw error;
210
256
  }
211
257
  this.refreshPromise = (async () => {
258
+ const { signal, cleanup } = createTimeoutController({
259
+ timeout: this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
260
+ });
212
261
  try {
213
262
  const response = await fetch(`${this.config.baseUrl}/v1/auth/re-issue`, {
214
263
  method: "POST",
215
264
  headers: {
216
265
  "Content-Type": "application/json",
217
266
  "Authorization": `Bearer ${this.config.refreshToken}`
218
- }
267
+ },
268
+ signal
219
269
  });
220
270
  if (!response.ok) {
221
271
  throw new Error("Token refresh failed");
@@ -226,20 +276,36 @@ var HttpClient = class {
226
276
  accessToken: data.access_token,
227
277
  refreshToken: data.refresh_token
228
278
  });
279
+ this.refreshFailureCount = 0;
280
+ this.refreshLockedUntil = 0;
229
281
  return data.access_token;
230
282
  } catch {
283
+ this.refreshFailureCount++;
284
+ const backoffMs = Math.min(
285
+ 500 * 2 ** Math.max(0, this.refreshFailureCount - 1),
286
+ 3e4
287
+ );
288
+ this.refreshLockedUntil = Date.now() + backoffMs;
231
289
  this.clearTokens();
232
290
  this.config.onTokenExpired?.();
233
291
  const error = new AuthError("Token refresh failed. Please login again.");
292
+ this.emitError(error);
234
293
  this.config.onAuthError?.(error);
235
294
  throw error;
236
295
  } finally {
296
+ cleanup();
237
297
  this.isRefreshing = false;
238
298
  this.refreshPromise = null;
239
299
  }
240
300
  })();
241
301
  return this.refreshPromise;
242
302
  }
303
+ emitError(error) {
304
+ try {
305
+ this.config.onError?.(error);
306
+ } catch {
307
+ }
308
+ }
243
309
  isTokenExpired(token) {
244
310
  try {
245
311
  const payload = JSON.parse(atob(token.split(".")[1]));
@@ -278,72 +344,119 @@ var HttpClient = class {
278
344
  const errorData = await response.json().catch(() => ({
279
345
  message: response.statusText
280
346
  }));
347
+ const retryAfterHeader = response.status === 429 ? response.headers.get("Retry-After") : null;
348
+ let retryAfterSeconds;
349
+ if (retryAfterHeader) {
350
+ const asInt = Number.parseInt(retryAfterHeader, 10);
351
+ if (Number.isFinite(asInt) && asInt >= 0) {
352
+ retryAfterSeconds = asInt;
353
+ } else {
354
+ const dateMs = Date.parse(retryAfterHeader);
355
+ if (Number.isFinite(dateMs)) {
356
+ retryAfterSeconds = Math.max(
357
+ 0,
358
+ Math.round((dateMs - Date.now()) / 1e3)
359
+ );
360
+ }
361
+ }
362
+ }
281
363
  const rawError = errorData.error;
282
364
  if (rawError && typeof rawError === "object" && "message" in rawError) {
283
- throw new ApiError(
365
+ const details = {
366
+ ...rawError.details && typeof rawError.details === "object" ? rawError.details : {}
367
+ };
368
+ if (retryAfterSeconds !== void 0) {
369
+ details.retry_after_seconds = retryAfterSeconds;
370
+ }
371
+ const err2 = new ApiError(
284
372
  response.status,
285
373
  rawError.message || "Unknown error",
286
374
  rawError.code,
287
- rawError.details
375
+ Object.keys(details).length > 0 ? details : rawError.details
288
376
  );
377
+ this.emitError(err2);
378
+ throw err2;
289
379
  }
290
380
  const message = typeof rawError === "string" ? rawError : errorData.message || "Unknown error";
291
- throw new ApiError(response.status, message);
381
+ const legacyDetails = retryAfterSeconds !== void 0 ? { retry_after_seconds: retryAfterSeconds } : void 0;
382
+ const err = new ApiError(response.status, message, void 0, legacyDetails);
383
+ this.emitError(err);
384
+ throw err;
292
385
  }
293
386
  if (response.status === 204 || response.headers.get("content-length") === "0") {
294
387
  return {};
295
388
  }
296
389
  return response.json();
297
390
  }
391
+ /**
392
+ * AbortController 를 관리하며 fetch 호출을 실행. 타임아웃/외부 signal 병합.
393
+ */
394
+ async doFetch(url, init, config) {
395
+ const { signal, cleanup } = createTimeoutController({
396
+ timeout: config?.timeout ?? this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
397
+ signal: config?.signal
398
+ });
399
+ try {
400
+ const response = await fetch(`${this.config.baseUrl}${url}`, {
401
+ ...init,
402
+ signal
403
+ });
404
+ return await this.handleResponse(response);
405
+ } finally {
406
+ cleanup();
407
+ }
408
+ }
298
409
  async get(url, config) {
299
410
  const headers = await this.prepareHeaders(config);
300
- const response = await fetch(`${this.config.baseUrl}${url}`, {
301
- method: "GET",
302
- headers
303
- });
304
- return this.handleResponse(response);
411
+ return this.doFetch(url, { method: "GET", headers }, config);
305
412
  }
306
413
  async post(url, data, config) {
307
414
  const headers = await this.prepareHeaders(config);
308
415
  if (data instanceof FormData) {
309
416
  headers.delete("Content-Type");
310
417
  }
311
- const response = await fetch(`${this.config.baseUrl}${url}`, {
312
- method: "POST",
313
- headers,
314
- body: data instanceof FormData ? data : JSON.stringify(data)
315
- });
316
- return this.handleResponse(response);
418
+ return this.doFetch(
419
+ url,
420
+ {
421
+ method: "POST",
422
+ headers,
423
+ body: data instanceof FormData ? data : JSON.stringify(data)
424
+ },
425
+ config
426
+ );
317
427
  }
318
428
  async put(url, data, config) {
319
429
  const headers = await this.prepareHeaders(config);
320
- const response = await fetch(`${this.config.baseUrl}${url}`, {
321
- method: "PUT",
322
- headers,
323
- body: JSON.stringify(data)
324
- });
325
- return this.handleResponse(response);
430
+ return this.doFetch(
431
+ url,
432
+ {
433
+ method: "PUT",
434
+ headers,
435
+ body: JSON.stringify(data)
436
+ },
437
+ config
438
+ );
326
439
  }
327
440
  async patch(url, data, config) {
328
441
  const headers = await this.prepareHeaders(config);
329
- const response = await fetch(`${this.config.baseUrl}${url}`, {
330
- method: "PATCH",
331
- headers,
332
- body: JSON.stringify(data)
333
- });
334
- return this.handleResponse(response);
442
+ return this.doFetch(
443
+ url,
444
+ {
445
+ method: "PATCH",
446
+ headers,
447
+ body: JSON.stringify(data)
448
+ },
449
+ config
450
+ );
335
451
  }
336
452
  async delete(url, config) {
337
453
  const headers = await this.prepareHeaders(config);
338
- const response = await fetch(`${this.config.baseUrl}${url}`, {
339
- method: "DELETE",
340
- headers
341
- });
342
- return this.handleResponse(response);
454
+ return this.doFetch(url, { method: "DELETE", headers }, config);
343
455
  }
344
456
  /**
345
457
  * Raw fetch 요청 (SSE 스트리밍 등에 사용)
346
- * 인증 헤더가 자동으로 추가됩니다.
458
+ * 인증 헤더가 자동으로 추가됩니다. timeout 은 호출자가 직접 signal 로 관리해야 합니다
459
+ * (스트리밍 특성상 전역 timeout 을 강제하지 않음).
347
460
  */
348
461
  async fetchRaw(url, init) {
349
462
  const headers = await this.prepareHeaders();
@@ -359,6 +472,57 @@ var HttpClient = class {
359
472
  }
360
473
  };
361
474
 
475
+ // src/core/validate.ts
476
+ function checkType(value, type) {
477
+ switch (type) {
478
+ case "string":
479
+ return typeof value === "string";
480
+ case "number":
481
+ return typeof value === "number" && Number.isFinite(value);
482
+ case "boolean":
483
+ return typeof value === "boolean";
484
+ case "array":
485
+ return Array.isArray(value);
486
+ case "object":
487
+ return typeof value === "object" && value !== null && !Array.isArray(value);
488
+ case "string-or-number":
489
+ return typeof value === "string" || typeof value === "number" && Number.isFinite(value);
490
+ }
491
+ }
492
+ function assertShape(value, shape, endpoint) {
493
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
494
+ throw new ApiError(
495
+ 502,
496
+ `[${endpoint}] expected object, got ${typeof value}`,
497
+ "SCHEMA_MISMATCH"
498
+ );
499
+ }
500
+ const obj = value;
501
+ for (const [key, spec] of Object.entries(shape)) {
502
+ const present = key in obj;
503
+ if (!present) {
504
+ if (spec.optional) continue;
505
+ throw new ApiError(
506
+ 502,
507
+ `[${endpoint}] missing required field "${key}"`,
508
+ "SCHEMA_MISMATCH"
509
+ );
510
+ }
511
+ const fieldValue = obj[key];
512
+ if (spec.optional && (fieldValue === null || fieldValue === void 0)) {
513
+ continue;
514
+ }
515
+ if (!checkType(fieldValue, spec.type)) {
516
+ throw new ApiError(
517
+ 502,
518
+ `[${endpoint}] field "${key}" expected ${spec.type}, got ${typeof fieldValue}`,
519
+ "SCHEMA_MISMATCH"
520
+ );
521
+ }
522
+ }
523
+ return value;
524
+ }
525
+
362
526
  // src/api/auth.ts
363
527
  var GUEST_MEMBER_TOKEN_KEY_PREFIX = "cb_guest_";
364
528
  function credentialToStorageKeyHash(credential) {
@@ -465,6 +629,15 @@ var AuthAPI = class {
465
629
  data,
466
630
  { skipAuth: true }
467
631
  );
632
+ assertShape(
633
+ response,
634
+ {
635
+ access_token: { type: "string" },
636
+ refresh_token: { type: "string" },
637
+ member_id: { type: "string-or-number" }
638
+ },
639
+ "auth.signInMember"
640
+ );
468
641
  this.http.setTokens(response.access_token, response.refresh_token);
469
642
  this.notifyVisitorTracker(response.member_id);
470
643
  return response;
@@ -504,7 +677,17 @@ var AuthAPI = class {
504
677
  * ```
505
678
  */
506
679
  async getMe() {
507
- return this.http.get("/v1/public/app-members/me");
680
+ const response = await this.http.get(
681
+ "/v1/public/app-members/me"
682
+ );
683
+ assertShape(
684
+ response,
685
+ {
686
+ member_id: { type: "string-or-number" }
687
+ },
688
+ "auth.getMe"
689
+ );
690
+ return response;
508
691
  }
509
692
  /**
510
693
  * 현재 로그인한 멤버의 custom_data 수정
@@ -1791,6 +1974,72 @@ var DatabaseAPI = class {
1791
1974
  }
1792
1975
  };
1793
1976
 
1977
+ // src/core/url-validation.ts
1978
+ var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
1979
+ var MAX_URL_LENGTH = 2048;
1980
+ function validateExternalUrl(rawUrl, options = {}) {
1981
+ const context = options.context ?? "external URL";
1982
+ if (typeof rawUrl !== "string" || rawUrl.length === 0) {
1983
+ throw new ApiError(
1984
+ 400,
1985
+ `${context}: empty URL`,
1986
+ "INVALID_PRESIGNED_URL"
1987
+ );
1988
+ }
1989
+ if (rawUrl.length > MAX_URL_LENGTH) {
1990
+ throw new ApiError(
1991
+ 400,
1992
+ `${context}: URL exceeds ${MAX_URL_LENGTH} chars`,
1993
+ "INVALID_PRESIGNED_URL"
1994
+ );
1995
+ }
1996
+ let parsed;
1997
+ try {
1998
+ parsed = new URL(rawUrl);
1999
+ } catch {
2000
+ throw new ApiError(
2001
+ 400,
2002
+ `${context}: cannot parse URL`,
2003
+ "INVALID_PRESIGNED_URL"
2004
+ );
2005
+ }
2006
+ const allowHttp = options.allowLocalhost && LOCAL_HOSTS.has(parsed.hostname);
2007
+ if (parsed.protocol !== "https:" && !(allowHttp && parsed.protocol === "http:")) {
2008
+ throw new ApiError(
2009
+ 400,
2010
+ `${context}: scheme must be https (got ${parsed.protocol})`,
2011
+ "INVALID_PRESIGNED_URL"
2012
+ );
2013
+ }
2014
+ if (!options.allowLocalhost && LOCAL_HOSTS.has(parsed.hostname)) {
2015
+ throw new ApiError(
2016
+ 400,
2017
+ `${context}: localhost is not allowed in production`,
2018
+ "INVALID_PRESIGNED_URL"
2019
+ );
2020
+ }
2021
+ if (options.allowedHosts && options.allowedHosts.length > 0) {
2022
+ const host = parsed.hostname;
2023
+ const allowed = options.allowedHosts.some((h) => {
2024
+ if (h === host) return true;
2025
+ return host.endsWith(`.${h}`);
2026
+ });
2027
+ if (!allowed) {
2028
+ throw new ApiError(
2029
+ 400,
2030
+ `${context}: host ${host} is not in allowlist`,
2031
+ "INVALID_PRESIGNED_URL"
2032
+ );
2033
+ }
2034
+ }
2035
+ return parsed;
2036
+ }
2037
+ function isLocalhostOrigin() {
2038
+ if (typeof window === "undefined") return false;
2039
+ const hostname = window.location.hostname;
2040
+ return LOCAL_HOSTS.has(hostname);
2041
+ }
2042
+
1794
2043
  // src/api/storage.ts
1795
2044
  var StorageAPI = class {
1796
2045
  constructor(http) {
@@ -1802,6 +2051,40 @@ var StorageAPI = class {
1802
2051
  getPublicPrefix() {
1803
2052
  return this.http.hasPublicKey() ? "/v1/public" : "/v1";
1804
2053
  }
2054
+ /**
2055
+ * Presigned URL 로 직접 업로드. 호출 전에 URL 스킴(https)을 검증해
2056
+ * 서버 응답을 그대로 신뢰하는 SSRF/오용 경로를 차단.
2057
+ * 타임아웃과 외부 signal 을 모두 지원한다.
2058
+ */
2059
+ async uploadToPresigned(rawUrl, file, options) {
2060
+ const parsed = validateExternalUrl(rawUrl, {
2061
+ allowLocalhost: isLocalhostOrigin(),
2062
+ context: "storage.presigned-url"
2063
+ });
2064
+ const { signal, cleanup } = createTimeoutController({
2065
+ timeout: options?.timeout,
2066
+ signal: options?.signal
2067
+ });
2068
+ try {
2069
+ const response = await fetch(parsed.toString(), {
2070
+ method: "PUT",
2071
+ body: file,
2072
+ headers: {
2073
+ "Content-Type": file.type || "application/octet-stream"
2074
+ },
2075
+ signal
2076
+ });
2077
+ if (!response.ok) {
2078
+ throw new ApiError(
2079
+ response.status,
2080
+ `Upload failed: ${response.statusText}`,
2081
+ "PRESIGNED_UPLOAD_FAILED"
2082
+ );
2083
+ }
2084
+ } finally {
2085
+ cleanup();
2086
+ }
2087
+ }
1805
2088
  /**
1806
2089
  * 파일 목록 조회
1807
2090
  *
@@ -1848,16 +2131,7 @@ var StorageAPI = class {
1848
2131
  parent_id: parentId
1849
2132
  }
1850
2133
  );
1851
- const uploadResponse = await fetch(presignedResponse.upload_url, {
1852
- method: "PUT",
1853
- body: file,
1854
- headers: {
1855
- "Content-Type": file.type || "application/octet-stream"
1856
- }
1857
- });
1858
- if (!uploadResponse.ok) {
1859
- throw new Error(`Upload failed: ${uploadResponse.statusText}`);
1860
- }
2134
+ await this.uploadToPresigned(presignedResponse.upload_url, file);
1861
2135
  const completeResponse = await this.http.post(
1862
2136
  `${prefix}/storages/files/${storageId}/complete-upload`,
1863
2137
  { file_id: presignedResponse.file_id }
@@ -1979,16 +2253,7 @@ var StorageAPI = class {
1979
2253
  overwrite
1980
2254
  }
1981
2255
  );
1982
- const uploadResponse = await fetch(presignedResponse.upload_url, {
1983
- method: "PUT",
1984
- body: file,
1985
- headers: {
1986
- "Content-Type": file.type || "application/octet-stream"
1987
- }
1988
- });
1989
- if (!uploadResponse.ok) {
1990
- throw new Error(`Upload failed: ${uploadResponse.statusText}`);
1991
- }
2256
+ await this.uploadToPresigned(presignedResponse.upload_url, file);
1992
2257
  const completeResponse = await this.http.post(
1993
2258
  `${prefix}/storages/files/${storageId}/complete-upload`,
1994
2259
  { file_id: presignedResponse.file_id }
@@ -2256,7 +2521,11 @@ var FunctionsAPI = class {
2256
2521
  async call(functionId, payload) {
2257
2522
  const response = await this.invoke(functionId, payload);
2258
2523
  if (!response.success) {
2259
- throw new Error(response.error || "Function execution failed");
2524
+ throw new ApiError(
2525
+ 500,
2526
+ response.error || "Function execution failed",
2527
+ "FUNCTION_EXECUTION_FAILED"
2528
+ );
2260
2529
  }
2261
2530
  return response.result;
2262
2531
  }
@@ -2977,7 +3246,7 @@ var RealtimeAPI = class {
2977
3246
  }
2978
3247
  });
2979
3248
  } catch (e) {
2980
- console.error("[Realtime] Failed to parse message:", line, e);
3249
+ this.logError("Failed to parse message", { line, error: e });
2981
3250
  }
2982
3251
  }
2983
3252
  };
@@ -2994,7 +3263,7 @@ var RealtimeAPI = class {
2994
3263
  };
2995
3264
  this.ws.onerror = (error) => {
2996
3265
  this.log("WebSocket error occurred");
2997
- console.error("[Realtime] WebSocket error:", error);
3266
+ this.logError("WebSocket error", error);
2998
3267
  this.notifyError(new Error("WebSocket connection error"));
2999
3268
  if (!settled && this.state === "connecting") {
3000
3269
  settled = true;
@@ -3014,6 +3283,16 @@ var RealtimeAPI = class {
3014
3283
  console.log(`[Realtime] ${message}`);
3015
3284
  }
3016
3285
  }
3286
+ /**
3287
+ * 에러 로그. `options.debug` 가 true 일 때만 console 에 출력.
3288
+ * 운영 환경에서는 소비자 애플리케이션의 `ConnectBase({ onError })` 또는
3289
+ * `cb.errorTracker` 로 전달하는 것을 권장하므로 SDK 자체 console 출력은 opt-in.
3290
+ */
3291
+ logError(message, error) {
3292
+ if (this.options.debug) {
3293
+ console.error(`[Realtime] ${message}`, error);
3294
+ }
3295
+ }
3017
3296
  handleServerMessage(msg, connectResolve) {
3018
3297
  switch (msg.event) {
3019
3298
  case "connected": {
@@ -3224,7 +3503,7 @@ var RealtimeAPI = class {
3224
3503
  previousTypingHandlers
3225
3504
  );
3226
3505
  } catch (e) {
3227
- console.error("[Realtime] Reconnect failed:", e);
3506
+ this.logError("Reconnect failed", e);
3228
3507
  }
3229
3508
  }, delay);
3230
3509
  } else {
@@ -3256,7 +3535,7 @@ var RealtimeAPI = class {
3256
3535
  this.subscriptions.set(category, { info, handlers });
3257
3536
  this.log(`Restored subscription: ${category}`);
3258
3537
  } catch (e) {
3259
- console.error(`[Realtime] Failed to restore subscription for ${category}:`, e);
3538
+ this.logError(`Failed to restore subscription for ${category}`, e);
3260
3539
  this.notifyError(new Error(`Failed to restore subscription: ${category}`));
3261
3540
  }
3262
3541
  }
@@ -3273,7 +3552,7 @@ var RealtimeAPI = class {
3273
3552
  this.presenceSubscriptions.set(userId, handlers);
3274
3553
  this.log(`Restored presence subscription: ${userId}`);
3275
3554
  } catch (e) {
3276
- console.error(`[Realtime] Failed to restore presence subscription for ${userId}:`, e);
3555
+ this.logError(`Failed to restore presence subscription for ${userId}`, e);
3277
3556
  }
3278
3557
  }
3279
3558
  for (const [roomId, handlers] of previousTypingHandlers) {
@@ -3289,7 +3568,7 @@ var RealtimeAPI = class {
3289
3568
  this.typingHandlers.set(roomId, handlers);
3290
3569
  this.log(`Restored typing subscription: ${roomId}`);
3291
3570
  } catch (e) {
3292
- console.error(`[Realtime] Failed to restore typing subscription for ${roomId}:`, e);
3571
+ this.logError(`Failed to restore typing subscription for ${roomId}`, e);
3293
3572
  }
3294
3573
  }
3295
3574
  }
@@ -4400,7 +4679,19 @@ var PaymentAPI = class {
4400
4679
  */
4401
4680
  async createCheckoutSession(data) {
4402
4681
  const prefix = this.getPublicPrefix();
4403
- return this.http.post(`${prefix}/payments/checkout-session`, data);
4682
+ const response = await this.http.post(
4683
+ `${prefix}/payments/checkout-session`,
4684
+ data
4685
+ );
4686
+ assertShape(
4687
+ response,
4688
+ {
4689
+ session_id: { type: "string" },
4690
+ session_url: { type: "string" }
4691
+ },
4692
+ "payment.createCheckoutSession"
4693
+ );
4694
+ return response;
4404
4695
  }
4405
4696
  /**
4406
4697
  * 결제 승인
@@ -4614,7 +4905,19 @@ var SubscriptionAPI = class {
4614
4905
  */
4615
4906
  async create(data) {
4616
4907
  const prefix = this.getPublicPrefix();
4617
- return this.http.post(`${prefix}/subscriptions`, data);
4908
+ const response = await this.http.post(
4909
+ `${prefix}/subscriptions`,
4910
+ data
4911
+ );
4912
+ assertShape(
4913
+ response,
4914
+ {
4915
+ id: { type: "string-or-number" },
4916
+ status: { type: "string" }
4917
+ },
4918
+ "subscription.create"
4919
+ );
4920
+ return response;
4618
4921
  }
4619
4922
  /**
4620
4923
  * 구독 목록 조회
@@ -4823,7 +5126,18 @@ var PushAPI = class {
4823
5126
  */
4824
5127
  async registerDevice(request) {
4825
5128
  const prefix = this.getPublicPrefix();
4826
- return this.http.post(`${prefix}/push/devices`, request);
5129
+ const response = await this.http.post(
5130
+ `${prefix}/push/devices`,
5131
+ request
5132
+ );
5133
+ assertShape(
5134
+ response,
5135
+ {
5136
+ device_token: { type: "string" }
5137
+ },
5138
+ "push.registerDevice"
5139
+ );
5140
+ return response;
4827
5141
  }
4828
5142
  /**
4829
5143
  * 디바이스 등록 해제
@@ -5189,21 +5503,39 @@ var VideoAPI = class {
5189
5503
  if (body && !(body instanceof FormData)) {
5190
5504
  headers["Content-Type"] = "application/json";
5191
5505
  }
5192
- const response = await fetch(`${this.videoBaseUrl}${path}`, {
5193
- method,
5194
- headers,
5195
- body: body instanceof FormData ? body : body ? JSON.stringify(body) : void 0
5506
+ const { signal, cleanup } = createTimeoutController({
5507
+ timeout: DEFAULT_REQUEST_TIMEOUT_MS
5196
5508
  });
5197
- if (!response.ok) {
5198
- const errorData = await response.json().catch(() => ({
5199
- message: response.statusText
5200
- }));
5201
- throw new ApiError(response.status, errorData.message || "Unknown error");
5202
- }
5203
- if (response.status === 204 || response.headers.get("content-length") === "0") {
5204
- return {};
5509
+ try {
5510
+ const response = await fetch(`${this.videoBaseUrl}${path}`, {
5511
+ method,
5512
+ headers,
5513
+ body: body instanceof FormData ? body : body ? JSON.stringify(body) : void 0,
5514
+ signal
5515
+ });
5516
+ if (!response.ok) {
5517
+ const errorData = await response.json().catch(() => ({
5518
+ message: response.statusText
5519
+ }));
5520
+ const rawError = errorData.error;
5521
+ if (rawError && typeof rawError === "object" && "message" in rawError) {
5522
+ throw new ApiError(
5523
+ response.status,
5524
+ rawError.message || "Unknown error",
5525
+ rawError.code,
5526
+ rawError.details
5527
+ );
5528
+ }
5529
+ const message = typeof rawError === "string" ? rawError : errorData.message || "Unknown error";
5530
+ throw new ApiError(response.status, message);
5531
+ }
5532
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
5533
+ return {};
5534
+ }
5535
+ return await response.json();
5536
+ } finally {
5537
+ cleanup();
5205
5538
  }
5206
- return response.json();
5207
5539
  }
5208
5540
  // ========== Video Operations ==========
5209
5541
  /**
@@ -5295,7 +5627,29 @@ var VideoAPI = class {
5295
5627
  throw new VideoProcessingError("Timeout waiting for video to be ready");
5296
5628
  }
5297
5629
  /**
5298
- * List videos
5630
+ * 영상 목록 조회.
5631
+ *
5632
+ * 필터링/페이지네이션 옵션을 조합해 조건에 맞는 영상들을 반환한다.
5633
+ * API Key(publicKey) 또는 JWT 인증이 모두 지원되며, publicKey 로는 공개 영상만 노출된다.
5634
+ *
5635
+ * @param options - 필터/페이지네이션 옵션
5636
+ * @param options.status - `uploading` | `processing` | `ready` | `failed`
5637
+ * @param options.visibility - `public` | `unlisted` | `private` | `members`
5638
+ * @param options.search - 제목 부분 일치 검색
5639
+ * @param options.channel_id - 특정 채널로 한정
5640
+ * @param options.page - 1 부터 시작
5641
+ * @param options.limit - 기본 20, 최대 100
5642
+ * @returns `videos` 배열과 `total` 카운트를 포함하는 응답
5643
+ *
5644
+ * @example
5645
+ * ```ts
5646
+ * // 내 채널의 최근 공개 영상 10개
5647
+ * const { videos, total } = await cb.video.list({
5648
+ * channel_id: 'ch_abc',
5649
+ * visibility: 'public',
5650
+ * limit: 10,
5651
+ * })
5652
+ * ```
5299
5653
  */
5300
5654
  async list(options) {
5301
5655
  const prefix = this.getPublicPrefix();
@@ -5331,7 +5685,20 @@ var VideoAPI = class {
5331
5685
  await this.videoFetch("DELETE", `${prefix}/videos/${videoId}`);
5332
5686
  }
5333
5687
  /**
5334
- * Get streaming URL for a video
5688
+ * 영상 스트리밍 URL 획득 (HLS manifest 권장).
5689
+ *
5690
+ * 반환된 URL 은 `hls.js` 또는 Safari 네이티브 HLS 플레이어에 그대로 전달 가능하다.
5691
+ * 서명된 URL 이므로 만료 시간이 존재하며, 갱신이 필요한 경우 다시 호출한다.
5692
+ *
5693
+ * @param videoId - 대상 영상 ID
5694
+ * @param quality - `auto` (기본) | `1080p` | `720p` | `480p` | `360p` 등 트랜스코딩된 품질 레벨
5695
+ * @returns HLS manifest URL 과 만료 정보
5696
+ *
5697
+ * @example
5698
+ * ```ts
5699
+ * const { url, expires_at } = await cb.video.getStreamUrl(videoId, '720p')
5700
+ * videoElement.src = url
5701
+ * ```
5335
5702
  */
5336
5703
  async getStreamUrl(videoId, quality) {
5337
5704
  const prefix = this.getPublicPrefix();
@@ -6186,7 +6553,11 @@ var GameAPI = class {
6186
6553
  headers: this.getHeaders()
6187
6554
  });
6188
6555
  if (!response.ok) {
6189
- throw new Error(`Failed to list rooms: ${response.statusText}`);
6556
+ throw new ApiError(
6557
+ response.status,
6558
+ `Failed to list rooms: ${response.statusText}`,
6559
+ "GAME_LIST_ROOMS_FAILED"
6560
+ );
6190
6561
  }
6191
6562
  const data = await response.json();
6192
6563
  return data.rooms;
@@ -6200,7 +6571,11 @@ var GameAPI = class {
6200
6571
  headers: this.getHeaders()
6201
6572
  });
6202
6573
  if (!response.ok) {
6203
- throw new Error(`Failed to get room: ${response.statusText}`);
6574
+ throw new ApiError(
6575
+ response.status,
6576
+ `Failed to get room: ${response.statusText}`,
6577
+ "GAME_GET_ROOM_FAILED"
6578
+ );
6204
6579
  }
6205
6580
  return response.json();
6206
6581
  }
@@ -7666,7 +8041,17 @@ var QueueAPI = class {
7666
8041
  if (options?.visibility_timeout) params.set("visibility_timeout", String(options.visibility_timeout));
7667
8042
  if (options?.auto_ack !== void 0) params.set("auto_ack", String(options.auto_ack));
7668
8043
  const query = params.toString();
7669
- return this.http.get(`/v1/public/queues/${queueID}/messages${query ? `?${query}` : ""}`);
8044
+ const response = await this.http.get(
8045
+ `/v1/public/queues/${queueID}/messages${query ? `?${query}` : ""}`
8046
+ );
8047
+ assertShape(
8048
+ response,
8049
+ {
8050
+ messages: { type: "array" }
8051
+ },
8052
+ "queue.consume"
8053
+ );
8054
+ return response;
7670
8055
  }
7671
8056
  /**
7672
8057
  * 메시지 처리 완료 확인 (Ack)
@@ -8037,11 +8422,33 @@ var AnalyticsAPI = class {
8037
8422
  sendHeartbeat();
8038
8423
  this.log("Heartbeat enabled (30s interval)");
8039
8424
  }
8040
- /** 큐에 있는 이벤트 즉시 전송 */
8425
+ /**
8426
+ * 큐에 있는 이벤트 즉시 전송.
8427
+ *
8428
+ * 기본적으로 이벤트는 배치(10개) 또는 주기(5초)로 flush 되지만, 페이지 이탈 직전이나
8429
+ * 결정적 이벤트(결제 완료 등)는 수동으로 `flush()` 를 호출해 전송 지연을 줄일 수 있다.
8430
+ *
8431
+ * @returns 서버 응답 완료 시까지 대기하는 Promise
8432
+ *
8433
+ * @example
8434
+ * ```ts
8435
+ * // 결제 완료 직후 이탈에 대비해 즉시 flush
8436
+ * await cb.analytics.track('purchase_completed', { order_id: '123' })
8437
+ * await cb.analytics.flush()
8438
+ * window.location.href = '/thank-you'
8439
+ * ```
8440
+ */
8041
8441
  async flush() {
8042
8442
  await this.flushQueue();
8043
8443
  }
8044
- /** 세션 매니저 접근 (고급) */
8444
+ /**
8445
+ * 세션 매니저 접근 (고급 사용자용).
8446
+ *
8447
+ * 외부에서 세션 ID 를 읽어 자체 로깅에 합치거나 강제 세션 종료/재시작이 필요한 경우
8448
+ * 사용. 일반적으로는 AnalyticsAPI 가 내부적으로 세션을 관리하므로 호출할 필요가 없다.
8449
+ *
8450
+ * @returns 내부 SessionManager 인스턴스
8451
+ */
8045
8452
  getSession() {
8046
8453
  return this.session;
8047
8454
  }
@@ -8958,6 +9365,8 @@ var ConnectBase = class {
8958
9365
  publicKey: config.publicKey,
8959
9366
  secretKey: config.secretKey,
8960
9367
  persistence: config.persistence,
9368
+ requestTimeoutMs: config.requestTimeoutMs,
9369
+ onError: config.onError,
8961
9370
  onTokenRefresh: config.onTokenRefresh,
8962
9371
  onAuthError: config.onAuthError,
8963
9372
  onTokenExpired: config.onTokenExpired