@spooled/sdk 1.0.28 → 1.0.30

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.
@@ -285,15 +285,22 @@ declare class HttpClient {
285
285
  private readonly debug;
286
286
  /** Current authentication token (API key or JWT) */
287
287
  private authToken;
288
+ /** Optional function to mint a fresh access token (set when auto-refresh is enabled) */
289
+ private refreshTokenFn;
290
+ /** Guard to prevent a refresh request from recursively triggering another refresh */
291
+ private isRefreshing;
288
292
  constructor(config: ResolvedConfig, circuitBreaker: CircuitBreaker);
289
293
  /**
290
294
  * Set the authentication token
291
295
  */
292
296
  setAuthToken(token: string): void;
293
297
  /**
294
- * Set the token refresh function (for future use)
298
+ * Set the token refresh function.
299
+ *
300
+ * When set, an expired-token 401 on the data plane triggers a single
301
+ * refresh + retry of the original request with the new token.
295
302
  */
296
- setRefreshTokenFn(_fn: () => Promise<string>): void;
303
+ setRefreshTokenFn(fn: () => Promise<string>): void;
297
304
  /**
298
305
  * Build full URL with query parameters
299
306
  */
@@ -314,6 +321,22 @@ declare class HttpClient {
314
321
  * Make an HTTP request with retry and circuit breaker
315
322
  */
316
323
  request<T>(path: string, options?: HttpRequestOptions): Promise<HttpResponse<T>>;
324
+ /**
325
+ * Determine whether a request carries an idempotency key (making a
326
+ * non-idempotent method safe to retry).
327
+ */
328
+ private hasIdempotencyKey;
329
+ /**
330
+ * Resolve the retry configuration for a request.
331
+ *
332
+ * Idempotent methods (GET/PUT/DELETE) and any request carrying an
333
+ * idempotency key retry normally. Non-idempotent methods (POST/PATCH)
334
+ * without an idempotency key must NOT be retried on ambiguous failures
335
+ * (network/timeout/5xx) — doing so risks duplicate jobs, double claims,
336
+ * etc. They are still retried on 429, which is rejected before the
337
+ * server processes the request and is therefore side-effect-free.
338
+ */
339
+ private resolveRetryConfig;
317
340
  /**
318
341
  * Make a GET request
319
342
  */
@@ -401,7 +424,11 @@ interface ValidateTokenResponse {
401
424
 
402
425
  declare class AuthResource {
403
426
  private readonly http;
404
- constructor(http: HttpClient);
427
+ /** Optional accessor for the client's current refresh token (used by logout) */
428
+ private readonly getRefreshToken?;
429
+ constructor(http: HttpClient,
430
+ /** Optional accessor for the client's current refresh token (used by logout) */
431
+ getRefreshToken?: (() => string | undefined) | undefined);
405
432
  /**
406
433
  * Exchange API key for JWT tokens
407
434
  */
@@ -411,9 +438,13 @@ declare class AuthResource {
411
438
  */
412
439
  refresh(params: RefreshTokenParams): Promise<RefreshTokenResponse>;
413
440
  /**
414
- * Logout and invalidate current token
441
+ * Logout and revoke the refresh token server-side.
442
+ *
443
+ * The backend requires the refresh token in the body to invalidate the
444
+ * session. When omitted, the client's current refresh token (if any) is
445
+ * used automatically.
415
446
  */
416
- logout(): Promise<void>;
447
+ logout(refreshToken?: string): Promise<void>;
417
448
  /**
418
449
  * Get current user/session info
419
450
  */
@@ -1055,7 +1086,11 @@ interface CreateScheduleParams {
1055
1086
  timezone?: string;
1056
1087
  /** Target queue name */
1057
1088
  queueName: string;
1058
- /** Job payload template */
1089
+ /**
1090
+ * Job payload template. Required: the API rejects a create without it
1091
+ * (HTTP 422), so this is typed as non-optional to surface the omission at
1092
+ * compile time rather than as a runtime 422.
1093
+ */
1059
1094
  payloadTemplate: JsonObject;
1060
1095
  /** Job priority (-100 to 100) */
1061
1096
  priority?: number;
@@ -2343,8 +2378,16 @@ interface RealtimeConnectionOptions {
2343
2378
  baseUrl: string;
2344
2379
  /** WebSocket URL for realtime connection (e.g., wss://api.spooled.cloud) - used for WebSocket */
2345
2380
  wsUrl: string;
2346
- /** JWT token for authentication */
2381
+ /** JWT token for authentication (used as a fallback when tokenProvider is not supplied) */
2347
2382
  token: string;
2383
+ /**
2384
+ * Async provider that mints a fresh JWT for each (re)connect.
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>;
2348
2391
  /** Auto-reconnect on disconnect (default: true) */
2349
2392
  autoReconnect?: boolean;
2350
2393
  /** Maximum reconnect attempts (default: 10) */
@@ -2387,6 +2430,8 @@ interface FullRealtimeOptions extends SpooledRealtimeOptions {
2387
2430
  baseUrl: string;
2388
2431
  wsUrl: string;
2389
2432
  token: string;
2433
+ /** Async provider that mints a fresh JWT for each (re)connect */
2434
+ tokenProvider?: () => Promise<string>;
2390
2435
  debug?: (message: string, meta?: unknown) => void;
2391
2436
  }
2392
2437
  /** Event handler type - simplified for internal use */
@@ -285,15 +285,22 @@ declare class HttpClient {
285
285
  private readonly debug;
286
286
  /** Current authentication token (API key or JWT) */
287
287
  private authToken;
288
+ /** Optional function to mint a fresh access token (set when auto-refresh is enabled) */
289
+ private refreshTokenFn;
290
+ /** Guard to prevent a refresh request from recursively triggering another refresh */
291
+ private isRefreshing;
288
292
  constructor(config: ResolvedConfig, circuitBreaker: CircuitBreaker);
289
293
  /**
290
294
  * Set the authentication token
291
295
  */
292
296
  setAuthToken(token: string): void;
293
297
  /**
294
- * Set the token refresh function (for future use)
298
+ * Set the token refresh function.
299
+ *
300
+ * When set, an expired-token 401 on the data plane triggers a single
301
+ * refresh + retry of the original request with the new token.
295
302
  */
296
- setRefreshTokenFn(_fn: () => Promise<string>): void;
303
+ setRefreshTokenFn(fn: () => Promise<string>): void;
297
304
  /**
298
305
  * Build full URL with query parameters
299
306
  */
@@ -314,6 +321,22 @@ declare class HttpClient {
314
321
  * Make an HTTP request with retry and circuit breaker
315
322
  */
316
323
  request<T>(path: string, options?: HttpRequestOptions): Promise<HttpResponse<T>>;
324
+ /**
325
+ * Determine whether a request carries an idempotency key (making a
326
+ * non-idempotent method safe to retry).
327
+ */
328
+ private hasIdempotencyKey;
329
+ /**
330
+ * Resolve the retry configuration for a request.
331
+ *
332
+ * Idempotent methods (GET/PUT/DELETE) and any request carrying an
333
+ * idempotency key retry normally. Non-idempotent methods (POST/PATCH)
334
+ * without an idempotency key must NOT be retried on ambiguous failures
335
+ * (network/timeout/5xx) — doing so risks duplicate jobs, double claims,
336
+ * etc. They are still retried on 429, which is rejected before the
337
+ * server processes the request and is therefore side-effect-free.
338
+ */
339
+ private resolveRetryConfig;
317
340
  /**
318
341
  * Make a GET request
319
342
  */
@@ -401,7 +424,11 @@ interface ValidateTokenResponse {
401
424
 
402
425
  declare class AuthResource {
403
426
  private readonly http;
404
- constructor(http: HttpClient);
427
+ /** Optional accessor for the client's current refresh token (used by logout) */
428
+ private readonly getRefreshToken?;
429
+ constructor(http: HttpClient,
430
+ /** Optional accessor for the client's current refresh token (used by logout) */
431
+ getRefreshToken?: (() => string | undefined) | undefined);
405
432
  /**
406
433
  * Exchange API key for JWT tokens
407
434
  */
@@ -411,9 +438,13 @@ declare class AuthResource {
411
438
  */
412
439
  refresh(params: RefreshTokenParams): Promise<RefreshTokenResponse>;
413
440
  /**
414
- * Logout and invalidate current token
441
+ * Logout and revoke the refresh token server-side.
442
+ *
443
+ * The backend requires the refresh token in the body to invalidate the
444
+ * session. When omitted, the client's current refresh token (if any) is
445
+ * used automatically.
415
446
  */
416
- logout(): Promise<void>;
447
+ logout(refreshToken?: string): Promise<void>;
417
448
  /**
418
449
  * Get current user/session info
419
450
  */
@@ -1055,7 +1086,11 @@ interface CreateScheduleParams {
1055
1086
  timezone?: string;
1056
1087
  /** Target queue name */
1057
1088
  queueName: string;
1058
- /** Job payload template */
1089
+ /**
1090
+ * Job payload template. Required: the API rejects a create without it
1091
+ * (HTTP 422), so this is typed as non-optional to surface the omission at
1092
+ * compile time rather than as a runtime 422.
1093
+ */
1059
1094
  payloadTemplate: JsonObject;
1060
1095
  /** Job priority (-100 to 100) */
1061
1096
  priority?: number;
@@ -2343,8 +2378,16 @@ interface RealtimeConnectionOptions {
2343
2378
  baseUrl: string;
2344
2379
  /** WebSocket URL for realtime connection (e.g., wss://api.spooled.cloud) - used for WebSocket */
2345
2380
  wsUrl: string;
2346
- /** JWT token for authentication */
2381
+ /** JWT token for authentication (used as a fallback when tokenProvider is not supplied) */
2347
2382
  token: string;
2383
+ /**
2384
+ * Async provider that mints a fresh JWT for each (re)connect.
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>;
2348
2391
  /** Auto-reconnect on disconnect (default: true) */
2349
2392
  autoReconnect?: boolean;
2350
2393
  /** Maximum reconnect attempts (default: 10) */
@@ -2387,6 +2430,8 @@ interface FullRealtimeOptions extends SpooledRealtimeOptions {
2387
2430
  baseUrl: string;
2388
2431
  wsUrl: string;
2389
2432
  token: string;
2433
+ /** Async provider that mints a fresh JWT for each (re)connect */
2434
+ tokenProvider?: () => Promise<string>;
2390
2435
  debug?: (message: string, meta?: unknown) => void;
2391
2436
  }
2392
2437
  /** Event handler type - simplified for internal use */
package/dist/index.cjs CHANGED
@@ -328,9 +328,10 @@ async function parseErrorBody(response) {
328
328
  try {
329
329
  const body = await response.json();
330
330
  if (typeof body === "object" && body !== null) {
331
+ const message = (body.message ?? body.error) || response.statusText;
331
332
  return {
332
333
  code: body.code || "UNKNOWN_ERROR",
333
- message: body.message || response.statusText,
334
+ message,
334
335
  details: body.details
335
336
  };
336
337
  }
@@ -548,12 +549,17 @@ function createRetryWrapper(config) {
548
549
  }
549
550
 
550
551
  // src/utils/http.ts
552
+ var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "PUT", "DELETE"]);
551
553
  var HttpClient = class {
552
554
  config;
553
555
  circuitBreaker;
554
556
  debug;
555
557
  /** Current authentication token (API key or JWT) */
556
558
  authToken;
559
+ /** Optional function to mint a fresh access token (set when auto-refresh is enabled) */
560
+ refreshTokenFn = null;
561
+ /** Guard to prevent a refresh request from recursively triggering another refresh */
562
+ isRefreshing = false;
557
563
  constructor(config, circuitBreaker) {
558
564
  this.config = config;
559
565
  this.circuitBreaker = circuitBreaker;
@@ -567,9 +573,13 @@ var HttpClient = class {
567
573
  this.authToken = token;
568
574
  }
569
575
  /**
570
- * Set the token refresh function (for future use)
576
+ * Set the token refresh function.
577
+ *
578
+ * When set, an expired-token 401 on the data plane triggers a single
579
+ * refresh + retry of the original request with the new token.
571
580
  */
572
- setRefreshTokenFn(_fn) {
581
+ setRefreshTokenFn(fn) {
582
+ this.refreshTokenFn = fn;
573
583
  }
574
584
  /**
575
585
  * Build full URL with query parameters
@@ -639,7 +649,7 @@ var HttpClient = class {
639
649
  /**
640
650
  * Execute a single HTTP request (no retry)
641
651
  */
642
- async executeRequest(url, options) {
652
+ async executeRequest(url, options, hasRefreshed = false) {
643
653
  const method = options.method ?? "GET";
644
654
  const headers = this.buildHeaders(options.headers);
645
655
  const timeout = options.timeout ?? this.config.timeout;
@@ -668,6 +678,23 @@ var HttpClient = class {
668
678
  }
669
679
  this.debug?.(`Response: ${response.status}`, { url });
670
680
  if (!response.ok) {
681
+ if (response.status === 401 && this.refreshTokenFn && !hasRefreshed && !this.isRefreshing) {
682
+ let refreshed = false;
683
+ this.isRefreshing = true;
684
+ try {
685
+ const newToken = await this.refreshTokenFn();
686
+ this.setAuthToken(newToken);
687
+ refreshed = true;
688
+ } catch (refreshError) {
689
+ const message = refreshError instanceof Error ? refreshError.message : "unknown";
690
+ this.debug?.("Token refresh failed", { error: message });
691
+ } finally {
692
+ this.isRefreshing = false;
693
+ }
694
+ if (refreshed) {
695
+ return this.executeRequest(url, options, true);
696
+ }
697
+ }
671
698
  const apiError = await createErrorFromResponse(response);
672
699
  throw apiError;
673
700
  }
@@ -692,6 +719,7 @@ var HttpClient = class {
692
719
  */
693
720
  async request(path, options = {}) {
694
721
  const url = this.buildUrl(path, options.params, options.skipApiPrefix);
722
+ const method = options.method ?? "GET";
695
723
  const execute = async () => {
696
724
  return this.circuitBreaker.execute(() => this.executeRequest(url, options));
697
725
  };
@@ -699,13 +727,46 @@ var HttpClient = class {
699
727
  return execute();
700
728
  }
701
729
  return withRetry(execute, {
702
- config: this.config.retry,
730
+ config: this.resolveRetryConfig(method, options),
703
731
  signal: options.signal,
704
732
  onRetry: (attempt, error, delayMs) => {
705
733
  this.debug?.(`Retry attempt ${attempt} after ${delayMs}ms`, { error: error.message });
706
734
  }
707
735
  });
708
736
  }
737
+ /**
738
+ * Determine whether a request carries an idempotency key (making a
739
+ * non-idempotent method safe to retry).
740
+ */
741
+ hasIdempotencyKey(options) {
742
+ if (!options.headers) {
743
+ return false;
744
+ }
745
+ return Object.keys(options.headers).some((key) => key.toLowerCase() === "idempotency-key");
746
+ }
747
+ /**
748
+ * Resolve the retry configuration for a request.
749
+ *
750
+ * Idempotent methods (GET/PUT/DELETE) and any request carrying an
751
+ * idempotency key retry normally. Non-idempotent methods (POST/PATCH)
752
+ * without an idempotency key must NOT be retried on ambiguous failures
753
+ * (network/timeout/5xx) — doing so risks duplicate jobs, double claims,
754
+ * etc. They are still retried on 429, which is rejected before the
755
+ * server processes the request and is therefore side-effect-free.
756
+ */
757
+ resolveRetryConfig(method, options) {
758
+ const base = this.config.retry;
759
+ if (base.retryOn) {
760
+ return base;
761
+ }
762
+ if (IDEMPOTENT_METHODS.has(method) || this.hasIdempotencyKey(options)) {
763
+ return base;
764
+ }
765
+ return {
766
+ ...base,
767
+ retryOn: (error) => error instanceof RateLimitError
768
+ };
769
+ }
709
770
  /**
710
771
  * Make a GET request
711
772
  */
@@ -907,8 +968,9 @@ function createCircuitBreaker(config) {
907
968
 
908
969
  // src/resources/auth.ts
909
970
  var AuthResource = class {
910
- constructor(http) {
971
+ constructor(http, getRefreshToken) {
911
972
  this.http = http;
973
+ this.getRefreshToken = getRefreshToken;
912
974
  }
913
975
  /**
914
976
  * Exchange API key for JWT tokens
@@ -923,10 +985,15 @@ var AuthResource = class {
923
985
  return this.http.post("/auth/refresh", params);
924
986
  }
925
987
  /**
926
- * Logout and invalidate current token
988
+ * Logout and revoke the refresh token server-side.
989
+ *
990
+ * The backend requires the refresh token in the body to invalidate the
991
+ * session. When omitted, the client's current refresh token (if any) is
992
+ * used automatically.
927
993
  */
928
- async logout() {
929
- await this.http.post("/auth/logout");
994
+ async logout(refreshToken) {
995
+ const token = refreshToken ?? this.getRefreshToken?.();
996
+ await this.http.post("/auth/logout", token ? { refreshToken: token } : void 0);
930
997
  }
931
998
  /**
932
999
  * Get current user/session info
@@ -1790,13 +1857,16 @@ var WebSocketRealtimeClient = class {
1790
1857
  stateChangeHandlers = /* @__PURE__ */ new Set();
1791
1858
  constructor(options) {
1792
1859
  this.options = {
1793
- autoReconnect: true,
1794
- maxReconnectAttempts: 10,
1795
- reconnectDelay: 1e3,
1796
- maxReconnectDelay: 3e4,
1797
- debug: () => {
1798
- },
1799
- ...options
1860
+ ...options,
1861
+ autoReconnect: options.autoReconnect ?? true,
1862
+ maxReconnectAttempts: options.maxReconnectAttempts ?? 10,
1863
+ reconnectDelay: options.reconnectDelay ?? 1e3,
1864
+ maxReconnectDelay: options.maxReconnectDelay ?? 3e4,
1865
+ // Always resolve to a provider so reconnects can mint a fresh JWT.
1866
+ // Falls back to the static token when no provider is supplied.
1867
+ tokenProvider: options.tokenProvider ?? (async () => options.token),
1868
+ debug: options.debug ?? (() => {
1869
+ })
1800
1870
  };
1801
1871
  }
1802
1872
  /**
@@ -1823,8 +1893,14 @@ var WebSocketRealtimeClient = class {
1823
1893
  return;
1824
1894
  }
1825
1895
  this.setState("connecting");
1826
- const wsUrl = this.buildWsUrl();
1827
- this.options.debug(`Connecting to ${wsUrl}`);
1896
+ let wsUrl;
1897
+ try {
1898
+ wsUrl = await this.buildWsUrl();
1899
+ } catch (error) {
1900
+ this.setState("disconnected");
1901
+ throw new Error(`Failed to acquire realtime token: ${error}`);
1902
+ }
1903
+ this.options.debug(`Connecting to ${redactToken(wsUrl)}`);
1828
1904
  let WebSocketImpl;
1829
1905
  try {
1830
1906
  WebSocketImpl = await this.getWebSocketImpl();
@@ -1938,9 +2014,10 @@ var WebSocketRealtimeClient = class {
1938
2014
  return () => this.stateChangeHandlers.delete(handler);
1939
2015
  }
1940
2016
  // Private methods
1941
- buildWsUrl() {
2017
+ async buildWsUrl() {
1942
2018
  const wsBase = this.options.wsUrl;
1943
- return `${wsBase}/api/v1/ws?token=${encodeURIComponent(this.options.token)}`;
2019
+ const token = await this.options.tokenProvider();
2020
+ return `${wsBase}/api/v1/ws?token=${encodeURIComponent(token)}`;
1944
2021
  }
1945
2022
  setState(state) {
1946
2023
  if (this.state !== state) {
@@ -1950,7 +2027,7 @@ var WebSocketRealtimeClient = class {
1950
2027
  }
1951
2028
  handleMessage(data) {
1952
2029
  try {
1953
- const message = JSON.parse(data);
2030
+ const message = convertResponse(JSON.parse(data));
1954
2031
  if (message.type === "subscribed" || message.type === "unsubscribed" || message.type === "error") {
1955
2032
  this.handleCommandResponse(message);
1956
2033
  return;
@@ -2007,10 +2084,11 @@ var WebSocketRealtimeClient = class {
2007
2084
  scheduleReconnect() {
2008
2085
  this.setState("reconnecting");
2009
2086
  this.reconnectAttempts++;
2010
- const delay = Math.min(
2087
+ const baseDelay = Math.min(
2011
2088
  this.options.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
2012
2089
  this.options.maxReconnectDelay
2013
2090
  );
2091
+ const delay = Math.floor(baseDelay + Math.random() * baseDelay * 0.25);
2014
2092
  this.options.debug(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`);
2015
2093
  this.reconnectTimer = setTimeout(async () => {
2016
2094
  try {
@@ -2058,6 +2136,9 @@ var WebSocketRealtimeClient = class {
2058
2136
  return `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
2059
2137
  }
2060
2138
  };
2139
+ function redactToken(url) {
2140
+ return url.replace(/([?&](?:token|api_key)=)[^&]*/gi, "$1***");
2141
+ }
2061
2142
 
2062
2143
  // src/realtime/sse.ts
2063
2144
  var SseRealtimeClient = class {
@@ -2074,13 +2155,22 @@ var SseRealtimeClient = class {
2074
2155
  stateChangeHandlers = /* @__PURE__ */ new Set();
2075
2156
  constructor(options) {
2076
2157
  this.options = {
2077
- autoReconnect: true,
2078
- maxReconnectAttempts: 10,
2079
- reconnectDelay: 1e3,
2080
- maxReconnectDelay: 3e4,
2081
- debug: () => {
2082
- },
2083
- ...options
2158
+ ...options,
2159
+ autoReconnect: options.autoReconnect ?? true,
2160
+ maxReconnectAttempts: options.maxReconnectAttempts ?? 10,
2161
+ reconnectDelay: options.reconnectDelay ?? 1e3,
2162
+ maxReconnectDelay: options.maxReconnectDelay ?? 3e4,
2163
+ // Always resolve to a provider so reconnects can mint a fresh token.
2164
+ // Falls back to the static token when no provider is supplied.
2165
+ tokenProvider: options.tokenProvider ?? (async () => options.token),
2166
+ // Coalesce debug to a no-op AFTER the spread. A caller that omits debug
2167
+ // still passes `debug: undefined` explicitly (e.g. via SpooledRealtime),
2168
+ // and spreading that would overwrite a pre-spread default with undefined,
2169
+ // making connect() call `this.options.debug(...)` on undefined and throw
2170
+ // "this.options.debug is not a function". Guarding here keeps it callable
2171
+ // regardless of what the caller passes.
2172
+ debug: options.debug ?? (() => {
2173
+ })
2084
2174
  };
2085
2175
  }
2086
2176
  /**
@@ -2112,9 +2202,15 @@ var SseRealtimeClient = class {
2112
2202
  throw new Error("eventsource package is required for SSE in Node.js. Install it with: npm install eventsource");
2113
2203
  }
2114
2204
  }
2205
+ let token;
2206
+ try {
2207
+ token = await this.options.tokenProvider();
2208
+ } catch (error) {
2209
+ this.setState("disconnected");
2210
+ throw new Error(`Failed to acquire realtime token: ${error}`);
2211
+ }
2115
2212
  return new Promise((resolve, reject) => {
2116
2213
  try {
2117
- const token = this.options.token;
2118
2214
  this.eventSource = new EventSourceImpl(sseUrl, {
2119
2215
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2120
2216
  fetch: (input, init) => fetch(input, {
@@ -2138,6 +2234,10 @@ var SseRealtimeClient = class {
2138
2234
  this.options.debug("SSE error");
2139
2235
  if (this.state === "connecting") {
2140
2236
  this.setState("disconnected");
2237
+ if (this.eventSource) {
2238
+ this.eventSource.close();
2239
+ this.eventSource = null;
2240
+ }
2141
2241
  reject(new Error("SSE connection failed"));
2142
2242
  return;
2143
2243
  }
@@ -2222,7 +2322,7 @@ var SseRealtimeClient = class {
2222
2322
  }
2223
2323
  handleMessage(data) {
2224
2324
  try {
2225
- const event = JSON.parse(data);
2325
+ const event = convertResponse(JSON.parse(data));
2226
2326
  this.options.debug(`Received SSE event: ${event.type}`, event);
2227
2327
  const handlers = this.eventHandlers.get(event.type);
2228
2328
  if (handlers) {
@@ -2260,10 +2360,11 @@ var SseRealtimeClient = class {
2260
2360
  scheduleReconnect() {
2261
2361
  this.setState("reconnecting");
2262
2362
  this.reconnectAttempts++;
2263
- const delay = Math.min(
2363
+ const baseDelay = Math.min(
2264
2364
  this.options.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
2265
2365
  this.options.maxReconnectDelay
2266
2366
  );
2367
+ const delay = Math.floor(baseDelay + Math.random() * baseDelay * 0.25);
2267
2368
  this.options.debug(`Scheduling SSE reconnect attempt ${this.reconnectAttempts} in ${delay}ms`);
2268
2369
  this.reconnectTimer = setTimeout(async () => {
2269
2370
  try {
@@ -2284,6 +2385,7 @@ var SpooledRealtime = class {
2284
2385
  baseUrl: options.baseUrl,
2285
2386
  wsUrl: options.wsUrl,
2286
2387
  token: options.token,
2388
+ tokenProvider: options.tokenProvider,
2287
2389
  autoReconnect: options.autoReconnect,
2288
2390
  maxReconnectAttempts: options.maxReconnectAttempts,
2289
2391
  reconnectDelay: options.reconnectDelay,
@@ -2783,7 +2885,7 @@ var SpooledClient = class _SpooledClient {
2783
2885
  if (this.config.accessToken && this.config.refreshToken && this.config.autoRefreshToken) {
2784
2886
  this.http.setRefreshTokenFn(this.refreshAccessToken.bind(this));
2785
2887
  }
2786
- this.auth = new AuthResource(this.http);
2888
+ this.auth = new AuthResource(this.http, () => this.config.refreshToken);
2787
2889
  this.jobs = new JobsResource(this.http);
2788
2890
  this.queues = new QueuesResource(this.http);
2789
2891
  this.workers = new WorkersResource(this.http);
@@ -2862,8 +2964,15 @@ var SpooledClient = class _SpooledClient {
2862
2964
  baseUrl: this.config.baseUrl,
2863
2965
  wsUrl: this.config.wsUrl,
2864
2966
  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(),
2865
2970
  ...options,
2866
- debug: this.config.debug ?? void 0
2971
+ // Only forward debug when the client actually has a logger, so we never
2972
+ // hand the realtime layer an explicit `debug: undefined`. The WS/SSE
2973
+ // constructors also guard against this, but keeping the undefined out of
2974
+ // the option object makes the intent clear at the call site.
2975
+ ...this.config.debug ? { debug: this.config.debug } : {}
2867
2976
  });
2868
2977
  }
2869
2978
  /**
@@ -3169,9 +3278,11 @@ var SpooledWorker = class {
3169
3278
  this.waitForActiveJobs(),
3170
3279
  this.sleep(this.options.shutdownTimeout)
3171
3280
  ]);
3172
- for (const [jobId, active] of this.activeJobs.entries()) {
3281
+ const remaining = Array.from(this.activeJobs.entries());
3282
+ for (const [jobId, active] of remaining) {
3173
3283
  this.debug(`Force-failing job ${jobId} due to shutdown timeout`);
3174
3284
  await this.failJob(active.job, "Worker shutdown timeout");
3285
+ this.cleanupJob(jobId);
3175
3286
  }
3176
3287
  }
3177
3288
  if (this.workerId) {