@classytic/arc-next 0.4.1 → 0.5.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/ws.js ADDED
@@ -0,0 +1,274 @@
1
+ "use client";
2
+
3
+ import { buildStreamUrl } from "./client.js";
4
+ import { useQueryClient } from "@tanstack/react-query";
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+
7
+ //#region src/ws.ts
8
+ /**
9
+ * Build an authenticated WebSocket URL using the global client + auth singletons.
10
+ *
11
+ * Thin alias for {@link import('./client.js').buildStreamUrl} with the WS
12
+ * protocol — rewrites `http(s)://` → `ws(s)://` and attaches the same auth
13
+ * params SSE uses, so the two transports stay in lock-step.
14
+ *
15
+ * @example
16
+ * const socket = new WebSocket(buildWsUrl('/ws'));
17
+ */
18
+ function buildWsUrl(path = "/ws", params = {}) {
19
+ return buildStreamUrl(path, params, "ws");
20
+ }
21
+ /**
22
+ * Connect to an Arc WebSocket channel from any JS context (React, Node, Bun, tests).
23
+ * Pure function — no React hook required. Returns a handle with `send()`,
24
+ * `subscribe()`, `on()`, `close()`, `reconnect()`, `isConnected()`.
25
+ *
26
+ * Reconnect uses exponential backoff (×1.5 per attempt, capped at 30s).
27
+ * Subscriptions persist across reconnect.
28
+ *
29
+ * @example
30
+ * const ws = connectWs<CrudEvent<Todo>>({
31
+ * subscribe: ['todo'],
32
+ * onMessage: (m) => console.log(m.type, m.data),
33
+ * });
34
+ *
35
+ * const off = ws.on('todo.created', (m) => console.log('new todo:', m.data));
36
+ * // ...later
37
+ * off();
38
+ * ws.close();
39
+ */
40
+ function connectWs(options = {}) {
41
+ const { url, path = "/ws", reconnectDelay = 3e3, maxReconnectAttempts = Infinity, heartbeatInterval = 0, protocols, patterns = [] } = options;
42
+ let ws = null;
43
+ let reconnectAttempts = 0;
44
+ let reconnectTimer = null;
45
+ let heartbeatTimer = null;
46
+ let manualClose = false;
47
+ let connected = false;
48
+ const subscriptions = new Set(options.subscribe ?? []);
49
+ const listeners = /* @__PURE__ */ new Map();
50
+ const matchesPattern = (type) => {
51
+ if (patterns.length === 0) return true;
52
+ return patterns.some((p) => p.endsWith(".") ? type.startsWith(p) : type === p);
53
+ };
54
+ const sendRaw = (payload) => {
55
+ if (!ws || ws.readyState !== WebSocket.OPEN) return false;
56
+ try {
57
+ ws.send(typeof payload === "string" ? payload : JSON.stringify(payload));
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ };
63
+ const dispatch = (message) => {
64
+ if (!matchesPattern(message.type)) return;
65
+ options.onMessage?.(message);
66
+ const exact = listeners.get(message.type);
67
+ if (exact) for (const fn of exact) fn(message);
68
+ const wildcard = listeners.get("*");
69
+ if (wildcard) for (const fn of wildcard) fn(message);
70
+ };
71
+ const connect = () => {
72
+ if (ws) try {
73
+ ws.close();
74
+ } catch {}
75
+ if (heartbeatTimer) {
76
+ clearInterval(heartbeatTimer);
77
+ heartbeatTimer = null;
78
+ }
79
+ manualClose = false;
80
+ const wsUrl = url ?? buildWsUrl(path);
81
+ ws = protocols !== void 0 ? new WebSocket(wsUrl, protocols) : new WebSocket(wsUrl);
82
+ ws.onopen = () => {
83
+ reconnectAttempts = 0;
84
+ connected = true;
85
+ options.onConnectionChange?.(true);
86
+ for (const resource of subscriptions) sendRaw({
87
+ type: "subscribe",
88
+ resource
89
+ });
90
+ if (heartbeatInterval > 0) heartbeatTimer = setInterval(() => {
91
+ sendRaw({ type: "ping" });
92
+ }, heartbeatInterval);
93
+ };
94
+ ws.onmessage = (event) => {
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(typeof event.data === "string" ? event.data : String(event.data));
98
+ } catch {
99
+ parsed = {
100
+ type: "message",
101
+ data: event.data
102
+ };
103
+ }
104
+ dispatch(parsed);
105
+ };
106
+ ws.onerror = () => {};
107
+ ws.onclose = () => {
108
+ connected = false;
109
+ options.onConnectionChange?.(false);
110
+ if (heartbeatTimer) {
111
+ clearInterval(heartbeatTimer);
112
+ heartbeatTimer = null;
113
+ }
114
+ if (manualClose) return;
115
+ if (reconnectAttempts < maxReconnectAttempts) {
116
+ reconnectAttempts += 1;
117
+ const delay = Math.min(reconnectDelay * Math.pow(1.5, reconnectAttempts - 1), 3e4);
118
+ reconnectTimer = setTimeout(connect, delay);
119
+ }
120
+ };
121
+ };
122
+ connect();
123
+ return {
124
+ isConnected: () => connected,
125
+ send: sendRaw,
126
+ subscribe: (resource) => {
127
+ subscriptions.add(resource);
128
+ if (ws?.readyState === WebSocket.OPEN) sendRaw({
129
+ type: "subscribe",
130
+ resource
131
+ });
132
+ },
133
+ unsubscribe: (resource) => {
134
+ subscriptions.delete(resource);
135
+ if (ws?.readyState === WebSocket.OPEN) sendRaw({
136
+ type: "unsubscribe",
137
+ resource
138
+ });
139
+ },
140
+ on: (eventType, handler) => {
141
+ let bucket = listeners.get(eventType);
142
+ if (!bucket) {
143
+ bucket = /* @__PURE__ */ new Set();
144
+ listeners.set(eventType, bucket);
145
+ }
146
+ bucket.add(handler);
147
+ return () => {
148
+ bucket?.delete(handler);
149
+ if (bucket && bucket.size === 0) listeners.delete(eventType);
150
+ };
151
+ },
152
+ close: () => {
153
+ manualClose = true;
154
+ if (reconnectTimer) {
155
+ clearTimeout(reconnectTimer);
156
+ reconnectTimer = null;
157
+ }
158
+ if (heartbeatTimer) {
159
+ clearInterval(heartbeatTimer);
160
+ heartbeatTimer = null;
161
+ }
162
+ if (ws) {
163
+ try {
164
+ ws.close();
165
+ } catch {}
166
+ ws = null;
167
+ }
168
+ connected = false;
169
+ options.onConnectionChange?.(false);
170
+ },
171
+ reconnect: () => {
172
+ reconnectAttempts = 0;
173
+ manualClose = false;
174
+ connect();
175
+ }
176
+ };
177
+ }
178
+ /**
179
+ * Connect to an Arc WebSocket channel with auto-reconnect, subscription
180
+ * persistence, and TanStack Query invalidation on inbound messages.
181
+ *
182
+ * Internally delegates to {@link connectWs} — for non-React contexts (Node,
183
+ * tests, plain JS) call that directly.
184
+ *
185
+ * @example
186
+ * const { isConnected, lastMessage } = useWebSocket<CrudEvent<Todo>>({
187
+ * subscribe: ['todo'],
188
+ * invalidateQueries: [todoKeys.lists()],
189
+ * });
190
+ */
191
+ function useWebSocket(options) {
192
+ const { url, path = "/ws", enabled = true, trackLastMessage = true, trackMessageCount = true } = options;
193
+ const queryClient = useQueryClient();
194
+ const [isConnected, setIsConnected] = useState(false);
195
+ const [lastMessage, setLastMessage] = useState(null);
196
+ const [messageCount, setMessageCount] = useState(0);
197
+ const handleRef = useRef(null);
198
+ const onMessageRef = useRef(options.onMessage);
199
+ onMessageRef.current = options.onMessage;
200
+ const onConnectionChangeRef = useRef(options.onConnectionChange);
201
+ onConnectionChangeRef.current = options.onConnectionChange;
202
+ const invalidateKeysRef = useRef(options.invalidateQueries ?? []);
203
+ invalidateKeysRef.current = options.invalidateQueries ?? [];
204
+ const subscribeKey = JSON.stringify(options.subscribe ?? []);
205
+ const patternsKey = JSON.stringify(options.patterns ?? null);
206
+ const subscribeArr = useMemo(() => options.subscribe ?? [], [subscribeKey]);
207
+ const patternsArr = useMemo(() => options.patterns, [patternsKey]);
208
+ useEffect(() => {
209
+ if (!enabled) {
210
+ handleRef.current?.close();
211
+ handleRef.current = null;
212
+ return;
213
+ }
214
+ const handle = connectWs({
215
+ url,
216
+ path,
217
+ subscribe: subscribeArr,
218
+ patterns: patternsArr,
219
+ reconnectDelay: options.reconnectDelay,
220
+ maxReconnectAttempts: options.maxReconnectAttempts,
221
+ heartbeatInterval: options.heartbeatInterval,
222
+ protocols: options.protocols,
223
+ onConnectionChange: (c) => {
224
+ setIsConnected(c);
225
+ onConnectionChangeRef.current?.(c);
226
+ },
227
+ onMessage: (msg) => {
228
+ if (trackLastMessage) setLastMessage(msg);
229
+ if (trackMessageCount) setMessageCount((n) => n + 1);
230
+ onMessageRef.current?.(msg);
231
+ for (const key of invalidateKeysRef.current) queryClient.invalidateQueries({ queryKey: key });
232
+ }
233
+ });
234
+ handleRef.current = handle;
235
+ return () => {
236
+ handle.close();
237
+ handleRef.current = null;
238
+ };
239
+ }, [
240
+ enabled,
241
+ url,
242
+ path,
243
+ subscribeArr,
244
+ patternsArr,
245
+ options.reconnectDelay,
246
+ options.maxReconnectAttempts,
247
+ options.heartbeatInterval,
248
+ options.protocols,
249
+ trackLastMessage,
250
+ trackMessageCount,
251
+ queryClient
252
+ ]);
253
+ const subscribe = useCallback((resource) => {
254
+ handleRef.current?.subscribe(resource);
255
+ }, []);
256
+ const unsubscribe = useCallback((resource) => {
257
+ handleRef.current?.unsubscribe(resource);
258
+ }, []);
259
+ return {
260
+ isConnected,
261
+ lastMessage,
262
+ messageCount,
263
+ send: useCallback((payload) => {
264
+ return handleRef.current?.send(payload) ?? false;
265
+ }, []),
266
+ subscribe,
267
+ unsubscribe,
268
+ close: useCallback(() => handleRef.current?.close(), []),
269
+ reconnect: useCallback(() => handleRef.current?.reconnect(), [])
270
+ };
271
+ }
272
+
273
+ //#endregion
274
+ export { buildWsUrl, connectWs, useWebSocket };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc-next",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "React + TanStack Query SDK for Arc resources",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -42,6 +42,10 @@
42
42
  "types": "./dist/api.d.ts",
43
43
  "default": "./dist/api.js"
44
44
  },
45
+ "./cache": {
46
+ "types": "./dist/cache.d.ts",
47
+ "default": "./dist/cache.js"
48
+ },
45
49
  "./query": {
46
50
  "types": "./dist/query.d.ts",
47
51
  "default": "./dist/query.js"
@@ -66,6 +70,34 @@
66
70
  "types": "./dist/sse.d.ts",
67
71
  "default": "./dist/sse.js"
68
72
  },
73
+ "./ws": {
74
+ "types": "./dist/ws.d.ts",
75
+ "default": "./dist/ws.js"
76
+ },
77
+ "./upload": {
78
+ "types": "./dist/upload.d.ts",
79
+ "default": "./dist/upload.js"
80
+ },
81
+ "./presets/soft-delete": {
82
+ "types": "./dist/presets/soft-delete.d.ts",
83
+ "default": "./dist/presets/soft-delete.js"
84
+ },
85
+ "./presets/bulk": {
86
+ "types": "./dist/presets/bulk.d.ts",
87
+ "default": "./dist/presets/bulk.js"
88
+ },
89
+ "./presets/slug": {
90
+ "types": "./dist/presets/slug.d.ts",
91
+ "default": "./dist/presets/slug.js"
92
+ },
93
+ "./presets/tree": {
94
+ "types": "./dist/presets/tree.d.ts",
95
+ "default": "./dist/presets/tree.js"
96
+ },
97
+ "./presets/search": {
98
+ "types": "./dist/presets/search.d.ts",
99
+ "default": "./dist/presets/search.js"
100
+ },
69
101
  "./package.json": "./package.json"
70
102
  },
71
103
  "files": [
@@ -84,6 +116,9 @@
84
116
  "test": "vitest run",
85
117
  "test:watch": "vitest",
86
118
  "typecheck": "tsc --noEmit",
119
+ "push": "classytic-push",
120
+ "release:tag": "node -e \"require('child_process').execSync('npm run push -- v'+require('./package.json').version,{stdio:'inherit'})\"",
121
+ "release": "npm run push -- main && npm run release:tag && npm publish",
87
122
  "prepublishOnly": "npm run typecheck && npm test && npm run build"
88
123
  },
89
124
  "peerDependencies": {
@@ -91,6 +126,7 @@
91
126
  "react": ">=19.0.0"
92
127
  },
93
128
  "devDependencies": {
129
+ "@classytic/dev-tools": "^0.2.0",
94
130
  "@tanstack/react-query": "^5.97.0",
95
131
  "@testing-library/jest-dom": "^6.9.1",
96
132
  "@testing-library/react": "^16.3.2",