@classytic/arc-next 0.6.0 → 0.7.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/query.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CacheUtils, DEFAULT_QUERY_CONFIG, PaginationData, QUERY_CONFIGS, QueryKeys, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache } from "./cache.js";
2
- import { InfiniteData, QueryKey } from "@tanstack/react-query";
2
+ import { InfiniteData, QueryClient, QueryKey } from "@tanstack/react-query";
3
3
 
4
4
  //#region src/query.d.ts
5
5
  /** Request-level options passed through to the fetch call */
@@ -17,6 +17,17 @@ interface ListQueryOptions<TData = unknown> {
17
17
  gcTime?: number;
18
18
  refetchOnWindowFocus?: boolean;
19
19
  structuralSharing?: boolean;
20
+ /**
21
+ * @deprecated No-op since 0.7. The old setQueryData-based prefill was
22
+ * removed because it (1) stored a `{ data: ... }` envelope that didn't
23
+ * match arc 2.13+'s raw doc wire, (2) blocked subsequent detail GETs from
24
+ * firing (fresh staleTime), and (3) clobbered detail responses every
25
+ * render due to an unstable effect dep. `useDetail` / `useDetailBySlug`
26
+ * now read list cache via `placeholderData` — the canonical TanStack
27
+ * pattern — so the GET always fires while consumers see an instant
28
+ * preview. Safe to remove from caller code. Retained for compile-time
29
+ * compatibility; will be deleted in a future major.
30
+ */
20
31
  prefillDetailCache?: boolean;
21
32
  refetchInterval?: number | false;
22
33
  refetchIntervalInBackground?: boolean;
@@ -65,6 +76,13 @@ interface DetailQueryResult<T> {
65
76
  isError: boolean;
66
77
  isSuccess: boolean;
67
78
  isStale: boolean;
79
+ /**
80
+ * `true` while the detail GET is in flight and the consumer is seeing a
81
+ * preview derived from a list cache entry (or any other `placeholderData`).
82
+ * Use to dim or label the preview, e.g. `<article aria-busy={isPlaceholderData}>`.
83
+ * Flips to `false` once the real detail payload resolves.
84
+ */
85
+ isPlaceholderData: boolean;
68
86
  error: Error | null;
69
87
  refetch: () => Promise<unknown>;
70
88
  data: unknown;
@@ -76,23 +94,52 @@ interface CreateListQueryConfig {
76
94
  }) => Promise<unknown>;
77
95
  enabled?: boolean;
78
96
  options?: Record<string, unknown>;
79
- prefillDetailCache?: boolean;
80
- detailKeyBuilder?: (id: string) => QueryKey;
81
- /** Custom ID extractor for cache prefill. Falls back to getItemId (_id → id). */
82
- itemIdResolver?: (item: unknown) => string | null;
83
97
  select?: (data: unknown) => unknown;
84
98
  }
99
+ /**
100
+ * List query hook. Returns the canonical `{ items, pagination, ... }` shape
101
+ * derived from arc's `PaginatedResult` wire envelope.
102
+ *
103
+ * **No detail-cache prefill.** Older revisions of this hook wrote each list
104
+ * item into the detail cache via `setQueryData`. That pattern was wrong on
105
+ * three counts: (1) it stored a `{ data: ... }` envelope that didn't match
106
+ * arc 2.13+'s raw doc wire shape, (2) the fresh-default `staleTime` made
107
+ * subsequent `useDetail` calls reuse the partial list payload and never fetch
108
+ * the rich detail response, and (3) the unstable `detailKeyBuilder` ref made
109
+ * the prefill effect re-run on every render, clobbering successful detail
110
+ * fetches. The clean fix is on the read side: `useDetail` now reads list cache
111
+ * directly via `placeholderData`, so the detail GET still fires while the
112
+ * consumer sees an instant list-shaped preview. See `findItemInListCache`.
113
+ */
85
114
  declare function useListQuery<T>({
86
115
  queryKey,
87
116
  queryFn,
88
117
  enabled,
89
118
  options,
90
- prefillDetailCache,
91
- detailKeyBuilder,
92
- itemIdResolver,
93
119
  select
94
120
  }: CreateListQueryConfig): ListQueryResult<T>;
95
- interface CreateDetailQueryConfig {
121
+ /**
122
+ * Find an item in any list cache for this entity by ID.
123
+ *
124
+ * Walks every `[entity, 'list', ...]` query (including scoped variants and
125
+ * infinite-list page arrays) and extracts items via the permissive list
126
+ * detector. Returns the first match — list payloads are subsets of detail
127
+ * payloads, so the consumer sees an instant preview while the real detail
128
+ * GET runs in the background.
129
+ *
130
+ * This is the canonical TanStack pattern for "show list data while fetching
131
+ * detail": pass the returned value as `placeholderData` to `useDetail`. The
132
+ * value isn't persisted to the detail cache (no pollution), `staleTime`
133
+ * doesn't apply to it (always refetches), and `isPlaceholderData` is `true`
134
+ * until the GET resolves.
135
+ *
136
+ * @param qc TanStack QueryClient (typically from `useQueryClient()`)
137
+ * @param listsKey Prefix key for this entity's lists (e.g. `KEYS.lists()`)
138
+ * @param id Item ID being requested
139
+ * @param idField Optional custom ID field (matches `createCrudHooks({ idField })`)
140
+ */
141
+ declare function findItemInListCache<T>(qc: QueryClient, listsKey: QueryKey, id: string, idField?: string): T | undefined;
142
+ interface CreateDetailQueryConfig<T = unknown> {
96
143
  queryKey: QueryKey;
97
144
  queryFn: (context: {
98
145
  signal?: AbortSignal;
@@ -100,14 +147,22 @@ interface CreateDetailQueryConfig {
100
147
  enabled?: boolean;
101
148
  options?: Record<string, unknown>;
102
149
  select?: (data: unknown) => unknown;
150
+ /**
151
+ * Lazy placeholder. Returns a value (or `undefined`) on each render to show
152
+ * before the detail GET resolves. Use with `findItemInListCache` to derive
153
+ * an instant preview from list cache without polluting the detail cache —
154
+ * the canonical TanStack pattern for "show list data while fetching detail."
155
+ */
156
+ placeholderData?: () => T | undefined;
103
157
  }
104
158
  declare function useDetailQuery<T>({
105
159
  queryKey,
106
160
  queryFn,
107
161
  enabled,
108
162
  options,
109
- select
110
- }: CreateDetailQueryConfig): DetailQueryResult<T>;
163
+ select,
164
+ placeholderData
165
+ }: CreateDetailQueryConfig<T>): DetailQueryResult<T>;
111
166
  interface InfiniteListQueryOptions {
112
167
  public?: boolean;
113
168
  enabled?: boolean;
@@ -250,4 +305,4 @@ declare function useApiQuery<TResponse = unknown, TData = ExtractData<TResponse>
250
305
  options
251
306
  }: UseApiQueryConfig<TResponse, TData>): UseApiQueryResult<TData>;
252
307
  //#endregion
253
- export { type CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, ExtractData, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, type PaginationData, QUERY_CONFIGS, QueryFreshness, type QueryKeys, RequestPassthrough, UseApiQueryConfig, UseApiQueryOptions, UseApiQueryResult, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
308
+ export { type CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, ExtractData, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, type PaginationData, QUERY_CONFIGS, QueryFreshness, type QueryKeys, RequestPassthrough, UseApiQueryConfig, UseApiQueryOptions, UseApiQueryResult, createCacheUtils, createQueryKeys, extractItem, extractItems, findItemInListCache, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
package/dist/query.js CHANGED
@@ -1,12 +1,26 @@
1
1
  "use client";
2
2
 
3
3
  import { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache } from "./cache.js";
4
- import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
5
- import { useCallback, useEffect, useMemo, useRef } from "react";
4
+ import { keepPreviousData, useInfiniteQuery, useQuery } from "@tanstack/react-query";
5
+ import { useCallback, useMemo, useRef } from "react";
6
6
 
7
7
  //#region src/query.ts
8
- function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefillDetailCache = true, detailKeyBuilder, itemIdResolver, select }) {
9
- const queryClient = useQueryClient();
8
+ /**
9
+ * List query hook. Returns the canonical `{ items, pagination, ... }` shape
10
+ * derived from arc's `PaginatedResult` wire envelope.
11
+ *
12
+ * **No detail-cache prefill.** Older revisions of this hook wrote each list
13
+ * item into the detail cache via `setQueryData`. That pattern was wrong on
14
+ * three counts: (1) it stored a `{ data: ... }` envelope that didn't match
15
+ * arc 2.13+'s raw doc wire shape, (2) the fresh-default `staleTime` made
16
+ * subsequent `useDetail` calls reuse the partial list payload and never fetch
17
+ * the rich detail response, and (3) the unstable `detailKeyBuilder` ref made
18
+ * the prefill effect re-run on every render, clobbering successful detail
19
+ * fetches. The clean fix is on the read side: `useDetail` now reads list cache
20
+ * directly via `placeholderData`, so the detail GET still fires while the
21
+ * consumer sees an instant list-shaped preview. See `findItemInListCache`.
22
+ */
23
+ function useListQuery({ queryKey, queryFn, enabled = true, options = {}, select }) {
10
24
  const query = useQuery({
11
25
  queryKey,
12
26
  queryFn: ({ signal }) => queryFn({ signal }),
@@ -16,24 +30,9 @@ function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefill
16
30
  ...select ? { select } : {},
17
31
  placeholderData: keepPreviousData
18
32
  });
19
- const items = useMemo(() => extractItems(query.data), [query.data]);
20
- const pagination = useMemo(() => normalizePagination(query.data), [query.data]);
21
- useEffect(() => {
22
- if (!prefillDetailCache || !detailKeyBuilder || items.length === 0) return;
23
- const resolveId = itemIdResolver ?? getItemId;
24
- items.forEach((item) => {
25
- const id = resolveId(item);
26
- if (id) queryClient.setQueryData(detailKeyBuilder(id), { data: item });
27
- });
28
- }, [
29
- items,
30
- prefillDetailCache,
31
- detailKeyBuilder,
32
- queryClient
33
- ]);
34
33
  return {
35
- items,
36
- pagination,
34
+ items: useMemo(() => extractItems(query.data), [query.data]),
35
+ pagination: useMemo(() => normalizePagination(query.data), [query.data]),
37
36
  isLoading: query.isLoading,
38
37
  isFetching: query.isFetching,
39
38
  isError: query.isError,
@@ -44,14 +43,50 @@ function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefill
44
43
  data: query.data
45
44
  };
46
45
  }
47
- function useDetailQuery({ queryKey, queryFn, enabled = true, options = {}, select }) {
46
+ /**
47
+ * Find an item in any list cache for this entity by ID.
48
+ *
49
+ * Walks every `[entity, 'list', ...]` query (including scoped variants and
50
+ * infinite-list page arrays) and extracts items via the permissive list
51
+ * detector. Returns the first match — list payloads are subsets of detail
52
+ * payloads, so the consumer sees an instant preview while the real detail
53
+ * GET runs in the background.
54
+ *
55
+ * This is the canonical TanStack pattern for "show list data while fetching
56
+ * detail": pass the returned value as `placeholderData` to `useDetail`. The
57
+ * value isn't persisted to the detail cache (no pollution), `staleTime`
58
+ * doesn't apply to it (always refetches), and `isPlaceholderData` is `true`
59
+ * until the GET resolves.
60
+ *
61
+ * @param qc TanStack QueryClient (typically from `useQueryClient()`)
62
+ * @param listsKey Prefix key for this entity's lists (e.g. `KEYS.lists()`)
63
+ * @param id Item ID being requested
64
+ * @param idField Optional custom ID field (matches `createCrudHooks({ idField })`)
65
+ */
66
+ function findItemInListCache(qc, listsKey, id, idField) {
67
+ if (!id) return void 0;
68
+ const entries = qc.getQueriesData({ queryKey: listsKey });
69
+ for (const [, raw] of entries) {
70
+ if (!raw) continue;
71
+ const pages = typeof raw === "object" && raw !== null && Array.isArray(raw.pages) ? raw.pages : [raw];
72
+ for (const page of pages) {
73
+ const items = extractItems(page);
74
+ for (const item of items) {
75
+ const got = idField ? item?.[idField] : getItemId(item);
76
+ if (got != null && String(got) === id) return item;
77
+ }
78
+ }
79
+ }
80
+ }
81
+ function useDetailQuery({ queryKey, queryFn, enabled = true, options = {}, select, placeholderData }) {
48
82
  const query = useQuery({
49
83
  queryKey,
50
84
  queryFn: ({ signal }) => queryFn({ signal }),
51
85
  enabled,
52
86
  ...DEFAULT_QUERY_CONFIG,
53
87
  ...options,
54
- ...select ? { select } : {}
88
+ ...select ? { select } : {},
89
+ ...placeholderData ? { placeholderData } : {}
55
90
  });
56
91
  return {
57
92
  item: extractItem(query.data),
@@ -60,6 +95,7 @@ function useDetailQuery({ queryKey, queryFn, enabled = true, options = {}, selec
60
95
  isError: query.isError,
61
96
  isSuccess: query.isSuccess,
62
97
  isStale: query.isStale,
98
+ isPlaceholderData: query.isPlaceholderData,
63
99
  error: query.error,
64
100
  refetch: query.refetch,
65
101
  data: query.data
@@ -155,4 +191,4 @@ function useApiQuery({ queryKey, queryFn, enabled = true, freshness, select, opt
155
191
  }
156
192
 
157
193
  //#endregion
158
- export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
194
+ export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, findItemInListCache, getItemId, normalizePagination, updateListCache, useApiQuery, useDetailQuery, useInfiniteListQuery, useListQuery };
package/dist/sse.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { buildStreamUrl, getAuthMode } from "./client.js";
3
+ import { ArcApiError, _getAuthErrorHandler, _runAuthRecovery, buildStreamUrl, getAuthMode } from "./client.js";
4
4
  import { useQueryClient } from "@tanstack/react-query";
5
5
  import { useEffect, useMemo, useRef, useState } from "react";
6
6
 
@@ -21,6 +21,35 @@ function buildSseUrl(path, params = {}) {
21
21
  return buildStreamUrl(path, params, "http");
22
22
  }
23
23
  /**
24
+ * Send a minimal HEAD/GET probe to the SSE URL to classify an EventSource
25
+ * failure as either an auth failure (401, or 403 when `retryOn403`) or
26
+ * something transient (network, 5xx, CORS). EventSource itself doesn't
27
+ * expose status codes — this is the only cross-browser way to route SSE
28
+ * close events through `onAuthError`.
29
+ *
30
+ * The probe uses HEAD when supported (cheaper); falls back to GET with
31
+ * `Range: bytes=0-0` for servers that 405 on HEAD. Either way, the body
32
+ * is never read — only the status code matters.
33
+ */
34
+ async function probeForAuthFailure(url, retryOn403) {
35
+ try {
36
+ let res = await fetch(url, {
37
+ method: "HEAD",
38
+ credentials: "include"
39
+ });
40
+ if (res.status === 405) res = await fetch(url, {
41
+ method: "GET",
42
+ credentials: "include",
43
+ headers: { Range: "bytes=0-0" }
44
+ });
45
+ if (res.status === 401) return "auth-failure";
46
+ if (retryOn403 && res.status === 403) return "auth-failure";
47
+ return "not-auth";
48
+ } catch {
49
+ return "not-auth";
50
+ }
51
+ }
52
+ /**
24
53
  * Subscribe to an Arc SSE stream from any JS context (React, Node, Bun, tests).
25
54
  * Pure function — no React hook required. Returns a handle with `close()` /
26
55
  * `reconnect()` / `isConnected()`.
@@ -68,6 +97,7 @@ function subscribeToEvents(options) {
68
97
  es = new EventSource(buildUrl(), { withCredentials: credentials });
69
98
  es.onopen = () => {
70
99
  reconnectAttempts = 0;
100
+ sseAuthRetries = 0;
71
101
  connected = true;
72
102
  options.onConnectionChange?.(true);
73
103
  };
@@ -98,13 +128,49 @@ function subscribeToEvents(options) {
98
128
  connected = false;
99
129
  options.onConnectionChange?.(false);
100
130
  if (manualClose) return;
101
- if (reconnectAttempts < maxReconnectAttempts) {
102
- reconnectAttempts += 1;
103
- const delay = Math.min(reconnectDelay * Math.pow(1.5, reconnectAttempts - 1), 3e4);
104
- reconnectTimer = setTimeout(connect, delay);
131
+ const { handler, retryOn403, maxAuthRetries } = _getAuthErrorHandler();
132
+ if (handler && sseAuthRetries < maxAuthRetries) {
133
+ sseAuthRetries += 1;
134
+ probeForAuthFailure(buildUrl(), retryOn403).then(async (status) => {
135
+ if (status === "auth-failure") {
136
+ const { decision } = await _runAuthRecovery(handler, {
137
+ error: new ArcApiError("SSE pre-flight auth failure", {
138
+ status: 401,
139
+ statusText: "SSE auth failure",
140
+ json: { code: "arc.sse.unauthorized" },
141
+ endpoint: ssePath,
142
+ method: "GET"
143
+ }),
144
+ request: {
145
+ method: "GET",
146
+ endpoint: ssePath
147
+ },
148
+ attempt: sseAuthRetries
149
+ });
150
+ if (decision === "retry") {
151
+ reconnectAttempts = 0;
152
+ connect();
153
+ return;
154
+ }
155
+ }
156
+ scheduleReconnect();
157
+ }).catch(() => {
158
+ scheduleReconnect();
159
+ });
160
+ return;
105
161
  }
162
+ scheduleReconnect();
106
163
  };
107
164
  };
165
+ /** Standard backoff-reconnect — shared between non-auth errors and skipped recoveries. */
166
+ const scheduleReconnect = () => {
167
+ if (reconnectAttempts < maxReconnectAttempts) {
168
+ reconnectAttempts += 1;
169
+ const delay = Math.min(reconnectDelay * Math.pow(1.5, reconnectAttempts - 1), 3e4);
170
+ reconnectTimer = setTimeout(connect, delay);
171
+ }
172
+ };
173
+ let sseAuthRetries = 0;
108
174
  connect();
109
175
  return {
110
176
  close: () => {
package/dist/upload.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ArcApiError, getAuthMode, getBaseUrl, getClientAuthContext } from "./client.js";
1
+ import { ArcApiError, _getAuthErrorHandler, _isAuthRecoverable, _resolveRefreshedToken, _runAuthRecovery, getAuthMode, getBaseUrl, getClientAuthContext } from "./client.js";
2
2
  import { getToastHandler } from "./mutation.js";
3
3
  import { useQueryClient } from "@tanstack/react-query";
4
4
  import { useCallback, useRef, useState } from "react";
@@ -78,7 +78,38 @@ import { useCallback, useRef, useState } from "react";
78
78
  * onProgress: ({ percent }) => setUiProgress(percent),
79
79
  * });
80
80
  */
81
- function uploadWithProgress(options) {
81
+ async function uploadWithProgress(options) {
82
+ const { handler, retryOn403, maxAuthRetries } = _getAuthErrorHandler();
83
+ if (!handler) return uploadAttempt(options);
84
+ let currentOptions = options;
85
+ for (let attempt = 0; attempt <= maxAuthRetries; attempt++) try {
86
+ return await uploadAttempt(currentOptions);
87
+ } catch (error) {
88
+ if (attempt >= maxAuthRetries || !_isAuthRecoverable(error, retryOn403)) throw error;
89
+ if (currentOptions.signal?.aborted) throw error;
90
+ const { decision, overrideToken } = await _runAuthRecovery(handler, {
91
+ error,
92
+ request: {
93
+ method: currentOptions.method ?? "POST",
94
+ endpoint: currentOptions.url
95
+ },
96
+ attempt: attempt + 1
97
+ });
98
+ if (decision !== "retry") throw error;
99
+ currentOptions = {
100
+ ...currentOptions,
101
+ token: _resolveRefreshedToken(overrideToken)
102
+ };
103
+ }
104
+ throw new Error("arc-next: upload auth retry loop terminated without resolution");
105
+ }
106
+ /**
107
+ * Single XHR attempt. Identical to the pre-0.7 body of `uploadWithProgress`
108
+ * — extracted so the auth-retry loop above can re-run it with a refreshed
109
+ * token without duplicating the XHR setup. All auth-recovery semantics live
110
+ * in the outer loop; this function just performs one upload.
111
+ */
112
+ function uploadAttempt(options) {
82
113
  const { url, formData, method = "POST", onProgress, signal, client, headers: extraHeaders, elevated, idempotencyKey, responseType = "json" } = options;
83
114
  return new Promise((resolve, reject) => {
84
115
  if (signal?.aborted) {
package/dist/ws.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { buildStreamUrl } from "./client.js";
3
+ import { ArcApiError, _getAuthErrorHandler, _runAuthRecovery, buildStreamUrl } from "./client.js";
4
4
  import { useQueryClient } from "@tanstack/react-query";
5
5
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
6
 
@@ -69,9 +69,15 @@ function connectWs(options = {}) {
69
69
  if (wildcard) for (const fn of wildcard) fn(message);
70
70
  };
71
71
  const connect = () => {
72
- if (ws) try {
73
- ws.close();
74
- } catch {}
72
+ if (ws) {
73
+ ws.onclose = null;
74
+ ws.onerror = null;
75
+ ws.onmessage = null;
76
+ ws.onopen = null;
77
+ try {
78
+ ws.close();
79
+ } catch {}
80
+ }
75
81
  if (heartbeatTimer) {
76
82
  clearInterval(heartbeatTimer);
77
83
  heartbeatTimer = null;
@@ -81,6 +87,7 @@ function connectWs(options = {}) {
81
87
  ws = protocols !== void 0 ? new WebSocket(wsUrl, protocols) : new WebSocket(wsUrl);
82
88
  ws.onopen = () => {
83
89
  reconnectAttempts = 0;
90
+ wsAuthRetries = 0;
84
91
  connected = true;
85
92
  options.onConnectionChange?.(true);
86
93
  for (const resource of subscriptions) sendRaw({
@@ -104,7 +111,7 @@ function connectWs(options = {}) {
104
111
  dispatch(parsed);
105
112
  };
106
113
  ws.onerror = () => {};
107
- ws.onclose = () => {
114
+ ws.onclose = (event) => {
108
115
  connected = false;
109
116
  options.onConnectionChange?.(false);
110
117
  if (heartbeatTimer) {
@@ -112,6 +119,35 @@ function connectWs(options = {}) {
112
119
  heartbeatTimer = null;
113
120
  }
114
121
  if (manualClose) return;
122
+ const { handler, maxAuthRetries } = _getAuthErrorHandler();
123
+ const isAuthClose = event.code === 1008 || event.code === 3401 || event.code === 4001 || event.code === 4401;
124
+ if (handler && isAuthClose && wsAuthRetries < maxAuthRetries) {
125
+ wsAuthRetries += 1;
126
+ _runAuthRecovery(handler, {
127
+ error: new ArcApiError(event.reason || `WebSocket closed with auth code ${event.code}`, {
128
+ status: 401,
129
+ statusText: "WebSocket auth failure",
130
+ json: {
131
+ code: "arc.websocket.unauthorized",
132
+ wsCloseCode: event.code,
133
+ reason: event.reason
134
+ },
135
+ endpoint: url ?? path,
136
+ method: "GET"
137
+ }),
138
+ request: {
139
+ method: "GET",
140
+ endpoint: url ?? path
141
+ },
142
+ attempt: wsAuthRetries
143
+ }).then(({ decision }) => {
144
+ if (decision === "retry") {
145
+ reconnectAttempts = 0;
146
+ connect();
147
+ }
148
+ }).catch(() => {});
149
+ return;
150
+ }
115
151
  if (reconnectAttempts < maxReconnectAttempts) {
116
152
  reconnectAttempts += 1;
117
153
  const delay = Math.min(reconnectDelay * Math.pow(1.5, reconnectAttempts - 1), 3e4);
@@ -119,6 +155,7 @@ function connectWs(options = {}) {
119
155
  }
120
156
  };
121
157
  };
158
+ let wsAuthRetries = 0;
122
159
  connect();
123
160
  return {
124
161
  isConnected: () => connected,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc-next",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "React + TanStack Query SDK for Arc resources",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -123,12 +123,17 @@
123
123
  },
124
124
  "peerDependencies": {
125
125
  "@classytic/repo-core": ">=0.4.0",
126
- "@tanstack/react-query": ">=5.0.0",
126
+ "@tanstack/react-query": ">=5.62.0",
127
127
  "react": ">=19.0.0"
128
128
  },
129
+ "peerDependenciesMeta": {
130
+ "react": { "optional": false },
131
+ "@tanstack/react-query": { "optional": false },
132
+ "@classytic/repo-core": { "optional": false }
133
+ },
129
134
  "devDependencies": {
130
135
  "@classytic/dev-tools": "^0.2.0",
131
- "@classytic/repo-core": "^0.4.0",
136
+ "@classytic/repo-core": "^0.5.0",
132
137
  "@tanstack/react-query": "^5.97.0",
133
138
  "@testing-library/jest-dom": "^6.9.1",
134
139
  "@testing-library/react": "^16.3.2",