@spooled/sdk 1.0.30 → 1.0.31

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.
@@ -107,11 +107,11 @@ declare const DEFAULT_CONFIG: {
107
107
  successThreshold: number;
108
108
  timeout: number;
109
109
  };
110
- readonly userAgent: "@spooled/sdk-nodejs/1.0.26";
110
+ readonly userAgent: "@spooled/sdk-nodejs/1.0.31";
111
111
  readonly autoRefreshToken: true;
112
112
  };
113
113
  /** SDK version */
114
- declare const SDK_VERSION = "1.0.26";
114
+ declare const SDK_VERSION = "1.0.31";
115
115
  /** API version prefix */
116
116
  declare const API_VERSION = "v1";
117
117
  /**
@@ -2381,13 +2381,17 @@ interface RealtimeConnectionOptions {
2381
2381
  /** JWT token for authentication (used as a fallback when tokenProvider is not supplied) */
2382
2382
  token: string;
2383
2383
  /**
2384
- * Async provider that mints a fresh JWT for each (re)connect.
2384
+ * Async provider that supplies a valid JWT for each (re)connect.
2385
2385
  *
2386
- * JWTs are short-lived, so a static token captured once will be stale by
2387
- * the time a long-running connection needs to reconnect. When provided,
2388
- * this is invoked before every connect/reconnect to obtain a valid token.
2389
- */
2390
- tokenProvider?: () => Promise<string>;
2386
+ * JWTs are short-lived, so a static token captured once will be stale by the
2387
+ * time a long-running connection needs to reconnect. When provided, this is
2388
+ * invoked before every connect/reconnect to obtain a valid token. The
2389
+ * provider is expected to cache and reuse the token itself (re-minting only
2390
+ * near expiry), so calling it on every reconnect does not hammer the login
2391
+ * endpoint. `forceRefresh` asks it to bypass that cache — the transport sets
2392
+ * this after the server rejects a token (e.g. a 401 on the WS upgrade).
2393
+ */
2394
+ tokenProvider?: (forceRefresh?: boolean) => Promise<string>;
2391
2395
  /** Auto-reconnect on disconnect (default: true) */
2392
2396
  autoReconnect?: boolean;
2393
2397
  /** Maximum reconnect attempts (default: 10) */
@@ -2430,8 +2434,8 @@ interface FullRealtimeOptions extends SpooledRealtimeOptions {
2430
2434
  baseUrl: string;
2431
2435
  wsUrl: string;
2432
2436
  token: string;
2433
- /** Async provider that mints a fresh JWT for each (re)connect */
2434
- tokenProvider?: () => Promise<string>;
2437
+ /** Async provider that supplies a valid JWT for each (re)connect */
2438
+ tokenProvider?: (forceRefresh?: boolean) => Promise<string>;
2435
2439
  debug?: (message: string, meta?: unknown) => void;
2436
2440
  }
2437
2441
  /** Event handler type - simplified for internal use */
@@ -3090,6 +3094,18 @@ declare class SpooledClient {
3090
3094
  /** Token refresh state */
3091
3095
  private refreshPromise;
3092
3096
  private tokenExpiresAt;
3097
+ /**
3098
+ * Cached JWT for realtime connections (API-key auth path).
3099
+ *
3100
+ * The realtime layer requests a token on every (re)connect. Minting a brand
3101
+ * new JWT via POST /auth/login each time trips the login rate limit (429)
3102
+ * under a reconnect storm, after which realtime can never recover. We cache
3103
+ * the token here — at the client level, shared across reconnects — and reuse
3104
+ * it until it nears expiry.
3105
+ */
3106
+ private cachedJwt;
3107
+ /** Deduplicates concurrent logins (e.g. WS + SSE reconnecting together). */
3108
+ private jwtLoginPromise;
3093
3109
  constructor(options: SpooledClientConfig);
3094
3110
  /**
3095
3111
  * Real gRPC client for high-performance operations (HTTP/2 + Protobuf)
@@ -3132,9 +3148,26 @@ declare class SpooledClient {
3132
3148
  */
3133
3149
  realtime(options?: SpooledRealtimeOptions): Promise<SpooledRealtime>;
3134
3150
  /**
3135
- * Get or acquire a JWT token for realtime connections
3151
+ * Get or acquire a JWT token for realtime connections.
3152
+ *
3153
+ * The realtime layer calls this on every (re)connect. We cache the JWT and
3154
+ * reuse it until it nears expiry so a reconnect storm doesn't hammer
3155
+ * /auth/login and trip its rate limit. A fresh login happens only when the
3156
+ * cached token is absent, within ~60s of its `exp`, or a refresh is forced.
3157
+ *
3158
+ * @param forceRefresh Bypass the cache and mint a new token. Set by the
3159
+ * transport when the server rejected the current token (e.g. a 401 on the
3160
+ * WebSocket upgrade), so a stale/revoked token is replaced without waiting
3161
+ * for it to expire.
3136
3162
  */
3137
3163
  private getJwtToken;
3164
+ /**
3165
+ * Exchange the API key for a JWT via /auth/login and cache the result.
3166
+ *
3167
+ * Concurrent callers share a single in-flight login so a burst of reconnects
3168
+ * (e.g. WebSocket and SSE at once) issues just one request.
3169
+ */
3170
+ private loginForJwt;
3138
3171
  /**
3139
3172
  * Check if token should be refreshed
3140
3173
  */
@@ -107,11 +107,11 @@ declare const DEFAULT_CONFIG: {
107
107
  successThreshold: number;
108
108
  timeout: number;
109
109
  };
110
- readonly userAgent: "@spooled/sdk-nodejs/1.0.26";
110
+ readonly userAgent: "@spooled/sdk-nodejs/1.0.31";
111
111
  readonly autoRefreshToken: true;
112
112
  };
113
113
  /** SDK version */
114
- declare const SDK_VERSION = "1.0.26";
114
+ declare const SDK_VERSION = "1.0.31";
115
115
  /** API version prefix */
116
116
  declare const API_VERSION = "v1";
117
117
  /**
@@ -2381,13 +2381,17 @@ interface RealtimeConnectionOptions {
2381
2381
  /** JWT token for authentication (used as a fallback when tokenProvider is not supplied) */
2382
2382
  token: string;
2383
2383
  /**
2384
- * Async provider that mints a fresh JWT for each (re)connect.
2384
+ * Async provider that supplies a valid JWT for each (re)connect.
2385
2385
  *
2386
- * JWTs are short-lived, so a static token captured once will be stale by
2387
- * the time a long-running connection needs to reconnect. When provided,
2388
- * this is invoked before every connect/reconnect to obtain a valid token.
2389
- */
2390
- tokenProvider?: () => Promise<string>;
2386
+ * JWTs are short-lived, so a static token captured once will be stale by the
2387
+ * time a long-running connection needs to reconnect. When provided, this is
2388
+ * invoked before every connect/reconnect to obtain a valid token. The
2389
+ * provider is expected to cache and reuse the token itself (re-minting only
2390
+ * near expiry), so calling it on every reconnect does not hammer the login
2391
+ * endpoint. `forceRefresh` asks it to bypass that cache — the transport sets
2392
+ * this after the server rejects a token (e.g. a 401 on the WS upgrade).
2393
+ */
2394
+ tokenProvider?: (forceRefresh?: boolean) => Promise<string>;
2391
2395
  /** Auto-reconnect on disconnect (default: true) */
2392
2396
  autoReconnect?: boolean;
2393
2397
  /** Maximum reconnect attempts (default: 10) */
@@ -2430,8 +2434,8 @@ interface FullRealtimeOptions extends SpooledRealtimeOptions {
2430
2434
  baseUrl: string;
2431
2435
  wsUrl: string;
2432
2436
  token: string;
2433
- /** Async provider that mints a fresh JWT for each (re)connect */
2434
- tokenProvider?: () => Promise<string>;
2437
+ /** Async provider that supplies a valid JWT for each (re)connect */
2438
+ tokenProvider?: (forceRefresh?: boolean) => Promise<string>;
2435
2439
  debug?: (message: string, meta?: unknown) => void;
2436
2440
  }
2437
2441
  /** Event handler type - simplified for internal use */
@@ -3090,6 +3094,18 @@ declare class SpooledClient {
3090
3094
  /** Token refresh state */
3091
3095
  private refreshPromise;
3092
3096
  private tokenExpiresAt;
3097
+ /**
3098
+ * Cached JWT for realtime connections (API-key auth path).
3099
+ *
3100
+ * The realtime layer requests a token on every (re)connect. Minting a brand
3101
+ * new JWT via POST /auth/login each time trips the login rate limit (429)
3102
+ * under a reconnect storm, after which realtime can never recover. We cache
3103
+ * the token here — at the client level, shared across reconnects — and reuse
3104
+ * it until it nears expiry.
3105
+ */
3106
+ private cachedJwt;
3107
+ /** Deduplicates concurrent logins (e.g. WS + SSE reconnecting together). */
3108
+ private jwtLoginPromise;
3093
3109
  constructor(options: SpooledClientConfig);
3094
3110
  /**
3095
3111
  * Real gRPC client for high-performance operations (HTTP/2 + Protobuf)
@@ -3132,9 +3148,26 @@ declare class SpooledClient {
3132
3148
  */
3133
3149
  realtime(options?: SpooledRealtimeOptions): Promise<SpooledRealtime>;
3134
3150
  /**
3135
- * Get or acquire a JWT token for realtime connections
3151
+ * Get or acquire a JWT token for realtime connections.
3152
+ *
3153
+ * The realtime layer calls this on every (re)connect. We cache the JWT and
3154
+ * reuse it until it nears expiry so a reconnect storm doesn't hammer
3155
+ * /auth/login and trip its rate limit. A fresh login happens only when the
3156
+ * cached token is absent, within ~60s of its `exp`, or a refresh is forced.
3157
+ *
3158
+ * @param forceRefresh Bypass the cache and mint a new token. Set by the
3159
+ * transport when the server rejected the current token (e.g. a 401 on the
3160
+ * WebSocket upgrade), so a stale/revoked token is replaced without waiting
3161
+ * for it to expire.
3136
3162
  */
3137
3163
  private getJwtToken;
3164
+ /**
3165
+ * Exchange the API key for a JWT via /auth/login and cache the result.
3166
+ *
3167
+ * Concurrent callers share a single in-flight login so a burst of reconnects
3168
+ * (e.g. WebSocket and SSE at once) issues just one request.
3169
+ */
3170
+ private loginForJwt;
3138
3171
  /**
3139
3172
  * Check if token should be refreshed
3140
3173
  */
package/dist/index.cjs CHANGED
@@ -49,10 +49,10 @@ var DEFAULT_CONFIG = {
49
49
  successThreshold: 3,
50
50
  timeout: 3e4
51
51
  },
52
- userAgent: "@spooled/sdk-nodejs/1.0.26",
52
+ userAgent: "@spooled/sdk-nodejs/1.0.31",
53
53
  autoRefreshToken: true
54
54
  };
55
- var SDK_VERSION = "1.0.26";
55
+ var SDK_VERSION = "1.0.31";
56
56
  var API_VERSION = "v1";
57
57
  var API_BASE_PATH = `/api/${API_VERSION}`;
58
58
  function resolveConfig(options) {
@@ -1849,6 +1849,12 @@ var WebSocketRealtimeClient = class {
1849
1849
  state = "disconnected";
1850
1850
  reconnectAttempts = 0;
1851
1851
  reconnectTimer = null;
1852
+ /**
1853
+ * Set when the server rejected our token on the upgrade (e.g. HTTP 401/403).
1854
+ * The next (re)connect uses it to force the token provider to mint a fresh
1855
+ * JWT instead of replaying the rejected one (which would loop forever).
1856
+ */
1857
+ authFailure = false;
1852
1858
  subscriptions = /* @__PURE__ */ new Map();
1853
1859
  pendingCommands = /* @__PURE__ */ new Map();
1854
1860
  // Event handlers
@@ -1930,6 +1936,9 @@ var WebSocketRealtimeClient = class {
1930
1936
  };
1931
1937
  this.ws.onerror = (event) => {
1932
1938
  this.options.debug("WebSocket error", event);
1939
+ if (isAuthFailureEvent(event)) {
1940
+ this.authFailure = true;
1941
+ }
1933
1942
  };
1934
1943
  } catch (error) {
1935
1944
  this.setState("disconnected");
@@ -2016,7 +2025,9 @@ var WebSocketRealtimeClient = class {
2016
2025
  // Private methods
2017
2026
  async buildWsUrl() {
2018
2027
  const wsBase = this.options.wsUrl;
2019
- const token = await this.options.tokenProvider();
2028
+ const forceRefresh = this.authFailure;
2029
+ this.authFailure = false;
2030
+ const token = await this.options.tokenProvider(forceRefresh);
2020
2031
  return `${wsBase}/api/v1/ws?token=${encodeURIComponent(token)}`;
2021
2032
  }
2022
2033
  setState(state) {
@@ -2139,6 +2150,17 @@ var WebSocketRealtimeClient = class {
2139
2150
  function redactToken(url) {
2140
2151
  return url.replace(/([?&](?:token|api_key)=)[^&]*/gi, "$1***");
2141
2152
  }
2153
+ function isAuthFailureEvent(event) {
2154
+ if (!event) {
2155
+ return false;
2156
+ }
2157
+ const status = event.status ?? event.statusCode ?? event.error?.status;
2158
+ if (status === 401 || status === 403) {
2159
+ return true;
2160
+ }
2161
+ const text = String(event.message ?? event.error?.message ?? event.reason ?? "");
2162
+ return /\b(401|403)\b/.test(text);
2163
+ }
2142
2164
 
2143
2165
  // src/realtime/sse.ts
2144
2166
  var SseRealtimeClient = class {
@@ -2877,6 +2899,18 @@ var SpooledClient = class _SpooledClient {
2877
2899
  /** Token refresh state */
2878
2900
  refreshPromise = null;
2879
2901
  tokenExpiresAt = null;
2902
+ /**
2903
+ * Cached JWT for realtime connections (API-key auth path).
2904
+ *
2905
+ * The realtime layer requests a token on every (re)connect. Minting a brand
2906
+ * new JWT via POST /auth/login each time trips the login rate limit (429)
2907
+ * under a reconnect storm, after which realtime can never recover. We cache
2908
+ * the token here — at the client level, shared across reconnects — and reuse
2909
+ * it until it nears expiry.
2910
+ */
2911
+ cachedJwt = null;
2912
+ /** Deduplicates concurrent logins (e.g. WS + SSE reconnecting together). */
2913
+ jwtLoginPromise = null;
2880
2914
  constructor(options) {
2881
2915
  this.config = resolveConfig(options);
2882
2916
  validateConfig(this.config);
@@ -2964,9 +2998,10 @@ var SpooledClient = class _SpooledClient {
2964
2998
  baseUrl: this.config.baseUrl,
2965
2999
  wsUrl: this.config.wsUrl,
2966
3000
  token,
2967
- // Provider so each (re)connect mints a fresh JWT via login/refresh,
2968
- // rather than reusing the short-lived token captured above.
2969
- tokenProvider: () => this.getJwtToken(),
3001
+ // Provider the realtime layer calls on every (re)connect. It returns a
3002
+ // cached JWT and only re-logs-in when the token is absent, near expiry,
3003
+ // or the transport asks to force a refresh (e.g. after a 401 upgrade).
3004
+ tokenProvider: (forceRefresh) => this.getJwtToken(forceRefresh),
2970
3005
  ...options,
2971
3006
  // Only forward debug when the client actually has a logger, so we never
2972
3007
  // hand the realtime layer an explicit `debug: undefined`. The WS/SSE
@@ -2976,23 +3011,59 @@ var SpooledClient = class _SpooledClient {
2976
3011
  });
2977
3012
  }
2978
3013
  /**
2979
- * Get or acquire a JWT token for realtime connections
3014
+ * Get or acquire a JWT token for realtime connections.
3015
+ *
3016
+ * The realtime layer calls this on every (re)connect. We cache the JWT and
3017
+ * reuse it until it nears expiry so a reconnect storm doesn't hammer
3018
+ * /auth/login and trip its rate limit. A fresh login happens only when the
3019
+ * cached token is absent, within ~60s of its `exp`, or a refresh is forced.
3020
+ *
3021
+ * @param forceRefresh Bypass the cache and mint a new token. Set by the
3022
+ * transport when the server rejected the current token (e.g. a 401 on the
3023
+ * WebSocket upgrade), so a stale/revoked token is replaced without waiting
3024
+ * for it to expire.
2980
3025
  */
2981
- async getJwtToken() {
3026
+ async getJwtToken(forceRefresh = false) {
2982
3027
  if (this.config.accessToken) {
2983
- if (this.shouldRefreshToken()) {
3028
+ if (forceRefresh || this.shouldRefreshToken()) {
2984
3029
  return this.refreshAccessToken();
2985
3030
  }
2986
3031
  return this.config.accessToken;
2987
3032
  }
2988
3033
  if (this.config.apiKey) {
3034
+ if (forceRefresh) {
3035
+ this.cachedJwt = null;
3036
+ }
3037
+ if (this.cachedJwt && !isJwtNearExpiry(this.cachedJwt)) {
3038
+ return this.cachedJwt;
3039
+ }
3040
+ return this.loginForJwt();
3041
+ }
3042
+ throw new AuthenticationError("No authentication method available for realtime connection");
3043
+ }
3044
+ /**
3045
+ * Exchange the API key for a JWT via /auth/login and cache the result.
3046
+ *
3047
+ * Concurrent callers share a single in-flight login so a burst of reconnects
3048
+ * (e.g. WebSocket and SSE at once) issues just one request.
3049
+ */
3050
+ async loginForJwt() {
3051
+ if (this.jwtLoginPromise) {
3052
+ return this.jwtLoginPromise;
3053
+ }
3054
+ this.jwtLoginPromise = (async () => {
2989
3055
  const response = await this.auth.login({ apiKey: this.config.apiKey });
2990
3056
  this.http.setAuthToken(response.accessToken);
2991
3057
  this.tokenExpiresAt = Date.now() + response.expiresIn * 1e3;
2992
3058
  this.config.refreshToken = response.refreshToken;
3059
+ this.cachedJwt = response.accessToken;
2993
3060
  return response.accessToken;
3061
+ })();
3062
+ try {
3063
+ return await this.jwtLoginPromise;
3064
+ } finally {
3065
+ this.jwtLoginPromise = null;
2994
3066
  }
2995
- throw new AuthenticationError("No authentication method available for realtime connection");
2996
3067
  }
2997
3068
  /**
2998
3069
  * Check if token should be refreshed
@@ -3093,6 +3164,34 @@ var SpooledClient = class _SpooledClient {
3093
3164
  function createClient(options) {
3094
3165
  return new SpooledClient(options);
3095
3166
  }
3167
+ function decodeJwtExp(token) {
3168
+ const parts = token.split(".");
3169
+ if (parts.length < 2) {
3170
+ return null;
3171
+ }
3172
+ try {
3173
+ const payload = JSON.parse(base64UrlDecode(parts[1]));
3174
+ return typeof payload.exp === "number" ? payload.exp : null;
3175
+ } catch {
3176
+ return null;
3177
+ }
3178
+ }
3179
+ function isJwtNearExpiry(token, skewMs = 6e4) {
3180
+ const exp = decodeJwtExp(token);
3181
+ if (exp === null) {
3182
+ return true;
3183
+ }
3184
+ return Date.now() >= exp * 1e3 - skewMs;
3185
+ }
3186
+ function base64UrlDecode(segment) {
3187
+ const b64 = segment.replace(/-/g, "+").replace(/_/g, "/");
3188
+ if (typeof globalThis.Buffer !== "undefined") {
3189
+ return globalThis.Buffer.from(b64, "base64").toString("utf8");
3190
+ }
3191
+ const binary = globalThis.atob(b64);
3192
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
3193
+ return new globalThis.TextDecoder().decode(bytes);
3194
+ }
3096
3195
 
3097
3196
  // src/types/common.ts
3098
3197
  function createPaginatedResponse(items, limit, offset = 0, total) {