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/CHANGELOG.md +59 -0
- package/README.md +37 -4
- package/dist/connect-base.umd.js +3 -3
- package/dist/index.d.mts +120 -10
- package/dist/index.d.ts +120 -10
- package/dist/index.js +493 -84
- package/dist/index.mjs +493 -84
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5193
|
-
|
|
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
|
-
|
|
5198
|
-
const
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|