connectbase-client 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -15,12 +15,50 @@ var AuthError = class extends Error {
15
15
  }
16
16
  };
17
17
 
18
+ // src/core/abort.ts
19
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
20
+ function createTimeoutController(options = {}) {
21
+ const controller = new AbortController();
22
+ const timeout = options.timeout ?? DEFAULT_REQUEST_TIMEOUT_MS;
23
+ const external = options.signal;
24
+ let timeoutId = null;
25
+ let externalListener = null;
26
+ if (external) {
27
+ if (external.aborted) {
28
+ controller.abort(external.reason);
29
+ } else {
30
+ externalListener = () => controller.abort(external.reason);
31
+ external.addEventListener("abort", externalListener, { once: true });
32
+ }
33
+ }
34
+ if (timeout > 0 && Number.isFinite(timeout)) {
35
+ timeoutId = setTimeout(() => {
36
+ controller.abort(
37
+ new DOMException(`Request timed out after ${timeout}ms`, "TimeoutError")
38
+ );
39
+ }, timeout);
40
+ }
41
+ return {
42
+ signal: controller.signal,
43
+ cleanup: () => {
44
+ if (timeoutId !== null) clearTimeout(timeoutId);
45
+ if (external && externalListener) {
46
+ external.removeEventListener("abort", externalListener);
47
+ }
48
+ }
49
+ };
50
+ }
51
+
18
52
  // src/core/http.ts
19
53
  var TOKEN_STORAGE_KEY = "cb_auth_tokens";
20
54
  var HttpClient = class {
21
55
  constructor(config) {
22
56
  this.isRefreshing = false;
23
57
  this.refreshPromise = null;
58
+ // 연속 refresh 실패 시 지수 백오프 — 같은 세션이 스피너 UI 로 묶였을 때
59
+ // 밀리초 단위로 서버에 재시도 요청이 쏟아지는 것을 차단.
60
+ this.refreshFailureCount = 0;
61
+ this.refreshLockedUntil = 0;
24
62
  this.config = { ...config };
25
63
  this.storageKey = this.buildStorageKey();
26
64
  this.warnIfUnsafePersistence();
@@ -162,22 +200,34 @@ var HttpClient = class {
162
200
  if (this.isRefreshing) {
163
201
  return this.refreshPromise;
164
202
  }
203
+ const now = Date.now();
204
+ if (now < this.refreshLockedUntil) {
205
+ const error = new AuthError("Token refresh locked due to repeated failures. Please login again.");
206
+ this.emitError(error);
207
+ this.config.onAuthError?.(error);
208
+ throw error;
209
+ }
165
210
  this.isRefreshing = true;
166
211
  if (!this.config.refreshToken) {
167
212
  this.isRefreshing = false;
168
213
  this.config.onTokenExpired?.();
169
214
  const error = new AuthError("Refresh token is missing. Please login again.");
215
+ this.emitError(error);
170
216
  this.config.onAuthError?.(error);
171
217
  throw error;
172
218
  }
173
219
  this.refreshPromise = (async () => {
220
+ const { signal, cleanup } = createTimeoutController({
221
+ timeout: this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
222
+ });
174
223
  try {
175
224
  const response = await fetch(`${this.config.baseUrl}/v1/auth/re-issue`, {
176
225
  method: "POST",
177
226
  headers: {
178
227
  "Content-Type": "application/json",
179
228
  "Authorization": `Bearer ${this.config.refreshToken}`
180
- }
229
+ },
230
+ signal
181
231
  });
182
232
  if (!response.ok) {
183
233
  throw new Error("Token refresh failed");
@@ -188,20 +238,36 @@ var HttpClient = class {
188
238
  accessToken: data.access_token,
189
239
  refreshToken: data.refresh_token
190
240
  });
241
+ this.refreshFailureCount = 0;
242
+ this.refreshLockedUntil = 0;
191
243
  return data.access_token;
192
244
  } catch {
245
+ this.refreshFailureCount++;
246
+ const backoffMs = Math.min(
247
+ 500 * 2 ** Math.max(0, this.refreshFailureCount - 1),
248
+ 3e4
249
+ );
250
+ this.refreshLockedUntil = Date.now() + backoffMs;
193
251
  this.clearTokens();
194
252
  this.config.onTokenExpired?.();
195
253
  const error = new AuthError("Token refresh failed. Please login again.");
254
+ this.emitError(error);
196
255
  this.config.onAuthError?.(error);
197
256
  throw error;
198
257
  } finally {
258
+ cleanup();
199
259
  this.isRefreshing = false;
200
260
  this.refreshPromise = null;
201
261
  }
202
262
  })();
203
263
  return this.refreshPromise;
204
264
  }
265
+ emitError(error) {
266
+ try {
267
+ this.config.onError?.(error);
268
+ } catch {
269
+ }
270
+ }
205
271
  isTokenExpired(token) {
206
272
  try {
207
273
  const payload = JSON.parse(atob(token.split(".")[1]));
@@ -240,72 +306,119 @@ var HttpClient = class {
240
306
  const errorData = await response.json().catch(() => ({
241
307
  message: response.statusText
242
308
  }));
309
+ const retryAfterHeader = response.status === 429 ? response.headers.get("Retry-After") : null;
310
+ let retryAfterSeconds;
311
+ if (retryAfterHeader) {
312
+ const asInt = Number.parseInt(retryAfterHeader, 10);
313
+ if (Number.isFinite(asInt) && asInt >= 0) {
314
+ retryAfterSeconds = asInt;
315
+ } else {
316
+ const dateMs = Date.parse(retryAfterHeader);
317
+ if (Number.isFinite(dateMs)) {
318
+ retryAfterSeconds = Math.max(
319
+ 0,
320
+ Math.round((dateMs - Date.now()) / 1e3)
321
+ );
322
+ }
323
+ }
324
+ }
243
325
  const rawError = errorData.error;
244
326
  if (rawError && typeof rawError === "object" && "message" in rawError) {
245
- throw new ApiError(
327
+ const details = {
328
+ ...rawError.details && typeof rawError.details === "object" ? rawError.details : {}
329
+ };
330
+ if (retryAfterSeconds !== void 0) {
331
+ details.retry_after_seconds = retryAfterSeconds;
332
+ }
333
+ const err2 = new ApiError(
246
334
  response.status,
247
335
  rawError.message || "Unknown error",
248
336
  rawError.code,
249
- rawError.details
337
+ Object.keys(details).length > 0 ? details : rawError.details
250
338
  );
339
+ this.emitError(err2);
340
+ throw err2;
251
341
  }
252
342
  const message = typeof rawError === "string" ? rawError : errorData.message || "Unknown error";
253
- throw new ApiError(response.status, message);
343
+ const legacyDetails = retryAfterSeconds !== void 0 ? { retry_after_seconds: retryAfterSeconds } : void 0;
344
+ const err = new ApiError(response.status, message, void 0, legacyDetails);
345
+ this.emitError(err);
346
+ throw err;
254
347
  }
255
348
  if (response.status === 204 || response.headers.get("content-length") === "0") {
256
349
  return {};
257
350
  }
258
351
  return response.json();
259
352
  }
353
+ /**
354
+ * AbortController 를 관리하며 fetch 호출을 실행. 타임아웃/외부 signal 병합.
355
+ */
356
+ async doFetch(url, init, config) {
357
+ const { signal, cleanup } = createTimeoutController({
358
+ timeout: config?.timeout ?? this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
359
+ signal: config?.signal
360
+ });
361
+ try {
362
+ const response = await fetch(`${this.config.baseUrl}${url}`, {
363
+ ...init,
364
+ signal
365
+ });
366
+ return await this.handleResponse(response);
367
+ } finally {
368
+ cleanup();
369
+ }
370
+ }
260
371
  async get(url, config) {
261
372
  const headers = await this.prepareHeaders(config);
262
- const response = await fetch(`${this.config.baseUrl}${url}`, {
263
- method: "GET",
264
- headers
265
- });
266
- return this.handleResponse(response);
373
+ return this.doFetch(url, { method: "GET", headers }, config);
267
374
  }
268
375
  async post(url, data, config) {
269
376
  const headers = await this.prepareHeaders(config);
270
377
  if (data instanceof FormData) {
271
378
  headers.delete("Content-Type");
272
379
  }
273
- const response = await fetch(`${this.config.baseUrl}${url}`, {
274
- method: "POST",
275
- headers,
276
- body: data instanceof FormData ? data : JSON.stringify(data)
277
- });
278
- return this.handleResponse(response);
380
+ return this.doFetch(
381
+ url,
382
+ {
383
+ method: "POST",
384
+ headers,
385
+ body: data instanceof FormData ? data : JSON.stringify(data)
386
+ },
387
+ config
388
+ );
279
389
  }
280
390
  async put(url, data, config) {
281
391
  const headers = await this.prepareHeaders(config);
282
- const response = await fetch(`${this.config.baseUrl}${url}`, {
283
- method: "PUT",
284
- headers,
285
- body: JSON.stringify(data)
286
- });
287
- return this.handleResponse(response);
392
+ return this.doFetch(
393
+ url,
394
+ {
395
+ method: "PUT",
396
+ headers,
397
+ body: JSON.stringify(data)
398
+ },
399
+ config
400
+ );
288
401
  }
289
402
  async patch(url, data, config) {
290
403
  const headers = await this.prepareHeaders(config);
291
- const response = await fetch(`${this.config.baseUrl}${url}`, {
292
- method: "PATCH",
293
- headers,
294
- body: JSON.stringify(data)
295
- });
296
- return this.handleResponse(response);
404
+ return this.doFetch(
405
+ url,
406
+ {
407
+ method: "PATCH",
408
+ headers,
409
+ body: JSON.stringify(data)
410
+ },
411
+ config
412
+ );
297
413
  }
298
414
  async delete(url, config) {
299
415
  const headers = await this.prepareHeaders(config);
300
- const response = await fetch(`${this.config.baseUrl}${url}`, {
301
- method: "DELETE",
302
- headers
303
- });
304
- return this.handleResponse(response);
416
+ return this.doFetch(url, { method: "DELETE", headers }, config);
305
417
  }
306
418
  /**
307
419
  * Raw fetch 요청 (SSE 스트리밍 등에 사용)
308
- * 인증 헤더가 자동으로 추가됩니다.
420
+ * 인증 헤더가 자동으로 추가됩니다. timeout 은 호출자가 직접 signal 로 관리해야 합니다
421
+ * (스트리밍 특성상 전역 timeout 을 강제하지 않음).
309
422
  */
310
423
  async fetchRaw(url, init) {
311
424
  const headers = await this.prepareHeaders();
@@ -321,6 +434,57 @@ var HttpClient = class {
321
434
  }
322
435
  };
323
436
 
437
+ // src/core/validate.ts
438
+ function checkType(value, type) {
439
+ switch (type) {
440
+ case "string":
441
+ return typeof value === "string";
442
+ case "number":
443
+ return typeof value === "number" && Number.isFinite(value);
444
+ case "boolean":
445
+ return typeof value === "boolean";
446
+ case "array":
447
+ return Array.isArray(value);
448
+ case "object":
449
+ return typeof value === "object" && value !== null && !Array.isArray(value);
450
+ case "string-or-number":
451
+ return typeof value === "string" || typeof value === "number" && Number.isFinite(value);
452
+ }
453
+ }
454
+ function assertShape(value, shape, endpoint) {
455
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
456
+ throw new ApiError(
457
+ 502,
458
+ `[${endpoint}] expected object, got ${typeof value}`,
459
+ "SCHEMA_MISMATCH"
460
+ );
461
+ }
462
+ const obj = value;
463
+ for (const [key, spec] of Object.entries(shape)) {
464
+ const present = key in obj;
465
+ if (!present) {
466
+ if (spec.optional) continue;
467
+ throw new ApiError(
468
+ 502,
469
+ `[${endpoint}] missing required field "${key}"`,
470
+ "SCHEMA_MISMATCH"
471
+ );
472
+ }
473
+ const fieldValue = obj[key];
474
+ if (spec.optional && (fieldValue === null || fieldValue === void 0)) {
475
+ continue;
476
+ }
477
+ if (!checkType(fieldValue, spec.type)) {
478
+ throw new ApiError(
479
+ 502,
480
+ `[${endpoint}] field "${key}" expected ${spec.type}, got ${typeof fieldValue}`,
481
+ "SCHEMA_MISMATCH"
482
+ );
483
+ }
484
+ }
485
+ return value;
486
+ }
487
+
324
488
  // src/api/auth.ts
325
489
  var GUEST_MEMBER_TOKEN_KEY_PREFIX = "cb_guest_";
326
490
  function credentialToStorageKeyHash(credential) {
@@ -427,6 +591,15 @@ var AuthAPI = class {
427
591
  data,
428
592
  { skipAuth: true }
429
593
  );
594
+ assertShape(
595
+ response,
596
+ {
597
+ access_token: { type: "string" },
598
+ refresh_token: { type: "string" },
599
+ member_id: { type: "string-or-number" }
600
+ },
601
+ "auth.signInMember"
602
+ );
430
603
  this.http.setTokens(response.access_token, response.refresh_token);
431
604
  this.notifyVisitorTracker(response.member_id);
432
605
  return response;
@@ -466,7 +639,17 @@ var AuthAPI = class {
466
639
  * ```
467
640
  */
468
641
  async getMe() {
469
- return this.http.get("/v1/public/app-members/me");
642
+ const response = await this.http.get(
643
+ "/v1/public/app-members/me"
644
+ );
645
+ assertShape(
646
+ response,
647
+ {
648
+ member_id: { type: "string-or-number" }
649
+ },
650
+ "auth.getMe"
651
+ );
652
+ return response;
470
653
  }
471
654
  /**
472
655
  * 현재 로그인한 멤버의 custom_data 수정
@@ -1753,6 +1936,72 @@ var DatabaseAPI = class {
1753
1936
  }
1754
1937
  };
1755
1938
 
1939
+ // src/core/url-validation.ts
1940
+ var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
1941
+ var MAX_URL_LENGTH = 2048;
1942
+ function validateExternalUrl(rawUrl, options = {}) {
1943
+ const context = options.context ?? "external URL";
1944
+ if (typeof rawUrl !== "string" || rawUrl.length === 0) {
1945
+ throw new ApiError(
1946
+ 400,
1947
+ `${context}: empty URL`,
1948
+ "INVALID_PRESIGNED_URL"
1949
+ );
1950
+ }
1951
+ if (rawUrl.length > MAX_URL_LENGTH) {
1952
+ throw new ApiError(
1953
+ 400,
1954
+ `${context}: URL exceeds ${MAX_URL_LENGTH} chars`,
1955
+ "INVALID_PRESIGNED_URL"
1956
+ );
1957
+ }
1958
+ let parsed;
1959
+ try {
1960
+ parsed = new URL(rawUrl);
1961
+ } catch {
1962
+ throw new ApiError(
1963
+ 400,
1964
+ `${context}: cannot parse URL`,
1965
+ "INVALID_PRESIGNED_URL"
1966
+ );
1967
+ }
1968
+ const allowHttp = options.allowLocalhost && LOCAL_HOSTS.has(parsed.hostname);
1969
+ if (parsed.protocol !== "https:" && !(allowHttp && parsed.protocol === "http:")) {
1970
+ throw new ApiError(
1971
+ 400,
1972
+ `${context}: scheme must be https (got ${parsed.protocol})`,
1973
+ "INVALID_PRESIGNED_URL"
1974
+ );
1975
+ }
1976
+ if (!options.allowLocalhost && LOCAL_HOSTS.has(parsed.hostname)) {
1977
+ throw new ApiError(
1978
+ 400,
1979
+ `${context}: localhost is not allowed in production`,
1980
+ "INVALID_PRESIGNED_URL"
1981
+ );
1982
+ }
1983
+ if (options.allowedHosts && options.allowedHosts.length > 0) {
1984
+ const host = parsed.hostname;
1985
+ const allowed = options.allowedHosts.some((h) => {
1986
+ if (h === host) return true;
1987
+ return host.endsWith(`.${h}`);
1988
+ });
1989
+ if (!allowed) {
1990
+ throw new ApiError(
1991
+ 400,
1992
+ `${context}: host ${host} is not in allowlist`,
1993
+ "INVALID_PRESIGNED_URL"
1994
+ );
1995
+ }
1996
+ }
1997
+ return parsed;
1998
+ }
1999
+ function isLocalhostOrigin() {
2000
+ if (typeof window === "undefined") return false;
2001
+ const hostname = window.location.hostname;
2002
+ return LOCAL_HOSTS.has(hostname);
2003
+ }
2004
+
1756
2005
  // src/api/storage.ts
1757
2006
  var StorageAPI = class {
1758
2007
  constructor(http) {
@@ -1764,6 +2013,40 @@ var StorageAPI = class {
1764
2013
  getPublicPrefix() {
1765
2014
  return this.http.hasPublicKey() ? "/v1/public" : "/v1";
1766
2015
  }
2016
+ /**
2017
+ * Presigned URL 로 직접 업로드. 호출 전에 URL 스킴(https)을 검증해
2018
+ * 서버 응답을 그대로 신뢰하는 SSRF/오용 경로를 차단.
2019
+ * 타임아웃과 외부 signal 을 모두 지원한다.
2020
+ */
2021
+ async uploadToPresigned(rawUrl, file, options) {
2022
+ const parsed = validateExternalUrl(rawUrl, {
2023
+ allowLocalhost: isLocalhostOrigin(),
2024
+ context: "storage.presigned-url"
2025
+ });
2026
+ const { signal, cleanup } = createTimeoutController({
2027
+ timeout: options?.timeout,
2028
+ signal: options?.signal
2029
+ });
2030
+ try {
2031
+ const response = await fetch(parsed.toString(), {
2032
+ method: "PUT",
2033
+ body: file,
2034
+ headers: {
2035
+ "Content-Type": file.type || "application/octet-stream"
2036
+ },
2037
+ signal
2038
+ });
2039
+ if (!response.ok) {
2040
+ throw new ApiError(
2041
+ response.status,
2042
+ `Upload failed: ${response.statusText}`,
2043
+ "PRESIGNED_UPLOAD_FAILED"
2044
+ );
2045
+ }
2046
+ } finally {
2047
+ cleanup();
2048
+ }
2049
+ }
1767
2050
  /**
1768
2051
  * 파일 목록 조회
1769
2052
  *
@@ -1810,16 +2093,7 @@ var StorageAPI = class {
1810
2093
  parent_id: parentId
1811
2094
  }
1812
2095
  );
1813
- const uploadResponse = await fetch(presignedResponse.upload_url, {
1814
- method: "PUT",
1815
- body: file,
1816
- headers: {
1817
- "Content-Type": file.type || "application/octet-stream"
1818
- }
1819
- });
1820
- if (!uploadResponse.ok) {
1821
- throw new Error(`Upload failed: ${uploadResponse.statusText}`);
1822
- }
2096
+ await this.uploadToPresigned(presignedResponse.upload_url, file);
1823
2097
  const completeResponse = await this.http.post(
1824
2098
  `${prefix}/storages/files/${storageId}/complete-upload`,
1825
2099
  { file_id: presignedResponse.file_id }
@@ -1941,16 +2215,7 @@ var StorageAPI = class {
1941
2215
  overwrite
1942
2216
  }
1943
2217
  );
1944
- const uploadResponse = await fetch(presignedResponse.upload_url, {
1945
- method: "PUT",
1946
- body: file,
1947
- headers: {
1948
- "Content-Type": file.type || "application/octet-stream"
1949
- }
1950
- });
1951
- if (!uploadResponse.ok) {
1952
- throw new Error(`Upload failed: ${uploadResponse.statusText}`);
1953
- }
2218
+ await this.uploadToPresigned(presignedResponse.upload_url, file);
1954
2219
  const completeResponse = await this.http.post(
1955
2220
  `${prefix}/storages/files/${storageId}/complete-upload`,
1956
2221
  { file_id: presignedResponse.file_id }
@@ -2218,7 +2483,11 @@ var FunctionsAPI = class {
2218
2483
  async call(functionId, payload) {
2219
2484
  const response = await this.invoke(functionId, payload);
2220
2485
  if (!response.success) {
2221
- throw new Error(response.error || "Function execution failed");
2486
+ throw new ApiError(
2487
+ 500,
2488
+ response.error || "Function execution failed",
2489
+ "FUNCTION_EXECUTION_FAILED"
2490
+ );
2222
2491
  }
2223
2492
  return response.result;
2224
2493
  }
@@ -2939,7 +3208,7 @@ var RealtimeAPI = class {
2939
3208
  }
2940
3209
  });
2941
3210
  } catch (e) {
2942
- console.error("[Realtime] Failed to parse message:", line, e);
3211
+ this.logError("Failed to parse message", { line, error: e });
2943
3212
  }
2944
3213
  }
2945
3214
  };
@@ -2956,7 +3225,7 @@ var RealtimeAPI = class {
2956
3225
  };
2957
3226
  this.ws.onerror = (error) => {
2958
3227
  this.log("WebSocket error occurred");
2959
- console.error("[Realtime] WebSocket error:", error);
3228
+ this.logError("WebSocket error", error);
2960
3229
  this.notifyError(new Error("WebSocket connection error"));
2961
3230
  if (!settled && this.state === "connecting") {
2962
3231
  settled = true;
@@ -2976,6 +3245,16 @@ var RealtimeAPI = class {
2976
3245
  console.log(`[Realtime] ${message}`);
2977
3246
  }
2978
3247
  }
3248
+ /**
3249
+ * 에러 로그. `options.debug` 가 true 일 때만 console 에 출력.
3250
+ * 운영 환경에서는 소비자 애플리케이션의 `ConnectBase({ onError })` 또는
3251
+ * `cb.errorTracker` 로 전달하는 것을 권장하므로 SDK 자체 console 출력은 opt-in.
3252
+ */
3253
+ logError(message, error) {
3254
+ if (this.options.debug) {
3255
+ console.error(`[Realtime] ${message}`, error);
3256
+ }
3257
+ }
2979
3258
  handleServerMessage(msg, connectResolve) {
2980
3259
  switch (msg.event) {
2981
3260
  case "connected": {
@@ -3186,7 +3465,7 @@ var RealtimeAPI = class {
3186
3465
  previousTypingHandlers
3187
3466
  );
3188
3467
  } catch (e) {
3189
- console.error("[Realtime] Reconnect failed:", e);
3468
+ this.logError("Reconnect failed", e);
3190
3469
  }
3191
3470
  }, delay);
3192
3471
  } else {
@@ -3218,7 +3497,7 @@ var RealtimeAPI = class {
3218
3497
  this.subscriptions.set(category, { info, handlers });
3219
3498
  this.log(`Restored subscription: ${category}`);
3220
3499
  } catch (e) {
3221
- console.error(`[Realtime] Failed to restore subscription for ${category}:`, e);
3500
+ this.logError(`Failed to restore subscription for ${category}`, e);
3222
3501
  this.notifyError(new Error(`Failed to restore subscription: ${category}`));
3223
3502
  }
3224
3503
  }
@@ -3235,7 +3514,7 @@ var RealtimeAPI = class {
3235
3514
  this.presenceSubscriptions.set(userId, handlers);
3236
3515
  this.log(`Restored presence subscription: ${userId}`);
3237
3516
  } catch (e) {
3238
- console.error(`[Realtime] Failed to restore presence subscription for ${userId}:`, e);
3517
+ this.logError(`Failed to restore presence subscription for ${userId}`, e);
3239
3518
  }
3240
3519
  }
3241
3520
  for (const [roomId, handlers] of previousTypingHandlers) {
@@ -3251,7 +3530,7 @@ var RealtimeAPI = class {
3251
3530
  this.typingHandlers.set(roomId, handlers);
3252
3531
  this.log(`Restored typing subscription: ${roomId}`);
3253
3532
  } catch (e) {
3254
- console.error(`[Realtime] Failed to restore typing subscription for ${roomId}:`, e);
3533
+ this.logError(`Failed to restore typing subscription for ${roomId}`, e);
3255
3534
  }
3256
3535
  }
3257
3536
  }
@@ -4362,7 +4641,19 @@ var PaymentAPI = class {
4362
4641
  */
4363
4642
  async createCheckoutSession(data) {
4364
4643
  const prefix = this.getPublicPrefix();
4365
- return this.http.post(`${prefix}/payments/checkout-session`, data);
4644
+ const response = await this.http.post(
4645
+ `${prefix}/payments/checkout-session`,
4646
+ data
4647
+ );
4648
+ assertShape(
4649
+ response,
4650
+ {
4651
+ session_id: { type: "string" },
4652
+ session_url: { type: "string" }
4653
+ },
4654
+ "payment.createCheckoutSession"
4655
+ );
4656
+ return response;
4366
4657
  }
4367
4658
  /**
4368
4659
  * 결제 승인
@@ -4576,7 +4867,19 @@ var SubscriptionAPI = class {
4576
4867
  */
4577
4868
  async create(data) {
4578
4869
  const prefix = this.getPublicPrefix();
4579
- return this.http.post(`${prefix}/subscriptions`, data);
4870
+ const response = await this.http.post(
4871
+ `${prefix}/subscriptions`,
4872
+ data
4873
+ );
4874
+ assertShape(
4875
+ response,
4876
+ {
4877
+ id: { type: "string-or-number" },
4878
+ status: { type: "string" }
4879
+ },
4880
+ "subscription.create"
4881
+ );
4882
+ return response;
4580
4883
  }
4581
4884
  /**
4582
4885
  * 구독 목록 조회
@@ -4785,7 +5088,18 @@ var PushAPI = class {
4785
5088
  */
4786
5089
  async registerDevice(request) {
4787
5090
  const prefix = this.getPublicPrefix();
4788
- return this.http.post(`${prefix}/push/devices`, request);
5091
+ const response = await this.http.post(
5092
+ `${prefix}/push/devices`,
5093
+ request
5094
+ );
5095
+ assertShape(
5096
+ response,
5097
+ {
5098
+ device_token: { type: "string" }
5099
+ },
5100
+ "push.registerDevice"
5101
+ );
5102
+ return response;
4789
5103
  }
4790
5104
  /**
4791
5105
  * 디바이스 등록 해제
@@ -5151,21 +5465,39 @@ var VideoAPI = class {
5151
5465
  if (body && !(body instanceof FormData)) {
5152
5466
  headers["Content-Type"] = "application/json";
5153
5467
  }
5154
- const response = await fetch(`${this.videoBaseUrl}${path}`, {
5155
- method,
5156
- headers,
5157
- body: body instanceof FormData ? body : body ? JSON.stringify(body) : void 0
5468
+ const { signal, cleanup } = createTimeoutController({
5469
+ timeout: DEFAULT_REQUEST_TIMEOUT_MS
5158
5470
  });
5159
- if (!response.ok) {
5160
- const errorData = await response.json().catch(() => ({
5161
- message: response.statusText
5162
- }));
5163
- throw new ApiError(response.status, errorData.message || "Unknown error");
5164
- }
5165
- if (response.status === 204 || response.headers.get("content-length") === "0") {
5166
- return {};
5471
+ try {
5472
+ const response = await fetch(`${this.videoBaseUrl}${path}`, {
5473
+ method,
5474
+ headers,
5475
+ body: body instanceof FormData ? body : body ? JSON.stringify(body) : void 0,
5476
+ signal
5477
+ });
5478
+ if (!response.ok) {
5479
+ const errorData = await response.json().catch(() => ({
5480
+ message: response.statusText
5481
+ }));
5482
+ const rawError = errorData.error;
5483
+ if (rawError && typeof rawError === "object" && "message" in rawError) {
5484
+ throw new ApiError(
5485
+ response.status,
5486
+ rawError.message || "Unknown error",
5487
+ rawError.code,
5488
+ rawError.details
5489
+ );
5490
+ }
5491
+ const message = typeof rawError === "string" ? rawError : errorData.message || "Unknown error";
5492
+ throw new ApiError(response.status, message);
5493
+ }
5494
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
5495
+ return {};
5496
+ }
5497
+ return await response.json();
5498
+ } finally {
5499
+ cleanup();
5167
5500
  }
5168
- return response.json();
5169
5501
  }
5170
5502
  // ========== Video Operations ==========
5171
5503
  /**
@@ -5257,7 +5589,29 @@ var VideoAPI = class {
5257
5589
  throw new VideoProcessingError("Timeout waiting for video to be ready");
5258
5590
  }
5259
5591
  /**
5260
- * List videos
5592
+ * 영상 목록 조회.
5593
+ *
5594
+ * 필터링/페이지네이션 옵션을 조합해 조건에 맞는 영상들을 반환한다.
5595
+ * API Key(publicKey) 또는 JWT 인증이 모두 지원되며, publicKey 로는 공개 영상만 노출된다.
5596
+ *
5597
+ * @param options - 필터/페이지네이션 옵션
5598
+ * @param options.status - `uploading` | `processing` | `ready` | `failed`
5599
+ * @param options.visibility - `public` | `unlisted` | `private` | `members`
5600
+ * @param options.search - 제목 부분 일치 검색
5601
+ * @param options.channel_id - 특정 채널로 한정
5602
+ * @param options.page - 1 부터 시작
5603
+ * @param options.limit - 기본 20, 최대 100
5604
+ * @returns `videos` 배열과 `total` 카운트를 포함하는 응답
5605
+ *
5606
+ * @example
5607
+ * ```ts
5608
+ * // 내 채널의 최근 공개 영상 10개
5609
+ * const { videos, total } = await cb.video.list({
5610
+ * channel_id: 'ch_abc',
5611
+ * visibility: 'public',
5612
+ * limit: 10,
5613
+ * })
5614
+ * ```
5261
5615
  */
5262
5616
  async list(options) {
5263
5617
  const prefix = this.getPublicPrefix();
@@ -5293,7 +5647,20 @@ var VideoAPI = class {
5293
5647
  await this.videoFetch("DELETE", `${prefix}/videos/${videoId}`);
5294
5648
  }
5295
5649
  /**
5296
- * Get streaming URL for a video
5650
+ * 영상 스트리밍 URL 획득 (HLS manifest 권장).
5651
+ *
5652
+ * 반환된 URL 은 `hls.js` 또는 Safari 네이티브 HLS 플레이어에 그대로 전달 가능하다.
5653
+ * 서명된 URL 이므로 만료 시간이 존재하며, 갱신이 필요한 경우 다시 호출한다.
5654
+ *
5655
+ * @param videoId - 대상 영상 ID
5656
+ * @param quality - `auto` (기본) | `1080p` | `720p` | `480p` | `360p` 등 트랜스코딩된 품질 레벨
5657
+ * @returns HLS manifest URL 과 만료 정보
5658
+ *
5659
+ * @example
5660
+ * ```ts
5661
+ * const { url, expires_at } = await cb.video.getStreamUrl(videoId, '720p')
5662
+ * videoElement.src = url
5663
+ * ```
5297
5664
  */
5298
5665
  async getStreamUrl(videoId, quality) {
5299
5666
  const prefix = this.getPublicPrefix();
@@ -6148,7 +6515,11 @@ var GameAPI = class {
6148
6515
  headers: this.getHeaders()
6149
6516
  });
6150
6517
  if (!response.ok) {
6151
- throw new Error(`Failed to list rooms: ${response.statusText}`);
6518
+ throw new ApiError(
6519
+ response.status,
6520
+ `Failed to list rooms: ${response.statusText}`,
6521
+ "GAME_LIST_ROOMS_FAILED"
6522
+ );
6152
6523
  }
6153
6524
  const data = await response.json();
6154
6525
  return data.rooms;
@@ -6162,7 +6533,11 @@ var GameAPI = class {
6162
6533
  headers: this.getHeaders()
6163
6534
  });
6164
6535
  if (!response.ok) {
6165
- throw new Error(`Failed to get room: ${response.statusText}`);
6536
+ throw new ApiError(
6537
+ response.status,
6538
+ `Failed to get room: ${response.statusText}`,
6539
+ "GAME_GET_ROOM_FAILED"
6540
+ );
6166
6541
  }
6167
6542
  return response.json();
6168
6543
  }
@@ -7628,7 +8003,17 @@ var QueueAPI = class {
7628
8003
  if (options?.visibility_timeout) params.set("visibility_timeout", String(options.visibility_timeout));
7629
8004
  if (options?.auto_ack !== void 0) params.set("auto_ack", String(options.auto_ack));
7630
8005
  const query = params.toString();
7631
- return this.http.get(`/v1/public/queues/${queueID}/messages${query ? `?${query}` : ""}`);
8006
+ const response = await this.http.get(
8007
+ `/v1/public/queues/${queueID}/messages${query ? `?${query}` : ""}`
8008
+ );
8009
+ assertShape(
8010
+ response,
8011
+ {
8012
+ messages: { type: "array" }
8013
+ },
8014
+ "queue.consume"
8015
+ );
8016
+ return response;
7632
8017
  }
7633
8018
  /**
7634
8019
  * 메시지 처리 완료 확인 (Ack)
@@ -7999,11 +8384,33 @@ var AnalyticsAPI = class {
7999
8384
  sendHeartbeat();
8000
8385
  this.log("Heartbeat enabled (30s interval)");
8001
8386
  }
8002
- /** 큐에 있는 이벤트 즉시 전송 */
8387
+ /**
8388
+ * 큐에 있는 이벤트 즉시 전송.
8389
+ *
8390
+ * 기본적으로 이벤트는 배치(10개) 또는 주기(5초)로 flush 되지만, 페이지 이탈 직전이나
8391
+ * 결정적 이벤트(결제 완료 등)는 수동으로 `flush()` 를 호출해 전송 지연을 줄일 수 있다.
8392
+ *
8393
+ * @returns 서버 응답 완료 시까지 대기하는 Promise
8394
+ *
8395
+ * @example
8396
+ * ```ts
8397
+ * // 결제 완료 직후 이탈에 대비해 즉시 flush
8398
+ * await cb.analytics.track('purchase_completed', { order_id: '123' })
8399
+ * await cb.analytics.flush()
8400
+ * window.location.href = '/thank-you'
8401
+ * ```
8402
+ */
8003
8403
  async flush() {
8004
8404
  await this.flushQueue();
8005
8405
  }
8006
- /** 세션 매니저 접근 (고급) */
8406
+ /**
8407
+ * 세션 매니저 접근 (고급 사용자용).
8408
+ *
8409
+ * 외부에서 세션 ID 를 읽어 자체 로깅에 합치거나 강제 세션 종료/재시작이 필요한 경우
8410
+ * 사용. 일반적으로는 AnalyticsAPI 가 내부적으로 세션을 관리하므로 호출할 필요가 없다.
8411
+ *
8412
+ * @returns 내부 SessionManager 인스턴스
8413
+ */
8007
8414
  getSession() {
8008
8415
  return this.session;
8009
8416
  }
@@ -8920,6 +9327,8 @@ var ConnectBase = class {
8920
9327
  publicKey: config.publicKey,
8921
9328
  secretKey: config.secretKey,
8922
9329
  persistence: config.persistence,
9330
+ requestTimeoutMs: config.requestTimeoutMs,
9331
+ onError: config.onError,
8923
9332
  onTokenRefresh: config.onTokenRefresh,
8924
9333
  onAuthError: config.onAuthError,
8925
9334
  onTokenExpired: config.onTokenExpired