connectbase-client 1.8.0 → 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/CHANGELOG.md +58 -0
- package/README.md +47 -7
- package/dist/connect-base.umd.js +3 -3
- package/dist/index.d.mts +116 -6
- package/dist/index.d.ts +116 -6
- package/dist/index.js +493 -84
- package/dist/index.mjs +493 -84
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5155
|
-
|
|
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
|
-
|
|
5160
|
-
const
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|