@classytic/arc-next 0.5.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;
@@ -169,13 +224,13 @@ declare function useInfiniteListQuery<T>({
169
224
  /** Recognized data-freshness presets. Maps to QUERY_CONFIGS. */
170
225
  type QueryFreshness = keyof typeof QUERY_CONFIGS;
171
226
  /**
172
- * Extracts the inner type of an Arc envelope `{ success, data: T }`.
173
- * Falls through to `T` itself when the response isn't an envelope.
227
+ * Identity passthrough arc emits raw data on success (no envelope; HTTP
228
+ * status discriminates errors via thrown `ArcApiError`). Kept as a named
229
+ * type for the `useApiQuery` default param so the public signature stays
230
+ * `useApiQuery<TResponse, TData = ExtractData<TResponse>>` for callers
231
+ * that want a custom select projection.
174
232
  */
175
- type ExtractData<T> = T extends {
176
- success: unknown;
177
- data: infer D;
178
- } ? D : T;
233
+ type ExtractData<T> = T;
179
234
  /** Per-call request pass-through and TanStack Query overrides. */
180
235
  interface UseApiQueryOptions {
181
236
  staleTime?: number;
@@ -196,9 +251,9 @@ interface UseApiQueryConfig<TResponse, TData> {
196
251
  /** Freshness preset name (`realtime` | `frequent` | `stable` | `static`). */
197
252
  freshness?: QueryFreshness;
198
253
  /**
199
- * Custom projection from the raw response. When omitted, the hook auto-unwraps
200
- * Arc envelopes (`{ success, data }`) and returns `data`. Non-envelope responses
201
- * pass through unchanged.
254
+ * Custom projection from the raw response. When omitted, the response is
255
+ * returned unchanged arc 2.13+ has no wire envelope, so the response
256
+ * already IS the data.
202
257
  */
203
258
  select?: (response: TResponse) => TData;
204
259
  /** TanStack Query overrides (staleTime, refetchInterval, retry, etc.). */
@@ -216,29 +271,29 @@ interface UseApiQueryResult<TData> {
216
271
  }
217
272
  /**
218
273
  * Generic query hook for non-CRUD reads (reports, aggregates, lookups, RPC-style
219
- * endpoints). Wraps TanStack Query with three ergonomics on top:
274
+ * endpoints). Wraps TanStack Query with two ergonomics on top:
220
275
  *
221
- * 1. **Envelope auto-unwrap.** `{ success, data: T }` responses are projected to
222
- * `T` automatically. Override with a custom `select` for shape changes or
223
- * multi-field projections.
224
- * 2. **Freshness presets.** Pass `freshness: 'realtime' | 'frequent' | 'stable' |
276
+ * 1. **Freshness presets.** Pass `freshness: 'realtime' | 'frequent' | 'stable' |
225
277
  * 'static'` to map onto `QUERY_CONFIGS`. Per-option overrides still win.
226
- * 3. **Standardized result shape.** `{ data, isLoading, isFetching, isError,
278
+ * 2. **Standardized result shape.** `{ data, isLoading, isFetching, isError,
227
279
  * isSuccess, isStale, error, refetch }` — same contract as the CRUD hooks.
228
280
  *
281
+ * Arc emits raw data on success (no envelope), so the response IS the data.
282
+ * Use `select` for shape transformations / multi-field projections.
283
+ *
229
284
  * @example
230
- * // Auto-unwrap envelope
231
- * const { data } = useApiQuery<ApiResponse<DashboardStats>>({
285
+ * // Direct typing — response IS the data
286
+ * const { data } = useApiQuery<DashboardStats>({
232
287
  * queryKey: ['dashboard', 'stats'],
233
288
  * queryFn: ({ signal }) => api.request('GET', '/dashboard/stats', { options: { signal } }),
234
289
  * freshness: 'realtime',
235
290
  * });
236
291
  *
237
- * // Custom projection
292
+ * // Custom projection (e.g. extract a sub-field)
238
293
  * const { data } = useApiQuery({
239
294
  * queryKey: ['ledger', accountId],
240
- * queryFn: ({ signal }) => api.request('GET', `/ledger/${accountId}`, { options: { signal } }),
241
- * select: (res) => res.data?.entries ?? [],
295
+ * queryFn: ({ signal }) => api.request<{ entries: Entry[] }>('GET', `/ledger/${accountId}`, { options: { signal } }),
296
+ * select: (res) => res.entries,
242
297
  * });
243
298
  */
244
299
  declare function useApiQuery<TResponse = unknown, TData = ExtractData<TResponse>>({
@@ -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
@@ -98,40 +134,30 @@ function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {},
98
134
  };
99
135
  }
100
136
  /**
101
- * Strict Arc envelope detector — requires BOTH `success` and `data` keys.
102
- *
103
- * Stricter than checking only `data` so we don't mis-unwrap responses that happen
104
- * to use a `data` field for unrelated semantics (e.g. a chart that returns
105
- * `{ data: number[], labels: [] }`).
106
- */
107
- function isArcEnvelope(value) {
108
- return typeof value === "object" && value !== null && "success" in value && "data" in value;
109
- }
110
- /**
111
137
  * Generic query hook for non-CRUD reads (reports, aggregates, lookups, RPC-style
112
- * endpoints). Wraps TanStack Query with three ergonomics on top:
138
+ * endpoints). Wraps TanStack Query with two ergonomics on top:
113
139
  *
114
- * 1. **Envelope auto-unwrap.** `{ success, data: T }` responses are projected to
115
- * `T` automatically. Override with a custom `select` for shape changes or
116
- * multi-field projections.
117
- * 2. **Freshness presets.** Pass `freshness: 'realtime' | 'frequent' | 'stable' |
140
+ * 1. **Freshness presets.** Pass `freshness: 'realtime' | 'frequent' | 'stable' |
118
141
  * 'static'` to map onto `QUERY_CONFIGS`. Per-option overrides still win.
119
- * 3. **Standardized result shape.** `{ data, isLoading, isFetching, isError,
142
+ * 2. **Standardized result shape.** `{ data, isLoading, isFetching, isError,
120
143
  * isSuccess, isStale, error, refetch }` — same contract as the CRUD hooks.
121
144
  *
145
+ * Arc emits raw data on success (no envelope), so the response IS the data.
146
+ * Use `select` for shape transformations / multi-field projections.
147
+ *
122
148
  * @example
123
- * // Auto-unwrap envelope
124
- * const { data } = useApiQuery<ApiResponse<DashboardStats>>({
149
+ * // Direct typing — response IS the data
150
+ * const { data } = useApiQuery<DashboardStats>({
125
151
  * queryKey: ['dashboard', 'stats'],
126
152
  * queryFn: ({ signal }) => api.request('GET', '/dashboard/stats', { options: { signal } }),
127
153
  * freshness: 'realtime',
128
154
  * });
129
155
  *
130
- * // Custom projection
156
+ * // Custom projection (e.g. extract a sub-field)
131
157
  * const { data } = useApiQuery({
132
158
  * queryKey: ['ledger', accountId],
133
- * queryFn: ({ signal }) => api.request('GET', `/ledger/${accountId}`, { options: { signal } }),
134
- * select: (res) => res.data?.entries ?? [],
159
+ * queryFn: ({ signal }) => api.request<{ entries: Entry[] }>('GET', `/ledger/${accountId}`, { options: { signal } }),
160
+ * select: (res) => res.entries,
135
161
  * });
136
162
  */
137
163
  function useApiQuery({ queryKey, queryFn, enabled = true, freshness, select, options = {} }) {
@@ -140,7 +166,7 @@ function useApiQuery({ queryKey, queryFn, enabled = true, freshness, select, opt
140
166
  const projection = useCallback((response) => {
141
167
  const fn = selectRef.current;
142
168
  if (fn) return fn(response);
143
- return isArcEnvelope(response) ? response.data : response;
169
+ return response;
144
170
  }, []);
145
171
  const preset = freshness ? QUERY_CONFIGS[freshness] : void 0;
146
172
  const query = useQuery({
@@ -165,4 +191,4 @@ function useApiQuery({ queryKey, queryFn, enabled = true, freshness, select, opt
165
191
  }
166
192
 
167
193
  //#endregion
168
- 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.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "React + TanStack Query SDK for Arc resources",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -122,11 +122,18 @@
122
122
  "prepublishOnly": "npm run typecheck && npm test && npm run build"
123
123
  },
124
124
  "peerDependencies": {
125
- "@tanstack/react-query": ">=5.0.0",
125
+ "@classytic/repo-core": ">=0.4.0",
126
+ "@tanstack/react-query": ">=5.62.0",
126
127
  "react": ">=19.0.0"
127
128
  },
129
+ "peerDependenciesMeta": {
130
+ "react": { "optional": false },
131
+ "@tanstack/react-query": { "optional": false },
132
+ "@classytic/repo-core": { "optional": false }
133
+ },
128
134
  "devDependencies": {
129
135
  "@classytic/dev-tools": "^0.2.0",
136
+ "@classytic/repo-core": "^0.5.0",
130
137
  "@tanstack/react-query": "^5.97.0",
131
138
  "@testing-library/jest-dom": "^6.9.1",
132
139
  "@testing-library/react": "^16.3.2",