connectbase-client 3.31.0 → 3.33.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.d.mts CHANGED
@@ -2951,6 +2951,21 @@ interface RealtimeConnectOptions {
2951
2951
  timeout?: number;
2952
2952
  /** 디버그 로깅 활성화 (기본: false) */
2953
2953
  debug?: boolean;
2954
+ /**
2955
+ * WebSocket 핸드셰이크가 실패할 때의 fallback transport (기본: `'sse'`).
2956
+ *
2957
+ * 일부 네트워크(특정 가정용 ISP/기업 프록시/미들박스)는 HTTPS 는 통과시키면서
2958
+ * WebSocket upgrade 만 차단한다. 이 경우 `'sse'` 이면 `connect()` 가 reject 하지 않고
2959
+ * **degraded(SSE) 모드로 resolve** 하며, 이후 `stream()` 호출은 자동으로 HTTP SSE
2960
+ * (`/v1/public/ai/chat/stream`) 로 흘러 AI 스트리밍이 계속 동작한다 (앱 코드 변경 불필요).
2961
+ *
2962
+ * SSE 는 단방향(server→client)이므로 fallback 모드에서는 AI `stream()` 만 지원되고
2963
+ * presence/typing/subscribe/sendMessage 같은 양방향 기능은 명시적 에러를 던진다.
2964
+ * 현재 활성 transport 는 `cb.realtime.transport` 로 확인할 수 있다.
2965
+ *
2966
+ * `'none'` 이면 기존처럼 WS 실패 시 `connect()` 가 reject 한다.
2967
+ */
2968
+ fallback?: 'sse' | 'none';
2954
2969
  }
2955
2970
  /** 이벤트 핸들러 타입 */
2956
2971
  type MessageHandler<T = unknown> = (message: RealtimeMessage<T>) => void;
@@ -3171,10 +3186,26 @@ declare class RealtimeAPI {
3171
3186
  private clientId;
3172
3187
  private userId?;
3173
3188
  private _appId;
3189
+ /**
3190
+ * 현재 활성 transport.
3191
+ * - `'ws'`: WebSocket 정상 연결
3192
+ * - `'sse'`: WS 핸드셰이크 실패로 HTTP SSE fallback 모드 (AI `stream()` 만 지원)
3193
+ * - `null`: 미연결
3194
+ */
3195
+ private activeTransport;
3196
+ /** SSE fallback 모드 활성 여부. true 면 백그라운드 WS 재연결 머신을 정지한다. */
3197
+ private sseFallbackActive;
3198
+ /** SSE fallback 스트림의 AbortController (sessionId → controller) — stop()/disconnect() 에서 중단용 */
3199
+ private sseSessions;
3174
3200
  /** 현재 연결 ID */
3175
3201
  get connectionId(): string | null;
3176
3202
  /** 현재 연결된 앱 ID */
3177
3203
  get appId(): string | null;
3204
+ /**
3205
+ * 현재 활성 transport (`'ws'` | `'sse'` | `null`).
3206
+ * `'sse'` 이면 WS 가 차단되어 fallback 모드로 동작 중이며 AI `stream()` 만 가능하다.
3207
+ */
3208
+ get transport(): 'ws' | 'sse' | null;
3178
3209
  private options;
3179
3210
  private retryCount;
3180
3211
  private pendingRequests;
@@ -3195,6 +3226,11 @@ declare class RealtimeAPI {
3195
3226
  * - userId: 사용자 식별자 (표시용)
3196
3227
  */
3197
3228
  connect(options?: RealtimeConnectOptions): Promise<void>;
3229
+ /**
3230
+ * SSE fallback 모드로 전환한다. WS 는 사용 불가하지만 state 는 'connected' 로 두어
3231
+ * (degraded) AI stream() 이 SSE 로 동작하게 한다. 백그라운드 WS 재연결 머신은 정지한다.
3232
+ */
3233
+ private activateSseFallback;
3198
3234
  /**
3199
3235
  * 연결 해제
3200
3236
  */
@@ -3251,6 +3287,17 @@ declare class RealtimeAPI {
3251
3287
  * ```
3252
3288
  */
3253
3289
  stream(messages: StreamMessage[], handlers: StreamHandlers, options?: StreamOptions): Promise<StreamSession>;
3290
+ /** WebSocket 으로 AI 스트리밍 (기본 경로) */
3291
+ private streamViaWS;
3292
+ /**
3293
+ * HTTP SSE (`/v1/public/ai/chat/stream`) 로 AI 스트리밍 — WS 가 차단된 네트워크용 fallback.
3294
+ *
3295
+ * SSE 는 단방향이라 WS 의 `StreamDoneData` 토큰 카운트(total/prompt)를 받지 못하므로
3296
+ * `totalTokens`/`promptTokens` 는 0 으로 채워진다(`fullText`/`duration` 은 정확). `mcpGroup`
3297
+ * (MCP 도구) 은 SSE fallback 에서 미지원 — 지정 시 무시되고 도구 없이 스트리밍한다.
3298
+ */
3299
+ private streamViaSSE;
3300
+ private runSseStream;
3254
3301
  /**
3255
3302
  * 스트리밍 중지
3256
3303
  */
@@ -3438,6 +3485,11 @@ declare class RealtimeAPI {
3438
3485
  * 재연결 성공 후 호출됩니다.
3439
3486
  */
3440
3487
  private restoreSubscriptions;
3488
+ /**
3489
+ * 양방향(WS 전용) 기능을 SSE fallback 모드에서 호출하면 명확한 에러를 던진다.
3490
+ * SSE 는 단방향이라 presence/typing/subscribe/sendMessage 를 지원하지 못한다.
3491
+ */
3492
+ private assertBidirectional;
3441
3493
  private sendRequest;
3442
3494
  private sendRaw;
3443
3495
  private notifyStateChange;
@@ -7543,6 +7595,21 @@ interface AIChatRequest {
7543
7595
  topK?: number;
7544
7596
  agentic?: boolean;
7545
7597
  toolGroupId?: string;
7598
+ /**
7599
+ * 0 보다 크면, 서버측 도구 그룹 실행(`toolGroupId` 사용 시)에서 각 도구 결과를 모델에
7600
+ * **재투입하기 전** N 자(rune 기준)로 잘라낸다(말미에 잘림 표시). SSE `onToolEvent` 의
7601
+ * `tool_end.result` 로 표시되는 값은 원문을 유지한다.
7602
+ *
7603
+ * 거대한 도구 결과(예: 복잡한 페이지의 `browser_snapshot`)가 누적 컨텍스트를 부풀려
7604
+ * 매 턴 prefill 을 무겁게 만들 때, 표시는 원문으로 두고 모델 재투입분만 압축해
7605
+ * 지연을 줄이는 용도. 미지정/0 이면 자르지 않는다.
7606
+ */
7607
+ toolResultMaxChars?: number;
7608
+ /**
7609
+ * 요청에 포함한 MCP 도구(`tools`)를 호출할 외부 MCP 서버의 Public Key (cb_pk_* 형식).
7610
+ * MCP 도구를 직접 넘기지 않으면 불필요하다.
7611
+ */
7612
+ mcpPublicKey?: string;
7546
7613
  }
7547
7614
  /**
7548
7615
  * `chatStream` 콜백.
package/dist/index.d.ts CHANGED
@@ -2951,6 +2951,21 @@ interface RealtimeConnectOptions {
2951
2951
  timeout?: number;
2952
2952
  /** 디버그 로깅 활성화 (기본: false) */
2953
2953
  debug?: boolean;
2954
+ /**
2955
+ * WebSocket 핸드셰이크가 실패할 때의 fallback transport (기본: `'sse'`).
2956
+ *
2957
+ * 일부 네트워크(특정 가정용 ISP/기업 프록시/미들박스)는 HTTPS 는 통과시키면서
2958
+ * WebSocket upgrade 만 차단한다. 이 경우 `'sse'` 이면 `connect()` 가 reject 하지 않고
2959
+ * **degraded(SSE) 모드로 resolve** 하며, 이후 `stream()` 호출은 자동으로 HTTP SSE
2960
+ * (`/v1/public/ai/chat/stream`) 로 흘러 AI 스트리밍이 계속 동작한다 (앱 코드 변경 불필요).
2961
+ *
2962
+ * SSE 는 단방향(server→client)이므로 fallback 모드에서는 AI `stream()` 만 지원되고
2963
+ * presence/typing/subscribe/sendMessage 같은 양방향 기능은 명시적 에러를 던진다.
2964
+ * 현재 활성 transport 는 `cb.realtime.transport` 로 확인할 수 있다.
2965
+ *
2966
+ * `'none'` 이면 기존처럼 WS 실패 시 `connect()` 가 reject 한다.
2967
+ */
2968
+ fallback?: 'sse' | 'none';
2954
2969
  }
2955
2970
  /** 이벤트 핸들러 타입 */
2956
2971
  type MessageHandler<T = unknown> = (message: RealtimeMessage<T>) => void;
@@ -3171,10 +3186,26 @@ declare class RealtimeAPI {
3171
3186
  private clientId;
3172
3187
  private userId?;
3173
3188
  private _appId;
3189
+ /**
3190
+ * 현재 활성 transport.
3191
+ * - `'ws'`: WebSocket 정상 연결
3192
+ * - `'sse'`: WS 핸드셰이크 실패로 HTTP SSE fallback 모드 (AI `stream()` 만 지원)
3193
+ * - `null`: 미연결
3194
+ */
3195
+ private activeTransport;
3196
+ /** SSE fallback 모드 활성 여부. true 면 백그라운드 WS 재연결 머신을 정지한다. */
3197
+ private sseFallbackActive;
3198
+ /** SSE fallback 스트림의 AbortController (sessionId → controller) — stop()/disconnect() 에서 중단용 */
3199
+ private sseSessions;
3174
3200
  /** 현재 연결 ID */
3175
3201
  get connectionId(): string | null;
3176
3202
  /** 현재 연결된 앱 ID */
3177
3203
  get appId(): string | null;
3204
+ /**
3205
+ * 현재 활성 transport (`'ws'` | `'sse'` | `null`).
3206
+ * `'sse'` 이면 WS 가 차단되어 fallback 모드로 동작 중이며 AI `stream()` 만 가능하다.
3207
+ */
3208
+ get transport(): 'ws' | 'sse' | null;
3178
3209
  private options;
3179
3210
  private retryCount;
3180
3211
  private pendingRequests;
@@ -3195,6 +3226,11 @@ declare class RealtimeAPI {
3195
3226
  * - userId: 사용자 식별자 (표시용)
3196
3227
  */
3197
3228
  connect(options?: RealtimeConnectOptions): Promise<void>;
3229
+ /**
3230
+ * SSE fallback 모드로 전환한다. WS 는 사용 불가하지만 state 는 'connected' 로 두어
3231
+ * (degraded) AI stream() 이 SSE 로 동작하게 한다. 백그라운드 WS 재연결 머신은 정지한다.
3232
+ */
3233
+ private activateSseFallback;
3198
3234
  /**
3199
3235
  * 연결 해제
3200
3236
  */
@@ -3251,6 +3287,17 @@ declare class RealtimeAPI {
3251
3287
  * ```
3252
3288
  */
3253
3289
  stream(messages: StreamMessage[], handlers: StreamHandlers, options?: StreamOptions): Promise<StreamSession>;
3290
+ /** WebSocket 으로 AI 스트리밍 (기본 경로) */
3291
+ private streamViaWS;
3292
+ /**
3293
+ * HTTP SSE (`/v1/public/ai/chat/stream`) 로 AI 스트리밍 — WS 가 차단된 네트워크용 fallback.
3294
+ *
3295
+ * SSE 는 단방향이라 WS 의 `StreamDoneData` 토큰 카운트(total/prompt)를 받지 못하므로
3296
+ * `totalTokens`/`promptTokens` 는 0 으로 채워진다(`fullText`/`duration` 은 정확). `mcpGroup`
3297
+ * (MCP 도구) 은 SSE fallback 에서 미지원 — 지정 시 무시되고 도구 없이 스트리밍한다.
3298
+ */
3299
+ private streamViaSSE;
3300
+ private runSseStream;
3254
3301
  /**
3255
3302
  * 스트리밍 중지
3256
3303
  */
@@ -3438,6 +3485,11 @@ declare class RealtimeAPI {
3438
3485
  * 재연결 성공 후 호출됩니다.
3439
3486
  */
3440
3487
  private restoreSubscriptions;
3488
+ /**
3489
+ * 양방향(WS 전용) 기능을 SSE fallback 모드에서 호출하면 명확한 에러를 던진다.
3490
+ * SSE 는 단방향이라 presence/typing/subscribe/sendMessage 를 지원하지 못한다.
3491
+ */
3492
+ private assertBidirectional;
3441
3493
  private sendRequest;
3442
3494
  private sendRaw;
3443
3495
  private notifyStateChange;
@@ -7543,6 +7595,21 @@ interface AIChatRequest {
7543
7595
  topK?: number;
7544
7596
  agentic?: boolean;
7545
7597
  toolGroupId?: string;
7598
+ /**
7599
+ * 0 보다 크면, 서버측 도구 그룹 실행(`toolGroupId` 사용 시)에서 각 도구 결과를 모델에
7600
+ * **재투입하기 전** N 자(rune 기준)로 잘라낸다(말미에 잘림 표시). SSE `onToolEvent` 의
7601
+ * `tool_end.result` 로 표시되는 값은 원문을 유지한다.
7602
+ *
7603
+ * 거대한 도구 결과(예: 복잡한 페이지의 `browser_snapshot`)가 누적 컨텍스트를 부풀려
7604
+ * 매 턴 prefill 을 무겁게 만들 때, 표시는 원문으로 두고 모델 재투입분만 압축해
7605
+ * 지연을 줄이는 용도. 미지정/0 이면 자르지 않는다.
7606
+ */
7607
+ toolResultMaxChars?: number;
7608
+ /**
7609
+ * 요청에 포함한 MCP 도구(`tools`)를 호출할 외부 MCP 서버의 Public Key (cb_pk_* 형식).
7610
+ * MCP 도구를 직접 넘기지 않으면 불필요하다.
7611
+ */
7612
+ mcpPublicKey?: string;
7546
7613
  }
7547
7614
  /**
7548
7615
  * `chatStream` 콜백.
package/dist/index.js CHANGED
@@ -153,6 +153,10 @@ function sanitizePathForBreadcrumb(rawUrl) {
153
153
  }
154
154
 
155
155
  // src/core/http.ts
156
+ function fetchCredentialsForPath(url) {
157
+ const path = url.split("?")[0];
158
+ return path.startsWith("/v1/public/") ? "omit" : "include";
159
+ }
156
160
  var TOKEN_STORAGE_KEY = "cb_auth_tokens";
157
161
  function decodeJwtPayload(token) {
158
162
  try {
@@ -679,7 +683,7 @@ var HttpClient = class {
679
683
  try {
680
684
  const response = await fetch(`${this.config.baseUrl}${url}`, {
681
685
  ...init,
682
- credentials: "include",
686
+ credentials: fetchCredentialsForPath(url),
683
687
  signal
684
688
  });
685
689
  return { response, ok: response.ok, status: response.status };
@@ -748,7 +752,7 @@ var HttpClient = class {
748
752
  }
749
753
  return fetch(`${this.config.baseUrl}${url}`, {
750
754
  ...init,
751
- credentials: "include",
755
+ credentials: fetchCredentialsForPath(url),
752
756
  headers: mergedHeaders
753
757
  });
754
758
  }
@@ -2955,13 +2959,25 @@ var RealtimeAPI = class {
2955
2959
  this.state = "disconnected";
2956
2960
  this._connectionId = null;
2957
2961
  this._appId = null;
2962
+ /**
2963
+ * 현재 활성 transport.
2964
+ * - `'ws'`: WebSocket 정상 연결
2965
+ * - `'sse'`: WS 핸드셰이크 실패로 HTTP SSE fallback 모드 (AI `stream()` 만 지원)
2966
+ * - `null`: 미연결
2967
+ */
2968
+ this.activeTransport = null;
2969
+ /** SSE fallback 모드 활성 여부. true 면 백그라운드 WS 재연결 머신을 정지한다. */
2970
+ this.sseFallbackActive = false;
2971
+ /** SSE fallback 스트림의 AbortController (sessionId → controller) — stop()/disconnect() 에서 중단용 */
2972
+ this.sseSessions = /* @__PURE__ */ new Map();
2958
2973
  this.options = {
2959
2974
  maxRetries: 5,
2960
2975
  retryInterval: 1e3,
2961
2976
  userId: "",
2962
2977
  accessToken: "",
2963
2978
  timeout: 3e4,
2964
- debug: false
2979
+ debug: false,
2980
+ fallback: "sse"
2965
2981
  };
2966
2982
  this.retryCount = 0;
2967
2983
  this.pendingRequests = /* @__PURE__ */ new Map();
@@ -2990,6 +3006,13 @@ var RealtimeAPI = class {
2990
3006
  get appId() {
2991
3007
  return this._appId;
2992
3008
  }
3009
+ /**
3010
+ * 현재 활성 transport (`'ws'` | `'sse'` | `null`).
3011
+ * `'sse'` 이면 WS 가 차단되어 fallback 모드로 동작 중이며 AI `stream()` 만 가능하다.
3012
+ */
3013
+ get transport() {
3014
+ return this.activeTransport;
3015
+ }
2993
3016
  /**
2994
3017
  * WebSocket 연결
2995
3018
  * @param options 연결 옵션
@@ -3010,10 +3033,36 @@ var RealtimeAPI = class {
3010
3033
  this.connectPromise = this.doConnect();
3011
3034
  try {
3012
3035
  await this.connectPromise;
3036
+ } catch (e) {
3037
+ const hasAuth = !!(this.options.accessToken || this.http.getPublicKey());
3038
+ if (this.options.fallback !== "none" && hasAuth) {
3039
+ this.activateSseFallback(e);
3040
+ } else {
3041
+ throw e;
3042
+ }
3013
3043
  } finally {
3014
3044
  this.connectPromise = null;
3015
3045
  }
3016
3046
  }
3047
+ /**
3048
+ * SSE fallback 모드로 전환한다. WS 는 사용 불가하지만 state 는 'connected' 로 두어
3049
+ * (degraded) AI stream() 이 SSE 로 동작하게 한다. 백그라운드 WS 재연결 머신은 정지한다.
3050
+ */
3051
+ activateSseFallback(cause) {
3052
+ if (this.ws) {
3053
+ try {
3054
+ this.ws.close();
3055
+ } catch {
3056
+ }
3057
+ this.ws = null;
3058
+ }
3059
+ this.sseFallbackActive = true;
3060
+ this.activeTransport = "sse";
3061
+ this.state = "connected";
3062
+ this.retryCount = 0;
3063
+ this.log(`WS unavailable (${cause instanceof Error ? cause.message : String(cause)}); using SSE fallback transport for AI streaming`);
3064
+ this.notifyStateChange();
3065
+ }
3017
3066
  /**
3018
3067
  * 연결 해제
3019
3068
  */
@@ -3036,6 +3085,15 @@ var RealtimeAPI = class {
3036
3085
  }
3037
3086
  });
3038
3087
  this.streamSessions.clear();
3088
+ this.sseSessions.forEach((controller) => {
3089
+ try {
3090
+ controller.abort();
3091
+ } catch {
3092
+ }
3093
+ });
3094
+ this.sseSessions.clear();
3095
+ this.sseFallbackActive = false;
3096
+ this.activeTransport = null;
3039
3097
  this.presenceHandlers = [];
3040
3098
  this.presenceSubscriptions.clear();
3041
3099
  this.typingHandlers.clear();
@@ -3049,6 +3107,7 @@ var RealtimeAPI = class {
3049
3107
  * 카테고리 구독
3050
3108
  */
3051
3109
  async subscribe(category, options = {}) {
3110
+ this.assertBidirectional("subscribe");
3052
3111
  if (this.state !== "connected") {
3053
3112
  throw new Error("Not connected. Call connect() first.");
3054
3113
  }
@@ -3109,6 +3168,7 @@ var RealtimeAPI = class {
3109
3168
  * @param options 전송 옵션 (includeSelf: 발신자도 메시지 수신 여부, 기본값 true)
3110
3169
  */
3111
3170
  async sendMessage(category, data, options = {}) {
3171
+ this.assertBidirectional("sendMessage");
3112
3172
  if (this.state !== "connected") {
3113
3173
  throw new Error("Not connected");
3114
3174
  }
@@ -3126,6 +3186,7 @@ var RealtimeAPI = class {
3126
3186
  * 히스토리 조회
3127
3187
  */
3128
3188
  async getHistory(category, limit) {
3189
+ this.assertBidirectional("getHistory");
3129
3190
  if (this.state !== "connected") {
3130
3191
  throw new Error("Not connected");
3131
3192
  }
@@ -3181,9 +3242,22 @@ var RealtimeAPI = class {
3181
3242
  * ```
3182
3243
  */
3183
3244
  async stream(messages, handlers, options = {}) {
3184
- if (this.state !== "connected") {
3185
- throw new Error("Not connected. Call connect() first.");
3245
+ const canWs = this.state === "connected" && this.activeTransport === "ws" && this.ws?.readyState === WebSocket.OPEN;
3246
+ if (canWs) {
3247
+ try {
3248
+ return this.streamViaWS(messages, handlers, options);
3249
+ } catch (e) {
3250
+ if (this.options.fallback === "none") throw e;
3251
+ this.log(`WS stream send failed (${e instanceof Error ? e.message : String(e)}); falling back to SSE`);
3252
+ }
3186
3253
  }
3254
+ if (this.options.fallback !== "none") {
3255
+ return this.streamViaSSE(messages, handlers, options);
3256
+ }
3257
+ throw new Error("Not connected. Call connect() first.");
3258
+ }
3259
+ /** WebSocket 으로 AI 스트리밍 (기본 경로) */
3260
+ streamViaWS(messages, handlers, options) {
3187
3261
  const requestId = this.generateRequestId();
3188
3262
  const sessionId = options.sessionId || this.generateRequestId();
3189
3263
  this.streamSessions.set(sessionId, {
@@ -3218,11 +3292,130 @@ var RealtimeAPI = class {
3218
3292
  }
3219
3293
  };
3220
3294
  }
3295
+ /**
3296
+ * HTTP SSE (`/v1/public/ai/chat/stream`) 로 AI 스트리밍 — WS 가 차단된 네트워크용 fallback.
3297
+ *
3298
+ * SSE 는 단방향이라 WS 의 `StreamDoneData` 토큰 카운트(total/prompt)를 받지 못하므로
3299
+ * `totalTokens`/`promptTokens` 는 0 으로 채워진다(`fullText`/`duration` 은 정확). `mcpGroup`
3300
+ * (MCP 도구) 은 SSE fallback 에서 미지원 — 지정 시 무시되고 도구 없이 스트리밍한다.
3301
+ */
3302
+ streamViaSSE(messages, handlers, options) {
3303
+ const sessionId = options.sessionId || this.generateRequestId();
3304
+ const controller = new AbortController();
3305
+ this.sseSessions.set(sessionId, controller);
3306
+ if (options.mcpGroup) {
3307
+ this.log("mcpGroup is not supported in SSE fallback mode; streaming without MCP tools");
3308
+ }
3309
+ this.log(`Streaming via SSE fallback (session ${sessionId})`);
3310
+ void this.runSseStream(sessionId, messages, handlers, options, controller.signal);
3311
+ return {
3312
+ sessionId,
3313
+ stop: async () => {
3314
+ await this.stopStream(sessionId);
3315
+ }
3316
+ };
3317
+ }
3318
+ async runSseStream(sessionId, messages, handlers, options, signal) {
3319
+ const startedAt = Date.now();
3320
+ let tokenIndex = 0;
3321
+ let fullText = "";
3322
+ let reader;
3323
+ const wireMessages = options.system ? [{ role: "system", content: options.system }, ...messages] : messages;
3324
+ const body = {
3325
+ messages: wireMessages,
3326
+ provider: options.provider,
3327
+ model: options.model,
3328
+ temperature: options.temperature,
3329
+ maxTokens: options.maxTokens
3330
+ };
3331
+ try {
3332
+ const response = await this.http.fetchRaw("/v1/public/ai/chat/stream", {
3333
+ method: "POST",
3334
+ headers: { "Content-Type": "application/json" },
3335
+ body: JSON.stringify(body),
3336
+ signal
3337
+ });
3338
+ if (!response.ok) {
3339
+ const errData = await response.json().catch(() => ({ error: "Stream request failed" }));
3340
+ handlers.onError?.(new Error(errData.error || errData.message || "Stream request failed"));
3341
+ return;
3342
+ }
3343
+ reader = response.body?.getReader();
3344
+ if (!reader) {
3345
+ handlers.onError?.(new Error("ReadableStream not supported"));
3346
+ return;
3347
+ }
3348
+ const decoder = new TextDecoder();
3349
+ let buffer = "";
3350
+ while (true) {
3351
+ const { done, value } = await reader.read();
3352
+ if (done) break;
3353
+ buffer += decoder.decode(value, { stream: true });
3354
+ const lines = buffer.split("\n");
3355
+ buffer = lines.pop() || "";
3356
+ for (const line of lines) {
3357
+ if (!line.startsWith("data: ")) continue;
3358
+ const data = line.slice(6).trim();
3359
+ if (data === "[DONE]") {
3360
+ handlers.onDone?.({ sessionId, fullText, totalTokens: 0, promptTokens: 0, duration: Date.now() - startedAt });
3361
+ return;
3362
+ }
3363
+ try {
3364
+ const ev = JSON.parse(data);
3365
+ if (ev.error) {
3366
+ handlers.onError?.(new Error(ev.message || ev.error || "stream error"));
3367
+ return;
3368
+ }
3369
+ if (ev.type === "tool_start") {
3370
+ handlers.onToolCall?.(ev.name || "", ev.arguments || {}, tokenIndex);
3371
+ continue;
3372
+ }
3373
+ if (ev.type === "tool_end") {
3374
+ handlers.onToolResult?.(ev.name || "", ev.success !== false, ev.durationMs || 0, tokenIndex);
3375
+ continue;
3376
+ }
3377
+ if (ev.type === "sources" || ev.type === "searching" || ev.type === "heartbeat") {
3378
+ continue;
3379
+ }
3380
+ if (ev.content) {
3381
+ fullText += ev.content;
3382
+ handlers.onToken?.(ev.content, tokenIndex++);
3383
+ }
3384
+ if (ev.done) {
3385
+ handlers.onDone?.({ sessionId, fullText, totalTokens: 0, promptTokens: 0, duration: Date.now() - startedAt });
3386
+ return;
3387
+ }
3388
+ } catch {
3389
+ }
3390
+ }
3391
+ }
3392
+ } catch (err) {
3393
+ const aborted = signal.aborted || err instanceof DOMException && err.name === "AbortError" || typeof err === "object" && err !== null && err.name === "AbortError";
3394
+ if (!aborted) {
3395
+ handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
3396
+ }
3397
+ } finally {
3398
+ this.sseSessions.delete(sessionId);
3399
+ if (reader) {
3400
+ try {
3401
+ await reader.cancel();
3402
+ } catch {
3403
+ }
3404
+ }
3405
+ }
3406
+ }
3221
3407
  /**
3222
3408
  * 스트리밍 중지
3223
3409
  */
3224
3410
  async stopStream(sessionId) {
3225
- if (this.state !== "connected") {
3411
+ const sse = this.sseSessions.get(sessionId);
3412
+ if (sse) {
3413
+ sse.abort();
3414
+ this.sseSessions.delete(sessionId);
3415
+ return;
3416
+ }
3417
+ if (this.state !== "connected" || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
3418
+ this.streamSessions.delete(sessionId);
3226
3419
  return;
3227
3420
  }
3228
3421
  const requestId = this.generateRequestId();
@@ -3281,6 +3474,7 @@ var RealtimeAPI = class {
3281
3474
  * ```
3282
3475
  */
3283
3476
  async setPresence(status, options = {}) {
3477
+ this.assertBidirectional("setPresence");
3284
3478
  if (this.state !== "connected") {
3285
3479
  throw new Error("Not connected");
3286
3480
  }
@@ -3308,6 +3502,7 @@ var RealtimeAPI = class {
3308
3502
  * ```
3309
3503
  */
3310
3504
  async setPresenceOnDisconnect(status, metadata) {
3505
+ this.assertBidirectional("setPresenceOnDisconnect");
3311
3506
  if (this.state !== "connected") {
3312
3507
  throw new Error("Not connected");
3313
3508
  }
@@ -3331,6 +3526,7 @@ var RealtimeAPI = class {
3331
3526
  * ```
3332
3527
  */
3333
3528
  async getPresence(userId) {
3529
+ this.assertBidirectional("getPresence");
3334
3530
  if (this.state !== "connected") {
3335
3531
  throw new Error("Not connected");
3336
3532
  }
@@ -3361,6 +3557,7 @@ var RealtimeAPI = class {
3361
3557
  * ```
3362
3558
  */
3363
3559
  async getPresenceMany(userIds) {
3560
+ this.assertBidirectional("getPresenceMany");
3364
3561
  if (this.state !== "connected") {
3365
3562
  throw new Error("Not connected");
3366
3563
  }
@@ -3400,6 +3597,7 @@ var RealtimeAPI = class {
3400
3597
  * ```
3401
3598
  */
3402
3599
  async subscribePresence(userId, handler) {
3600
+ this.assertBidirectional("subscribePresence");
3403
3601
  if (this.state !== "connected") {
3404
3602
  throw new Error("Not connected");
3405
3603
  }
@@ -3469,6 +3667,7 @@ var RealtimeAPI = class {
3469
3667
  * ```
3470
3668
  */
3471
3669
  async startTyping(roomId) {
3670
+ this.assertBidirectional("startTyping");
3472
3671
  if (this.state !== "connected") {
3473
3672
  throw new Error("Not connected");
3474
3673
  }
@@ -3493,6 +3692,7 @@ var RealtimeAPI = class {
3493
3692
  * ```
3494
3693
  */
3495
3694
  async stopTyping(roomId) {
3695
+ this.assertBidirectional("stopTyping");
3496
3696
  if (this.state !== "connected") {
3497
3697
  throw new Error("Not connected");
3498
3698
  }
@@ -3522,6 +3722,7 @@ var RealtimeAPI = class {
3522
3722
  * ```
3523
3723
  */
3524
3724
  async onTypingChange(roomId, handler) {
3725
+ this.assertBidirectional("onTypingChange");
3525
3726
  if (this.state !== "connected") {
3526
3727
  throw new Error("Not connected");
3527
3728
  }
@@ -3569,6 +3770,7 @@ var RealtimeAPI = class {
3569
3770
  * ```
3570
3771
  */
3571
3772
  async markRead(category, messageIds) {
3773
+ this.assertBidirectional("markRead");
3572
3774
  if (this.state !== "connected") {
3573
3775
  throw new Error("Not connected");
3574
3776
  }
@@ -3717,6 +3919,8 @@ var RealtimeAPI = class {
3717
3919
  this._connectionId = data.connection_id;
3718
3920
  this._appId = data.app_id;
3719
3921
  this.state = "connected";
3922
+ this.activeTransport = "ws";
3923
+ this.sseFallbackActive = false;
3720
3924
  this.retryCount = 0;
3721
3925
  this.notifyStateChange();
3722
3926
  if (connectResolve) connectResolve();
@@ -3879,6 +4083,10 @@ var RealtimeAPI = class {
3879
4083
  }
3880
4084
  }
3881
4085
  handleDisconnect() {
4086
+ if (this.sseFallbackActive) {
4087
+ this.ws = null;
4088
+ return;
4089
+ }
3882
4090
  this.ws = null;
3883
4091
  this._connectionId = null;
3884
4092
  this.streamSessions.forEach((session) => {
@@ -3911,6 +4119,7 @@ var RealtimeAPI = class {
3911
4119
  3e4
3912
4120
  );
3913
4121
  setTimeout(async () => {
4122
+ if (this.sseFallbackActive) return;
3914
4123
  try {
3915
4124
  await this.doConnect();
3916
4125
  this.log("Reconnected successfully, restoring subscriptions...");
@@ -3921,12 +4130,19 @@ var RealtimeAPI = class {
3921
4130
  );
3922
4131
  } catch (e) {
3923
4132
  this.logError("Reconnect failed", e);
4133
+ if (this.retryCount >= this.options.maxRetries && this.options.fallback !== "none" && !this.sseFallbackActive) {
4134
+ this.activateSseFallback(e);
4135
+ }
3924
4136
  }
3925
4137
  }, delay);
3926
4138
  } else {
3927
- this.state = "disconnected";
3928
- this.notifyStateChange();
3929
- this.notifyError(new Error("Connection lost. Max retries exceeded."));
4139
+ if (this.options.fallback !== "none") {
4140
+ this.activateSseFallback(new Error("Connection lost. Max WS retries exceeded."));
4141
+ } else {
4142
+ this.state = "disconnected";
4143
+ this.notifyStateChange();
4144
+ this.notifyError(new Error("Connection lost. Max retries exceeded."));
4145
+ }
3930
4146
  }
3931
4147
  }
3932
4148
  /**
@@ -3989,6 +4205,17 @@ var RealtimeAPI = class {
3989
4205
  }
3990
4206
  }
3991
4207
  }
4208
+ /**
4209
+ * 양방향(WS 전용) 기능을 SSE fallback 모드에서 호출하면 명확한 에러를 던진다.
4210
+ * SSE 는 단방향이라 presence/typing/subscribe/sendMessage 를 지원하지 못한다.
4211
+ */
4212
+ assertBidirectional(feature) {
4213
+ if (this.activeTransport === "sse") {
4214
+ throw new Error(
4215
+ `realtime.${feature}() requires a WebSocket connection, which is blocked on this network. Only AI streaming (realtime.stream) works in SSE fallback mode (cb.realtime.transport === 'sse').`
4216
+ );
4217
+ }
4218
+ }
3992
4219
  sendRequest(message) {
3993
4220
  return new Promise((resolve, reject) => {
3994
4221
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {