connectbase-client 3.9.0 → 3.11.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
@@ -49,6 +49,40 @@ function createTimeoutController(options = {}) {
49
49
  };
50
50
  }
51
51
 
52
+ // src/core/recent-calls.ts
53
+ var DEFAULT_CAPACITY = 20;
54
+ var MAX_CAPACITY = 50;
55
+ var RecentCallsBuffer = class {
56
+ constructor(capacity = DEFAULT_CAPACITY) {
57
+ this.buf = [];
58
+ this.capacity = Math.min(Math.max(1, capacity), MAX_CAPACITY);
59
+ }
60
+ push(call) {
61
+ if (this.buf.length >= this.capacity) {
62
+ this.buf.shift();
63
+ }
64
+ this.buf.push(call);
65
+ }
66
+ /**
67
+ * 시간순(오래된 → 최신) 복사본 반환. 호출자가 mutate 해도 내부에 영향 없음.
68
+ */
69
+ snapshot() {
70
+ return this.buf.slice();
71
+ }
72
+ clear() {
73
+ this.buf = [];
74
+ }
75
+ };
76
+ function sanitizePathForBreadcrumb(rawUrl) {
77
+ try {
78
+ const u = rawUrl.startsWith("http") ? new URL(rawUrl) : new URL(rawUrl, "http://x");
79
+ return u.pathname || rawUrl;
80
+ } catch {
81
+ const idx = rawUrl.indexOf("?");
82
+ return idx >= 0 ? rawUrl.slice(0, idx) : rawUrl;
83
+ }
84
+ }
85
+
52
86
  // src/core/http.ts
53
87
  var TOKEN_STORAGE_KEY = "cb_auth_tokens";
54
88
  var HttpClient = class {
@@ -59,11 +93,24 @@ var HttpClient = class {
59
93
  // 밀리초 단위로 서버에 재시도 요청이 쏟아지는 것을 차단.
60
94
  this.refreshFailureCount = 0;
61
95
  this.refreshLockedUntil = 0;
96
+ /**
97
+ * 최근 API 호출 breadcrumb (PII strip 후 저장). platform_issue 발행 시 자동 첨부 가능.
98
+ * `client.support.getRecentCalls()` 로 외부 노출.
99
+ */
100
+ this.recentCalls = new RecentCallsBuffer();
62
101
  this.config = { ...config };
63
102
  this.storageKey = this.buildStorageKey();
64
103
  this.warnIfUnsafePersistence();
65
104
  this.restoreTokens();
66
105
  }
106
+ /** 최근 호출 ring buffer 스냅샷 (시간순). */
107
+ getRecentCalls() {
108
+ return this.recentCalls.snapshot();
109
+ }
110
+ /** 최근 호출 buffer clear (테스트/프라이버시 처리). */
111
+ clearRecentCalls() {
112
+ this.recentCalls.clear();
113
+ }
67
114
  warnIfUnsafePersistence() {
68
115
  if (typeof window === "undefined") return;
69
116
  if (this.config.persistence === "localStorage") {
@@ -220,6 +267,7 @@ var HttpClient = class {
220
267
  const { signal, cleanup } = createTimeoutController({
221
268
  timeout: this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
222
269
  });
270
+ let failureKind = "transient";
223
271
  try {
224
272
  const headers = {
225
273
  "Content-Type": "application/json"
@@ -238,7 +286,28 @@ var HttpClient = class {
238
286
  signal
239
287
  });
240
288
  if (!response.ok) {
241
- throw new Error("Token refresh failed");
289
+ const status = response.status;
290
+ let oauthError;
291
+ try {
292
+ const errBody = await response.clone().json();
293
+ if (errBody && typeof errBody.error === "string") {
294
+ oauthError = errBody.error;
295
+ } else if (errBody && typeof errBody.code === "string") {
296
+ oauthError = errBody.code;
297
+ }
298
+ } catch {
299
+ }
300
+ if (status >= 500) {
301
+ throw new Error(`Token refresh failed (${status})`);
302
+ }
303
+ if (status === 401 || status === 403 || status === 400 && (oauthError === "invalid_grant" || oauthError === "invalid_token")) {
304
+ failureKind = "permanent";
305
+ } else {
306
+ failureKind = "client_bug";
307
+ }
308
+ throw new Error(
309
+ `Token refresh failed (${status}${oauthError ? ` ${oauthError}` : ""})`
310
+ );
242
311
  }
243
312
  const data = await response.json();
244
313
  if (!data || typeof data.access_token !== "string") {
@@ -258,17 +327,35 @@ var HttpClient = class {
258
327
  this.refreshFailureCount = 0;
259
328
  this.refreshLockedUntil = 0;
260
329
  return data.access_token;
261
- } catch {
330
+ } catch (e) {
331
+ const baseMsg = e instanceof Error ? e.message : "Token refresh failed";
262
332
  this.refreshFailureCount++;
263
333
  const backoffMs = Math.min(
264
334
  500 * 2 ** Math.max(0, this.refreshFailureCount - 1),
265
335
  3e4
266
336
  );
267
337
  this.refreshLockedUntil = Date.now() + backoffMs;
268
- this.clearTokens();
269
- this.config.onTokenExpired?.();
270
- const error = new AuthError("Token refresh failed. Please login again.");
338
+ if (failureKind === "permanent") {
339
+ this.clearTokens();
340
+ this.config.onTokenExpired?.();
341
+ const error2 = new AuthError(`${baseMsg}. Please login again.`);
342
+ this.emitError(error2);
343
+ this.config.onAuthError?.(error2);
344
+ throw error2;
345
+ }
346
+ if (failureKind === "client_bug") {
347
+ const error2 = new AuthError(
348
+ `${baseMsg}. Client request invalid; tokens preserved.`
349
+ );
350
+ this.emitError(error2);
351
+ this.config.onAuthError?.(error2);
352
+ throw error2;
353
+ }
354
+ const error = new AuthError(
355
+ `${baseMsg}. Transient failure; tokens preserved, will retry after backoff.`
356
+ );
271
357
  this.emitError(error);
358
+ this.config.onTransientRefreshFailure?.(error);
272
359
  this.config.onAuthError?.(error);
273
360
  throw error;
274
361
  } finally {
@@ -397,14 +484,24 @@ var HttpClient = class {
397
484
  timeout: config?.timeout ?? this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
398
485
  signal: config?.signal
399
486
  });
487
+ const startedAt = Date.now();
488
+ let status = 0;
400
489
  try {
401
490
  const response = await fetch(`${this.config.baseUrl}${url}`, {
402
491
  ...init,
403
492
  credentials: "include",
404
493
  signal
405
494
  });
495
+ status = response.status;
406
496
  return await this.handleResponse(response);
407
497
  } finally {
498
+ this.recentCalls.push({
499
+ method: (init.method || "GET").toUpperCase(),
500
+ path: sanitizePathForBreadcrumb(url),
501
+ status,
502
+ duration_ms: Date.now() - startedAt,
503
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
504
+ });
408
505
  cleanup();
409
506
  }
410
507
  }
@@ -6132,6 +6229,16 @@ var getDefaultGameServerUrl = () => {
6132
6229
  }
6133
6230
  return "wss://game.connectbase.world";
6134
6231
  };
6232
+ function toCreateRoomWire(config) {
6233
+ const out = {};
6234
+ if (config.roomId) out.room_id = config.roomId;
6235
+ if (config.categoryId) out.category_id = config.categoryId;
6236
+ if (typeof config.tickRate === "number") out.tick_rate = config.tickRate;
6237
+ if (typeof config.maxPlayers === "number") out.max_players = config.maxPlayers;
6238
+ if (config.scriptName) out.script_name = config.scriptName;
6239
+ if (config.metadata) out.metadata = config.metadata;
6240
+ return out;
6241
+ }
6135
6242
  var GameRoom = class {
6136
6243
  constructor(config) {
6137
6244
  this.ws = null;
@@ -6250,7 +6357,7 @@ var GameRoom = class {
6250
6357
  }
6251
6358
  return false;
6252
6359
  };
6253
- this.sendWithHandler("create_room", config, handler, 15e3, reject);
6360
+ this.sendWithHandler("create_room", toCreateRoomWire(config), handler, 15e3, reject);
6254
6361
  });
6255
6362
  }
6256
6363
  /**
@@ -8966,7 +9073,148 @@ var SupportAPI = class {
8966
9073
  if (req.recaptchaToken) body.recaptcha_token = req.recaptchaToken;
8967
9074
  return this.http.post("/v1/public/reports", body);
8968
9075
  }
9076
+ /**
9077
+ * ConnectBase 플랫폼 자체 버그/요청/문의를 발행한다.
9078
+ *
9079
+ * `reportIssue` 와 다른 점: target 이 앱 운영자가 아닌 **ConnectBase 운영팀**.
9080
+ * SDK 가 자체 버그를 던지거나, 결제/문서/플랫폼 동작이 이상할 때 사용.
9081
+ *
9082
+ * 자동 첨부 (opt-out 가능):
9083
+ * - SDK 버전 + 플랫폼 (web/node)
9084
+ * - 마지막 N(20)개 API 호출 breadcrumb (PII strip 후)
9085
+ * - error 객체의 stack trace (sanitize)
9086
+ *
9087
+ * @example SDK 가 throw 한 에러를 자동 첨부해서 발행
9088
+ * ```typescript
9089
+ * try {
9090
+ * await cb.functions.invoke('foo', {})
9091
+ * } catch (err) {
9092
+ * await cb.support.reportPlatformBug({
9093
+ * title: 'functions.invoke 가 504 만 반환',
9094
+ * body: '같은 인자로 5분 째 504. 콘솔에선 정상.',
9095
+ * category: 'sdk',
9096
+ * severity: 'high',
9097
+ * error: err as Error,
9098
+ * })
9099
+ * }
9100
+ * ```
9101
+ */
9102
+ async reportPlatformBug(req) {
9103
+ const attachContext = req.attachAutomaticContext !== false;
9104
+ const body = {
9105
+ title: req.title,
9106
+ body: req.body,
9107
+ category: req.category ?? "other",
9108
+ severity: req.severity ?? "medium",
9109
+ metadata: req.metadata
9110
+ };
9111
+ if (req.reporterEmail) body.reporter_email = req.reporterEmail;
9112
+ if (req.recaptchaToken) body.recaptcha_token = req.recaptchaToken;
9113
+ if (attachContext) {
9114
+ body.sdk_version = req.sdkVersion ?? detectSdkVersion();
9115
+ body.sdk_platform = req.sdkPlatform ?? detectSdkPlatform();
9116
+ body.environment = req.environment ?? "unknown";
9117
+ if (req.error) {
9118
+ body.stack_trace = sanitizeStackTrace(req.error.stack ?? String(req.error));
9119
+ } else if (req.stackTrace) {
9120
+ body.stack_trace = sanitizeStackTrace(req.stackTrace);
9121
+ }
9122
+ const calls = req.recentApiCalls ?? this.http.getRecentCalls();
9123
+ if (calls.length > 0) {
9124
+ body.recent_api_calls = calls;
9125
+ }
9126
+ }
9127
+ return this.http.post(
9128
+ "/v1/public/platform-issues",
9129
+ body
9130
+ );
9131
+ }
9132
+ /** 디버깅용: 마지막 N개 API 호출 breadcrumb. PII 제거 후 저장돼있다. */
9133
+ getRecentApiCalls() {
9134
+ return this.http.getRecentCalls();
9135
+ }
9136
+ /** breadcrumb buffer 비우기 (사용자 명시적 요청 / 프라이버시). */
9137
+ clearRecentApiCalls() {
9138
+ this.http.clearRecentCalls();
9139
+ }
9140
+ /**
9141
+ * Platform issue 의 reply thread 조회.
9142
+ *
9143
+ * 본인이 발행한 issue 만 조회 가능 (server-side ownership guard). admin 의 internal 메모는 응답 제외.
9144
+ *
9145
+ * @param issueId - `reportPlatformBug` 가 반환한 id
9146
+ * @throws ApiError 404 — 본인 issue 가 아니거나 존재하지 않음
9147
+ */
9148
+ async listPlatformIssueReplies(issueId) {
9149
+ const res = await this.http.get(
9150
+ `/v1/public/platform-issues/${issueId}/comments`
9151
+ );
9152
+ return res.comments;
9153
+ }
9154
+ /**
9155
+ * Platform issue 에 follow-up reply 작성.
9156
+ *
9157
+ * 단말 상태(resolved/wontfix/duplicate) issue 는 reply 거부 — 새 issue 발행 권장.
9158
+ */
9159
+ async replyToPlatformIssue(issueId, body) {
9160
+ return this.http.post(
9161
+ `/v1/public/platform-issues/${issueId}/comments`,
9162
+ { body }
9163
+ );
9164
+ }
9165
+ /**
9166
+ * 본인이 발행한 platform issue 의 처리 진행 상황을 단건 조회.
9167
+ *
9168
+ * AI 가 "내가 발행한 이슈 처리됐어?" 를 폴링하는 표준 경로. status / resolution_note /
9169
+ * triage_summary / external_links 로 ConnectBase 운영팀의 처리 상태를 확인.
9170
+ *
9171
+ * @throws ApiError 404 — 본인 issue 가 아니거나 존재하지 않음
9172
+ */
9173
+ async getPlatformIssue(issueId) {
9174
+ return this.http.get(
9175
+ `/v1/public/platform-issues/${issueId}`
9176
+ );
9177
+ }
9178
+ /**
9179
+ * 본인 app 으로 발행한 platform issue 목록 (cursor 페이지네이션).
9180
+ *
9181
+ * status/severity/category 필터 + `since_updated_at` 으로 미해결만 폴링하는 사용 패턴 권장:
9182
+ *
9183
+ * ```typescript
9184
+ * const { issues } = await cb.support.listMyPlatformIssues({ status: ['open', 'triaged', 'in_progress'] })
9185
+ * ```
9186
+ */
9187
+ async listMyPlatformIssues(opts = {}) {
9188
+ const params = new URLSearchParams();
9189
+ for (const s of opts.status ?? []) params.append("status", s);
9190
+ for (const s of opts.severity ?? []) params.append("severity", s);
9191
+ for (const s of opts.category ?? []) params.append("category", s);
9192
+ if (opts.sinceUpdatedAt) params.set("since_updated_at", opts.sinceUpdatedAt);
9193
+ if (opts.cursor) params.set("cursor", opts.cursor);
9194
+ if (typeof opts.limit === "number") params.set("limit", String(opts.limit));
9195
+ const query = params.toString();
9196
+ return this.http.get(
9197
+ `/v1/public/platform-issues${query ? "?" + query : ""}`
9198
+ );
9199
+ }
8969
9200
  };
9201
+ function detectSdkVersion() {
9202
+ if (typeof __SDK_VERSION__ !== "undefined" && __SDK_VERSION__) {
9203
+ return __SDK_VERSION__;
9204
+ }
9205
+ return "unknown";
9206
+ }
9207
+ function detectSdkPlatform() {
9208
+ return typeof window !== "undefined" && typeof document !== "undefined" ? "web" : "node";
9209
+ }
9210
+ function sanitizeStackTrace(raw) {
9211
+ let s = raw;
9212
+ s = s.replace(/(\(|\s|^)(?:file:\/\/)?\/[^\s)]+\/([^\s/)]+)(:\d+:\d+)?/g, "$1$2$3");
9213
+ s = s.replace(/(https?:\/\/[^\s)?]+)\?[^\s)]*/g, "$1");
9214
+ const max = 32 * 1024;
9215
+ if (s.length > max) s = s.slice(0, max) + "\n\u2026[truncated]";
9216
+ return s;
9217
+ }
8970
9218
 
8971
9219
  // src/types/knowledge.ts
8972
9220
  var AUTH_MEMBER_ID_TOKEN = "$auth.member_id";
@@ -9350,7 +9598,7 @@ var GameRoomTransport = class {
9350
9598
  reject(new Error(msg.data.message));
9351
9599
  }
9352
9600
  };
9353
- this.sendWithHandler("create_room", config, handler);
9601
+ this.sendWithHandler("create_room", toCreateRoomWire(config), handler);
9354
9602
  });
9355
9603
  }
9356
9604
  /**
@@ -9632,7 +9880,8 @@ var ConnectBase = class {
9632
9880
  onError: config.onError,
9633
9881
  onTokenRefresh: config.onTokenRefresh,
9634
9882
  onAuthError: config.onAuthError,
9635
- onTokenExpired: config.onTokenExpired
9883
+ onTokenExpired: config.onTokenExpired,
9884
+ onTransientRefreshFailure: config.onTransientRefreshFailure
9636
9885
  };
9637
9886
  this.http = new HttpClient(httpConfig);
9638
9887
  this.auth = new AuthAPI(this.http);
@@ -9710,5 +9959,6 @@ export {
9710
9959
  SessionManager,
9711
9960
  VideoProcessingError,
9712
9961
  index_default as default,
9713
- isWebTransportSupported
9962
+ isWebTransportSupported,
9963
+ toCreateRoomWire
9714
9964
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "connectbase-client",
3
- "version": "3.9.0",
3
+ "version": "3.11.0",
4
4
  "description": "Connect Base JavaScript/TypeScript SDK for browser and Node.js",
5
5
  "repository": {
6
6
  "type": "git",