@classytic/arc-next 0.3.1 → 0.4.1

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/mutation.js CHANGED
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { isArcApiError } from "./client.js";
3
+ import { isArcApiError, isAutoIdempotency } from "./client.js";
4
4
  import { useMutation, useQueryClient } from "@tanstack/react-query";
5
- import { useTransition } from "react";
5
+ import { useCallback, useRef, useTransition } from "react";
6
6
 
7
7
  //#region src/mutation.ts
8
8
  let toastHandler = {
@@ -43,8 +43,18 @@ function useMutationWithTransition(config) {
43
43
  const { mutationFn, invalidateQueries = [], onSuccess, onError, onSettled, messages, useTransition: withTransition = true, showToast: toast = true, toastHandler: instanceToast } = config;
44
44
  const queryClient = useQueryClient();
45
45
  const [isTransitioning, startTransition] = useTransition();
46
+ const idempotencyKeyRef = useRef(null);
46
47
  const mutation = useMutation({
47
- mutationFn,
48
+ mutationFn: (variables) => {
49
+ if (idempotencyKeyRef.current && typeof variables === "object" && variables !== null) {
50
+ const vars = variables;
51
+ if (vars.options && typeof vars.options === "object") vars.options.idempotencyKey ??= idempotencyKeyRef.current;
52
+ }
53
+ return mutationFn(variables);
54
+ },
55
+ onMutate: () => {
56
+ idempotencyKeyRef.current = isAutoIdempotency() ? globalThis.crypto.randomUUID() : null;
57
+ },
48
58
  onSuccess: (data, variables) => {
49
59
  const invalidate = () => {
50
60
  invalidateQueries.forEach((key) => queryClient.invalidateQueries({ queryKey: key }));
@@ -151,17 +161,6 @@ function useOptimisticMutation(config) {
151
161
  }
152
162
  });
153
163
  }
154
- const QUERY_CONFIGS = {
155
- realtime: {
156
- staleTime: 2e4,
157
- refetchInterval: 3e4
158
- },
159
- frequent: { staleTime: 6e4 },
160
- stable: { staleTime: 3e5 },
161
- static: { staleTime: 6e5 }
162
- };
163
- /** @deprecated Use `useOptimisticMutation` */
164
- const createOptimisticMutation = useOptimisticMutation;
165
164
 
166
165
  //#endregion
167
- export { QUERY_CONFIGS, configureToast, createOptimisticMutation, useMutationWithOptimistic, useMutationWithTransition, useOptimisticMutation };
166
+ export { configureToast, useMutationWithOptimistic, useMutationWithTransition, useOptimisticMutation };
@@ -1,7 +1,15 @@
1
1
  import { QueryClient, dehydrate } from "@tanstack/react-query";
2
2
 
3
3
  //#region src/prefetch.d.ts
4
- interface PrefetchOptions {
4
+ interface PrefetchAuthContext {
5
+ /** Auth token for protected endpoints. Required for bearer/header auth on server. */
6
+ token?: string | null;
7
+ /** Organization ID for multi-tenant prefetch. Sent as x-organization-id header. */
8
+ organizationId?: string | null;
9
+ /** Additional headers (e.g., x-api-key for header auth). */
10
+ headers?: Record<string, string>;
11
+ }
12
+ interface PrefetchOptions extends PrefetchAuthContext {
5
13
  staleTime?: number;
6
14
  }
7
15
  interface PrefetchDetailOptions extends PrefetchOptions {
@@ -28,6 +36,21 @@ interface CrudPrefetcher {
28
36
  * await productsPrefetcher.prefetchDetail(queryClient, productId);
29
37
  */
30
38
  prefetchDetail: (queryClient: QueryClient, id: string, options?: PrefetchDetailOptions) => Promise<void>;
39
+ /**
40
+ * Prefetch a detail-by-slug query. Uses the same query keys as useDetailBySlug.
41
+ * Only available when the API has a `getBySlug` method (slugLookup preset).
42
+ */
43
+ prefetchBySlug: (queryClient: QueryClient, slug: string, options?: PrefetchDetailOptions) => Promise<void>;
44
+ /**
45
+ * Prefetch soft-deleted items. Uses the same query keys as useDeleted.
46
+ * Only available when the API has a `getDeleted` method (softDelete preset).
47
+ */
48
+ prefetchDeleted: (queryClient: QueryClient, params?: Record<string, unknown>, options?: PrefetchOptions) => Promise<void>;
49
+ /**
50
+ * Prefetch a tree query. Uses the same query keys as useTree.
51
+ * Only available when the API has a `getTree` method (tree preset).
52
+ */
53
+ prefetchTree: (queryClient: QueryClient, params?: Record<string, unknown>, options?: PrefetchOptions) => Promise<void>;
31
54
  }
32
55
  /**
33
56
  * Create server-safe prefetch helpers for CRUD queries.
@@ -59,12 +82,43 @@ declare function createCrudPrefetcher(api: {
59
82
  params?: Record<string, unknown>;
60
83
  token?: string | null;
61
84
  organizationId?: string | null;
85
+ options?: {
86
+ headerOptions?: Record<string, string>;
87
+ };
62
88
  }) => Promise<unknown>;
63
89
  getById: (opts: {
64
90
  id: string;
65
91
  token?: string | null;
66
92
  organizationId?: string | null;
93
+ options?: {
94
+ headerOptions?: Record<string, string>;
95
+ };
96
+ }) => Promise<unknown>;
97
+ getBySlug?: (opts: {
98
+ slug: string;
99
+ token?: string | null;
100
+ organizationId?: string | null;
101
+ params?: Record<string, unknown>;
102
+ options?: {
103
+ headerOptions?: Record<string, string>;
104
+ };
105
+ }) => Promise<unknown>;
106
+ getDeleted?: (opts: {
107
+ params?: Record<string, unknown>;
108
+ token?: string | null;
109
+ organizationId?: string | null;
110
+ options?: {
111
+ headerOptions?: Record<string, string>;
112
+ };
113
+ }) => Promise<unknown>;
114
+ getTree?: (opts: {
115
+ params?: Record<string, unknown>;
116
+ token?: string | null;
117
+ organizationId?: string | null;
118
+ options?: {
119
+ headerOptions?: Record<string, string>;
120
+ };
67
121
  }) => Promise<unknown>;
68
122
  }, entityKey: string): CrudPrefetcher;
69
123
  //#endregion
70
- export { CrudPrefetcher, PrefetchDetailOptions, PrefetchOptions, createCrudPrefetcher, dehydrate };
124
+ export { CrudPrefetcher, PrefetchAuthContext, PrefetchDetailOptions, PrefetchOptions, createCrudPrefetcher, dehydrate };
package/dist/prefetch.js CHANGED
@@ -1,23 +1,7 @@
1
+ import { createQueryKeys } from "./query.js";
1
2
  import { dehydrate } from "@tanstack/react-query";
2
3
 
3
4
  //#region src/prefetch.ts
4
- function scopedListKey(entityKey, scope, params) {
5
- return [
6
- entityKey,
7
- "list",
8
- {
9
- _scope: scope,
10
- ...params || {}
11
- }
12
- ];
13
- }
14
- function detailKey(entityKey, id) {
15
- return [
16
- entityKey,
17
- "detail",
18
- id
19
- ];
20
- }
21
5
  /**
22
6
  * Create server-safe prefetch helpers for CRUD queries.
23
7
  * Use in Next.js server components to pre-populate the query cache before rendering.
@@ -44,34 +28,96 @@ function detailKey(entityKey, id) {
44
28
  * }
45
29
  */
46
30
  function createCrudPrefetcher(api, entityKey) {
31
+ const KEYS = createQueryKeys(entityKey);
47
32
  return {
48
33
  async prefetchList(queryClient, params = {}, options = {}) {
49
- const { organizationId, ...restParams } = params;
50
- const queryKey = scopedListKey(entityKey, organizationId ? "tenant" : "super-admin", {
51
- organizationId,
34
+ const { organizationId: paramOrgId, ...restParams } = params;
35
+ const orgId = paramOrgId ?? options.organizationId ?? null;
36
+ const scope = orgId ? "tenant" : "super-admin";
37
+ const queryKey = KEYS.scopedList(scope, {
38
+ ...orgId ? { organizationId: orgId } : {},
52
39
  ...restParams
53
40
  });
54
41
  await queryClient.prefetchQuery({
55
42
  queryKey,
56
43
  queryFn: () => api.getAll({
57
44
  params: restParams,
58
- organizationId: organizationId ?? null
45
+ token: options.token ?? null,
46
+ organizationId: orgId,
47
+ ...options.headers ? { options: { headerOptions: options.headers } } : {}
59
48
  }),
60
49
  staleTime: options.staleTime
61
50
  });
62
51
  },
63
52
  async prefetchDetail(queryClient, id, options = {}) {
64
- const { params, staleTime } = options;
65
- const baseKey = detailKey(entityKey, id);
53
+ const { params, staleTime, token, organizationId } = options;
54
+ const baseKey = KEYS.detail(id);
66
55
  const queryKey = params ? [...baseKey, params] : baseKey;
67
56
  await queryClient.prefetchQuery({
68
57
  queryKey,
69
58
  queryFn: () => api.getById({
70
59
  id,
71
- ...params ? { params } : {}
60
+ token: token ?? null,
61
+ organizationId: organizationId ?? null,
62
+ ...params ? { params } : {},
63
+ ...options.headers ? { options: { headerOptions: options.headers } } : {}
64
+ }),
65
+ staleTime
66
+ });
67
+ },
68
+ async prefetchBySlug(queryClient, slug, options = {}) {
69
+ if (!api.getBySlug) throw new Error(`[arc-next] prefetchBySlug requires an api with getBySlug (slugLookup preset)`);
70
+ const { params, staleTime, token, organizationId } = options;
71
+ const queryKey = params ? KEYS.custom("slug", slug, params) : KEYS.custom("slug", slug);
72
+ await queryClient.prefetchQuery({
73
+ queryKey,
74
+ queryFn: () => api.getBySlug({
75
+ slug,
76
+ token: token ?? null,
77
+ organizationId: organizationId ?? null,
78
+ ...params ? { params } : {},
79
+ ...options.headers ? { options: { headerOptions: options.headers } } : {}
72
80
  }),
73
81
  staleTime
74
82
  });
83
+ },
84
+ async prefetchDeleted(queryClient, params = {}, options = {}) {
85
+ if (!api.getDeleted) throw new Error(`[arc-next] prefetchDeleted requires an api with getDeleted (softDelete preset)`);
86
+ const { organizationId: paramOrgId, ...restParams } = params;
87
+ const orgId = paramOrgId ?? options.organizationId ?? null;
88
+ const queryKey = KEYS.custom("deleted", {
89
+ ...orgId ? { organizationId: orgId } : {},
90
+ ...restParams
91
+ });
92
+ await queryClient.prefetchQuery({
93
+ queryKey,
94
+ queryFn: () => api.getDeleted({
95
+ params: restParams,
96
+ token: options.token ?? null,
97
+ organizationId: orgId,
98
+ ...options.headers ? { options: { headerOptions: options.headers } } : {}
99
+ }),
100
+ staleTime: options.staleTime
101
+ });
102
+ },
103
+ async prefetchTree(queryClient, params = {}, options = {}) {
104
+ if (!api.getTree) throw new Error(`[arc-next] prefetchTree requires an api with getTree (tree preset)`);
105
+ const { organizationId: paramOrgId, ...restParams } = params;
106
+ const orgId = paramOrgId ?? options.organizationId ?? null;
107
+ const queryKey = KEYS.custom("tree", {
108
+ ...orgId ? { organizationId: orgId } : {},
109
+ ...restParams
110
+ });
111
+ await queryClient.prefetchQuery({
112
+ queryKey,
113
+ queryFn: () => api.getTree({
114
+ params: restParams,
115
+ token: options.token ?? null,
116
+ organizationId: orgId,
117
+ ...options.headers ? { options: { headerOptions: options.headers } } : {}
118
+ }),
119
+ staleTime: options.staleTime
120
+ });
75
121
  }
76
122
  };
77
123
  }
package/dist/query.d.ts CHANGED
@@ -86,16 +86,27 @@ interface QueryKeys {
86
86
  list: (params?: unknown) => QueryKey;
87
87
  details: () => QueryKey;
88
88
  detail: (id: string) => QueryKey;
89
+ /** Tenant-scoped detail key. Use when IDs are only unique within an org. */
90
+ scopedDetail: (id: string, organizationId: string | null) => QueryKey;
89
91
  custom: (key: string, ...args: unknown[]) => QueryKey;
90
92
  scopedList: (scope: string, params?: unknown) => QueryKey;
91
93
  }
92
94
  interface CacheUtils<T> {
93
95
  invalidateAll: (client: QueryClient) => Promise<void>;
94
96
  invalidateLists: (client: QueryClient) => Promise<void>;
97
+ /** Invalidate detail by ID (prefix-matches all scoped/parameterized variants). */
95
98
  invalidateDetail: (client: QueryClient, id: string) => Promise<void>;
96
99
  setDetail: (client: QueryClient, id: string, data: T) => void;
97
100
  getDetail: (client: QueryClient, id: string) => T | undefined;
98
101
  removeDetail: (client: QueryClient, id: string) => void;
102
+ /** Invalidate tenant-scoped detail (prefix-matches parameterized variants within org). */
103
+ invalidateScopedDetail: (client: QueryClient, id: string, organizationId: string | null) => Promise<void>;
104
+ /** Set tenant-scoped detail cache. */
105
+ setScopedDetail: (client: QueryClient, id: string, organizationId: string | null, data: T) => void;
106
+ /** Get tenant-scoped detail from cache. */
107
+ getScopedDetail: (client: QueryClient, id: string, organizationId: string | null) => T | undefined;
108
+ /** Remove tenant-scoped detail from cache. */
109
+ removeScopedDetail: (client: QueryClient, id: string, organizationId: string | null) => void;
99
110
  }
100
111
  declare const DEFAULT_QUERY_CONFIG: {
101
112
  readonly staleTime: number;
@@ -103,6 +114,22 @@ declare const DEFAULT_QUERY_CONFIG: {
103
114
  readonly refetchOnWindowFocus: false;
104
115
  readonly retry: 0;
105
116
  };
117
+ /** Pre-built query config presets for common data freshness patterns. */
118
+ declare const QUERY_CONFIGS: {
119
+ /** Live data: 20s stale, 30s polling */readonly realtime: {
120
+ readonly staleTime: 20000;
121
+ readonly refetchInterval: 30000;
122
+ }; /** Frequently updated: 60s stale */
123
+ readonly frequent: {
124
+ readonly staleTime: 60000;
125
+ }; /** Stable data: 5min stale (same as default) */
126
+ readonly stable: {
127
+ readonly staleTime: 300000;
128
+ }; /** Rarely changes: 10min stale */
129
+ readonly static: {
130
+ readonly staleTime: 600000;
131
+ };
132
+ };
106
133
  declare function getItemId(item: unknown): string | null;
107
134
  declare function extractItem<T>(data: unknown): T | null;
108
135
  declare function updateListCache<T>(listData: unknown, updater: (items: T[]) => T[]): unknown;
@@ -117,6 +144,8 @@ interface CreateListQueryConfig {
117
144
  options?: Record<string, unknown>;
118
145
  prefillDetailCache?: boolean;
119
146
  detailKeyBuilder?: (id: string) => QueryKey;
147
+ /** Custom ID extractor for cache prefill. Falls back to getItemId (_id → id). */
148
+ itemIdResolver?: (item: unknown) => string | null;
120
149
  select?: (data: unknown) => unknown;
121
150
  }
122
151
  declare function useListQuery<T>({
@@ -126,6 +155,7 @@ declare function useListQuery<T>({
126
155
  options,
127
156
  prefillDetailCache,
128
157
  detailKeyBuilder,
158
+ itemIdResolver,
129
159
  select
130
160
  }: CreateListQueryConfig): ListQueryResult<T>;
131
161
  interface CreateDetailQueryConfig {
@@ -155,6 +185,12 @@ interface InfiniteListQueryOptions {
155
185
  refetchIntervalInBackground?: boolean;
156
186
  _scope?: string;
157
187
  request?: RequestPassthrough;
188
+ /**
189
+ * Max pages to keep in memory. Old pages are evicted and re-fetched on scroll-back.
190
+ * Requires `getPreviousPageParam` for backward re-fetching.
191
+ * When unset, all fetched pages are retained (default TanStack Query behavior).
192
+ */
193
+ maxPages?: number;
158
194
  }
159
195
  interface InfiniteListQueryResult<T> {
160
196
  items: T[];
@@ -183,6 +219,8 @@ interface CreateInfiniteListQueryConfig {
183
219
  initialPageParam?: unknown;
184
220
  getNextPageParam: (lastPage: unknown) => unknown;
185
221
  getPreviousPageParam?: (firstPage: unknown) => unknown;
222
+ /** Max pages to keep in memory. Old pages are evicted when exceeded. */
223
+ maxPages?: number;
186
224
  }
187
225
  declare function useInfiniteListQuery<T>({
188
226
  queryKey,
@@ -191,13 +229,8 @@ declare function useInfiniteListQuery<T>({
191
229
  options,
192
230
  initialPageParam,
193
231
  getNextPageParam,
194
- getPreviousPageParam
232
+ getPreviousPageParam,
233
+ maxPages
195
234
  }: CreateInfiniteListQueryConfig): InfiniteListQueryResult<T>;
196
- /** @deprecated Use `useListQuery` */
197
- declare const createListQuery: typeof useListQuery;
198
- /** @deprecated Use `useDetailQuery` */
199
- declare const createDetailQuery: typeof useDetailQuery;
200
- /** @deprecated Use `useInfiniteListQuery` */
201
- declare const createInfiniteListQuery: typeof useInfiniteListQuery;
202
235
  //#endregion
203
- export { CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, PaginationData, QueryKeys, RequestPassthrough, createCacheUtils, createDetailQuery, createInfiniteListQuery, createListQuery, createQueryKeys, extractItem, getItemId, updateListCache, useDetailQuery, useInfiniteListQuery, useListQuery };
236
+ export { CacheUtils, CreateDetailQueryConfig, CreateInfiniteListQueryConfig, CreateListQueryConfig, DEFAULT_QUERY_CONFIG, DetailQueryOptions, DetailQueryResult, InfiniteListQueryOptions, InfiniteListQueryResult, ListQueryOptions, ListQueryResult, PaginationData, QUERY_CONFIGS, QueryKeys, RequestPassthrough, createCacheUtils, createQueryKeys, extractItem, getItemId, updateListCache, useDetailQuery, useInfiniteListQuery, useListQuery };
package/dist/query.js CHANGED
@@ -10,6 +10,16 @@ const DEFAULT_QUERY_CONFIG = {
10
10
  refetchOnWindowFocus: false,
11
11
  retry: 0
12
12
  };
13
+ /** Pre-built query config presets for common data freshness patterns. */
14
+ const QUERY_CONFIGS = {
15
+ realtime: {
16
+ staleTime: 2e4,
17
+ refetchInterval: 3e4
18
+ },
19
+ frequent: { staleTime: 6e4 },
20
+ stable: { staleTime: 3e5 },
21
+ static: { staleTime: 6e5 }
22
+ };
13
23
  function getItemId(item) {
14
24
  if (!item || typeof item !== "object") return null;
15
25
  const obj = item;
@@ -110,6 +120,16 @@ function createQueryKeys(entityKey) {
110
120
  "detail",
111
121
  id
112
122
  ],
123
+ scopedDetail: (id, organizationId) => organizationId ? [
124
+ entityKey,
125
+ "detail",
126
+ id,
127
+ { _org: organizationId }
128
+ ] : [
129
+ entityKey,
130
+ "detail",
131
+ id
132
+ ],
113
133
  custom: (key, ...args) => [
114
134
  entityKey,
115
135
  key,
@@ -134,10 +154,16 @@ function createCacheUtils(KEYS) {
134
154
  getDetail: (client, id) => {
135
155
  return client.getQueryData(KEYS.detail(id))?.data;
136
156
  },
137
- removeDetail: (client, id) => client.removeQueries({ queryKey: KEYS.detail(id) })
157
+ removeDetail: (client, id) => client.removeQueries({ queryKey: KEYS.detail(id) }),
158
+ invalidateScopedDetail: (client, id, organizationId) => client.invalidateQueries({ queryKey: KEYS.scopedDetail(id, organizationId) }),
159
+ setScopedDetail: (client, id, organizationId, data) => client.setQueryData(KEYS.scopedDetail(id, organizationId), { data }),
160
+ getScopedDetail: (client, id, organizationId) => {
161
+ return client.getQueryData(KEYS.scopedDetail(id, organizationId))?.data;
162
+ },
163
+ removeScopedDetail: (client, id, organizationId) => client.removeQueries({ queryKey: KEYS.scopedDetail(id, organizationId) })
138
164
  };
139
165
  }
140
- function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefillDetailCache = true, detailKeyBuilder, select }) {
166
+ function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefillDetailCache = true, detailKeyBuilder, itemIdResolver, select }) {
141
167
  const queryClient = useQueryClient();
142
168
  const query = useQuery({
143
169
  queryKey,
@@ -152,8 +178,9 @@ function useListQuery({ queryKey, queryFn, enabled = true, options = {}, prefill
152
178
  const pagination = useMemo(() => normalizePagination(query.data), [query.data]);
153
179
  useEffect(() => {
154
180
  if (!prefillDetailCache || !detailKeyBuilder || items.length === 0) return;
181
+ const resolveId = itemIdResolver ?? getItemId;
155
182
  items.forEach((item) => {
156
- const id = getItemId(item);
183
+ const id = resolveId(item);
157
184
  if (id) queryClient.setQueryData(detailKeyBuilder(id), { data: item });
158
185
  });
159
186
  }, [
@@ -196,7 +223,7 @@ function useDetailQuery({ queryKey, queryFn, enabled = true, options = {}, selec
196
223
  data: query.data
197
224
  };
198
225
  }
199
- function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {}, initialPageParam = 1, getNextPageParam, getPreviousPageParam }) {
226
+ function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {}, initialPageParam = 1, getNextPageParam, getPreviousPageParam, maxPages }) {
200
227
  const query = useInfiniteQuery({
201
228
  queryKey,
202
229
  queryFn: ({ pageParam, signal }) => queryFn({
@@ -207,6 +234,7 @@ function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {},
207
234
  initialPageParam,
208
235
  getNextPageParam,
209
236
  getPreviousPageParam,
237
+ ...maxPages != null ? { maxPages } : {},
210
238
  ...DEFAULT_QUERY_CONFIG,
211
239
  ...options
212
240
  });
@@ -227,12 +255,6 @@ function useInfiniteListQuery({ queryKey, queryFn, enabled = true, options = {},
227
255
  data: query.data
228
256
  };
229
257
  }
230
- /** @deprecated Use `useListQuery` */
231
- const createListQuery = useListQuery;
232
- /** @deprecated Use `useDetailQuery` */
233
- const createDetailQuery = useDetailQuery;
234
- /** @deprecated Use `useInfiniteListQuery` */
235
- const createInfiniteListQuery = useInfiniteListQuery;
236
258
 
237
259
  //#endregion
238
- export { DEFAULT_QUERY_CONFIG, createCacheUtils, createDetailQuery, createInfiniteListQuery, createListQuery, createQueryKeys, extractItem, getItemId, updateListCache, useDetailQuery, useInfiniteListQuery, useListQuery };
260
+ export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, getItemId, updateListCache, useDetailQuery, useInfiniteListQuery, useListQuery };
package/dist/sse.d.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { QueryKey } from "@tanstack/react-query";
2
+
3
+ //#region src/sse.d.ts
4
+ interface ArcServerEvent {
5
+ type: string;
6
+ resource: string;
7
+ data: unknown;
8
+ timestamp: string;
9
+ id?: string;
10
+ }
11
+ interface EventStreamOptions {
12
+ /** Full SSE endpoint URL. When set, overrides `path` and `baseUrl`. Use for non-Arc backends or custom URLs. */
13
+ url?: string;
14
+ /**
15
+ * Resource name for automatic pattern filtering.
16
+ * When set and `patterns` is empty, auto-generates `['resource.*']` filter.
17
+ * Does NOT affect the endpoint URL — use `path` for that.
18
+ */
19
+ resource?: string;
20
+ /**
21
+ * SSE endpoint path (appended to baseUrl). Default: '/events/stream'.
22
+ * Matches Arc's ssePlugin default. Override if your backend uses a custom path.
23
+ */
24
+ path?: string;
25
+ /** Event patterns to listen for (e.g., ['agents.created', 'agents.updated']). When empty, all events are received. */
26
+ patterns?: string[];
27
+ /** Query keys to invalidate when any event is received. */
28
+ invalidateQueries?: QueryKey[];
29
+ /** Callback for each event. */
30
+ onEvent?: (event: ArcServerEvent) => void;
31
+ /** Callback for connection state changes. */
32
+ onConnectionChange?: (connected: boolean) => void;
33
+ /** Whether the stream is enabled. Default: true */
34
+ enabled?: boolean;
35
+ /** Reconnect delay in ms. Default: 3000 */
36
+ reconnectDelay?: number;
37
+ /** Maximum reconnect attempts before giving up. Default: Infinity */
38
+ maxReconnectAttempts?: number;
39
+ /** Whether to include credentials (cookies). Derived from authMode when not set. */
40
+ withCredentials?: boolean;
41
+ }
42
+ interface EventStreamResult {
43
+ /** Whether the EventSource is currently connected. */
44
+ isConnected: boolean;
45
+ /** The most recently received event. */
46
+ lastEvent: ArcServerEvent | null;
47
+ /** Number of events received since connection. */
48
+ eventCount: number;
49
+ /** Close the connection manually. */
50
+ close: () => void;
51
+ /** Reconnect after a manual close. */
52
+ reconnect: () => void;
53
+ }
54
+ /**
55
+ * Subscribe to Arc server-sent events for real-time cache invalidation.
56
+ *
57
+ * Uses the browser's native `EventSource` API for automatic reconnection
58
+ * and efficient server-push. Events trigger query invalidation so TanStack Query
59
+ * refetches affected data automatically.
60
+ *
61
+ * @example
62
+ * const { isConnected } = useEventStream({
63
+ * resource: 'agents',
64
+ * invalidateQueries: [agentKeys.lists()],
65
+ * });
66
+ */
67
+ declare function useEventStream(options: EventStreamOptions): EventStreamResult;
68
+ //#endregion
69
+ export { ArcServerEvent, EventStreamOptions, EventStreamResult, useEventStream };
package/dist/sse.js ADDED
@@ -0,0 +1,145 @@
1
+ "use client";
2
+
3
+ import { getAuthContext, getAuthMode, getBaseUrl } from "./client.js";
4
+ import { useQueryClient } from "@tanstack/react-query";
5
+ import { useCallback, useEffect, useRef, useState } from "react";
6
+
7
+ //#region src/sse.ts
8
+ /**
9
+ * Subscribe to Arc server-sent events for real-time cache invalidation.
10
+ *
11
+ * Uses the browser's native `EventSource` API for automatic reconnection
12
+ * and efficient server-push. Events trigger query invalidation so TanStack Query
13
+ * refetches affected data automatically.
14
+ *
15
+ * @example
16
+ * const { isConnected } = useEventStream({
17
+ * resource: 'agents',
18
+ * invalidateQueries: [agentKeys.lists()],
19
+ * });
20
+ */
21
+ function useEventStream(options) {
22
+ const { url, resource, path: ssePath = "/events/stream", enabled = true, reconnectDelay = 3e3, maxReconnectAttempts = Infinity, withCredentials } = options;
23
+ const queryClient = useQueryClient();
24
+ const [isConnected, setIsConnected] = useState(false);
25
+ const [lastEvent, setLastEvent] = useState(null);
26
+ const [eventCount, setEventCount] = useState(0);
27
+ const esRef = useRef(null);
28
+ const reconnectAttemptsRef = useRef(0);
29
+ const reconnectTimerRef = useRef(null);
30
+ const manualCloseRef = useRef(false);
31
+ const onEventRef = useRef(options.onEvent);
32
+ onEventRef.current = options.onEvent;
33
+ const onConnectionChangeRef = useRef(options.onConnectionChange);
34
+ onConnectionChangeRef.current = options.onConnectionChange;
35
+ const patternsRef = useRef(options.patterns ?? []);
36
+ patternsRef.current = options.patterns ?? [];
37
+ const invalidateKeysRef = useRef(options.invalidateQueries ?? []);
38
+ invalidateKeysRef.current = options.invalidateQueries ?? [];
39
+ const buildUrl = useCallback(() => {
40
+ if (url) return url;
41
+ const auth = getAuthContext();
42
+ const params = new URLSearchParams();
43
+ const patterns = patternsRef.current;
44
+ const effectivePatterns = patterns.length > 0 ? patterns : resource ? [`${resource}.*`] : [];
45
+ if (effectivePatterns.length > 0) params.set("patterns", effectivePatterns.join(","));
46
+ if (auth.organizationId) params.set("organizationId", auth.organizationId);
47
+ if (auth.token) params.set("token", auth.token);
48
+ const qs = params.toString();
49
+ const base = `${getBaseUrl()}${ssePath}`;
50
+ return qs ? `${base}?${qs}` : base;
51
+ }, [
52
+ url,
53
+ resource,
54
+ ssePath
55
+ ]);
56
+ const connect = useCallback(() => {
57
+ if (esRef.current) esRef.current.close();
58
+ manualCloseRef.current = false;
59
+ const eventUrl = buildUrl();
60
+ const authMode = getAuthMode();
61
+ const es = new EventSource(eventUrl, { withCredentials: withCredentials ?? authMode === "cookie" });
62
+ esRef.current = es;
63
+ es.onopen = () => {
64
+ reconnectAttemptsRef.current = 0;
65
+ setIsConnected(true);
66
+ onConnectionChangeRef.current?.(true);
67
+ };
68
+ es.onmessage = (event) => {
69
+ try {
70
+ const parsed = JSON.parse(event.data);
71
+ const patterns = patternsRef.current;
72
+ if (patterns.length > 0 && !patterns.includes(parsed.type)) return;
73
+ setLastEvent(parsed);
74
+ setEventCount((c) => c + 1);
75
+ onEventRef.current?.(parsed);
76
+ const keys = invalidateKeysRef.current;
77
+ for (const key of keys) queryClient.invalidateQueries({ queryKey: key });
78
+ } catch {}
79
+ };
80
+ es.onerror = () => {
81
+ es.close();
82
+ setIsConnected(false);
83
+ onConnectionChangeRef.current?.(false);
84
+ if (manualCloseRef.current) return;
85
+ if (reconnectAttemptsRef.current < maxReconnectAttempts) {
86
+ reconnectAttemptsRef.current += 1;
87
+ const delay = Math.min(reconnectDelay * Math.pow(1.5, reconnectAttemptsRef.current - 1), 3e4);
88
+ reconnectTimerRef.current = setTimeout(connect, delay);
89
+ }
90
+ };
91
+ }, [
92
+ buildUrl,
93
+ queryClient,
94
+ withCredentials,
95
+ reconnectDelay,
96
+ maxReconnectAttempts
97
+ ]);
98
+ const close = useCallback(() => {
99
+ manualCloseRef.current = true;
100
+ if (reconnectTimerRef.current) {
101
+ clearTimeout(reconnectTimerRef.current);
102
+ reconnectTimerRef.current = null;
103
+ }
104
+ if (esRef.current) {
105
+ esRef.current.close();
106
+ esRef.current = null;
107
+ }
108
+ setIsConnected(false);
109
+ onConnectionChangeRef.current?.(false);
110
+ }, []);
111
+ const reconnect = useCallback(() => {
112
+ reconnectAttemptsRef.current = 0;
113
+ manualCloseRef.current = false;
114
+ connect();
115
+ }, [connect]);
116
+ useEffect(() => {
117
+ if (!enabled) {
118
+ close();
119
+ return;
120
+ }
121
+ connect();
122
+ return () => {
123
+ manualCloseRef.current = true;
124
+ if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
125
+ if (esRef.current) {
126
+ esRef.current.close();
127
+ esRef.current = null;
128
+ }
129
+ };
130
+ }, [
131
+ enabled,
132
+ connect,
133
+ close
134
+ ]);
135
+ return {
136
+ isConnected,
137
+ lastEvent,
138
+ eventCount,
139
+ close,
140
+ reconnect
141
+ };
142
+ }
143
+
144
+ //#endregion
145
+ export { useEventStream };