@classytic/arc-next 0.4.1 → 0.6.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/README.md +272 -660
- package/dist/api.d.ts +115 -194
- package/dist/api.js +69 -116
- package/dist/cache.d.ts +125 -0
- package/dist/cache.js +211 -0
- package/dist/client.d.ts +295 -5
- package/dist/client.js +392 -12
- package/dist/hooks.d.ts +162 -22
- package/dist/hooks.js +249 -89
- package/dist/mutation.d.ts +18 -1
- package/dist/mutation.js +20 -1
- package/dist/prefetch.d.ts +36 -2
- package/dist/prefetch.js +47 -3
- package/dist/presets/bulk.d.ts +43 -0
- package/dist/presets/bulk.js +50 -0
- package/dist/presets/search.d.ts +56 -0
- package/dist/presets/search.js +60 -0
- package/dist/presets/slug.d.ts +28 -0
- package/dist/presets/slug.js +27 -0
- package/dist/presets/soft-delete.d.ts +34 -0
- package/dist/presets/soft-delete.js +45 -0
- package/dist/presets/tree.d.ts +32 -0
- package/dist/presets/tree.js +47 -0
- package/dist/query.d.ts +86 -69
- package/dist/query.js +59 -161
- package/dist/sse.d.ts +115 -36
- package/dist/sse.js +179 -103
- package/dist/upload.d.ts +181 -0
- package/dist/upload.js +346 -0
- package/dist/ws.d.ts +167 -0
- package/dist/ws.js +274 -0
- package/package.json +39 -1
package/dist/upload.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { ArcApiError, getAuthMode, getBaseUrl, getClientAuthContext } from "./client.js";
|
|
2
|
+
import { getToastHandler } from "./mutation.js";
|
|
3
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { useCallback, useRef, useState } from "react";
|
|
5
|
+
|
|
6
|
+
//#region src/upload.ts
|
|
7
|
+
/**
|
|
8
|
+
* XHR-based upload with real progress events.
|
|
9
|
+
*
|
|
10
|
+
* `fetch()` has no upload-progress API across browsers — Chrome 105+ supports
|
|
11
|
+
* `ReadableStream` request bodies, but Safari and Firefox don't ship the stream
|
|
12
|
+
* fetch+request body combination consumers need. This module solves that with
|
|
13
|
+
* `XMLHttpRequest`, which gives universal `xhr.upload.onprogress` support.
|
|
14
|
+
*
|
|
15
|
+
* Two surfaces:
|
|
16
|
+
* 1. `uploadWithProgress()` — plain Promise-returning function. Works in any
|
|
17
|
+
* JS context (React, Node-with-XHR-shim, tests).
|
|
18
|
+
* 2. `useUploadWithProgress()` — TanStack-Query-flavored React hook. Tracks
|
|
19
|
+
* progress as React state alongside the standard mutation lifecycle
|
|
20
|
+
* (loading, error, data, reset, cancel) and integrates with the SDK's
|
|
21
|
+
* toast handler + query invalidation.
|
|
22
|
+
*
|
|
23
|
+
* Both reuse arc-next's auth pipeline (`getClientAuthContext`), the shared
|
|
24
|
+
* `ArcApiError` envelope (top-level `code` + `details.code` slots), and the
|
|
25
|
+
* same `Authorization` / `x-organization-id` / `x-arc-scope` /
|
|
26
|
+
* `Idempotency-Key` header conventions as the fetch path. SDK behavior (auth,
|
|
27
|
+
* errors, elevated requests, ArcApiError messages) stays consistent across
|
|
28
|
+
* both upload variants.
|
|
29
|
+
*
|
|
30
|
+
* ─── Divergence from the fetch path ──────────────────────────────────────
|
|
31
|
+
*
|
|
32
|
+
* Two `ClientConfig` surfaces deliberately do NOT propagate to uploads:
|
|
33
|
+
*
|
|
34
|
+
* 1. **`retry`** — uploads do not auto-retry. Re-uploading a multi-MB body
|
|
35
|
+
* after a transient 5xx is rarely what consumers want (re-encoding cost,
|
|
36
|
+
* duplicate-write risk on non-idempotent endpoints, bandwidth burn). If
|
|
37
|
+
* you need at-most-once semantics, set `idempotencyKey` and let the
|
|
38
|
+
* backend dedupe; if you need at-least-once, wrap the call site with
|
|
39
|
+
* your own retry policy where you control file streaming.
|
|
40
|
+
*
|
|
41
|
+
* 2. **`beforeRequest` / `afterResponse`** — interceptors are tied to
|
|
42
|
+
* `executeRequest` (the fetch path) and DO NOT fire on XHR uploads.
|
|
43
|
+
* Trace headers, correlation IDs, and latency loggers configured via
|
|
44
|
+
* `configureClient({ beforeRequest, afterResponse })` will be missing
|
|
45
|
+
* on upload requests. To attach trace/correlation headers per-upload,
|
|
46
|
+
* pass them through the `headers` option on `uploadWithProgress` (or
|
|
47
|
+
* the `headers` factory on `useUploadWithProgress`):
|
|
48
|
+
*
|
|
49
|
+
* const traceId = crypto.randomUUID();
|
|
50
|
+
* await uploadWithProgress({
|
|
51
|
+
* url, formData,
|
|
52
|
+
* headers: { 'x-correlation-id': traceId },
|
|
53
|
+
* });
|
|
54
|
+
* log.info({ traceId, uploadKind: 'media' });
|
|
55
|
+
*
|
|
56
|
+
* This is documented behavior, not a bug — bridging XHR's progress events
|
|
57
|
+
* into the fetch interceptor pipeline would either require duplicating
|
|
58
|
+
* every interceptor for both transports or giving up the upload-progress
|
|
59
|
+
* contract. Future versions may expose dedicated `beforeUpload` /
|
|
60
|
+
* `afterUpload` hooks if a strong use case emerges.
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* Upload a `FormData` payload via XHR with native progress events.
|
|
64
|
+
*
|
|
65
|
+
* Returns a Promise that resolves with the parsed response body
|
|
66
|
+
* (`responseType: 'json'` by default — pass `'text'` or `'blob'` to opt out).
|
|
67
|
+
* Rejects with {@link ArcApiError} on non-2xx, with abort errors when the
|
|
68
|
+
* provided signal fires, or `Error` on transport failure (network down, CORS).
|
|
69
|
+
*
|
|
70
|
+
* Reuses {@link getClientAuthContext} so the global `configureAuth()` token /
|
|
71
|
+
* orgId — and per-client overrides via `client?.auth` — flow through. Sets
|
|
72
|
+
* `withCredentials = true` automatically for `authMode: 'cookie'`.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* const result = await uploadWithProgress<{ url: string }>({
|
|
76
|
+
* url: '/api/v1/media/upload',
|
|
77
|
+
* formData,
|
|
78
|
+
* onProgress: ({ percent }) => setUiProgress(percent),
|
|
79
|
+
* });
|
|
80
|
+
*/
|
|
81
|
+
function uploadWithProgress(options) {
|
|
82
|
+
const { url, formData, method = "POST", onProgress, signal, client, headers: extraHeaders, elevated, idempotencyKey, responseType = "json" } = options;
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
if (signal?.aborted) {
|
|
85
|
+
reject(abortReason(signal));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const xhr = new XMLHttpRequest();
|
|
89
|
+
const fullUrl = resolveUrl(url, client);
|
|
90
|
+
xhr.open(method, fullUrl, true);
|
|
91
|
+
xhr.responseType = responseType === "blob" ? "blob" : "";
|
|
92
|
+
const authMode = client?.config?.authMode ?? getAuthMode();
|
|
93
|
+
if (authMode === "cookie") xhr.withCredentials = true;
|
|
94
|
+
const authCtx = getClientAuthContext(client);
|
|
95
|
+
const token = options.token !== void 0 ? options.token : authCtx.token;
|
|
96
|
+
const orgId = options.organizationId !== void 0 ? options.organizationId : authCtx.organizationId;
|
|
97
|
+
const builtHeaders = {};
|
|
98
|
+
if (token) {
|
|
99
|
+
if (authMode === "header") {
|
|
100
|
+
const headerName = client?.auth?.headerName ?? "x-api-key";
|
|
101
|
+
builtHeaders[headerName] = token;
|
|
102
|
+
} else if (authMode !== "cookie") builtHeaders["Authorization"] = `Bearer ${token}`;
|
|
103
|
+
}
|
|
104
|
+
if (orgId) builtHeaders["x-organization-id"] = orgId;
|
|
105
|
+
if (elevated ?? client?.config?.elevated) builtHeaders["x-arc-scope"] = "platform";
|
|
106
|
+
if (idempotencyKey) builtHeaders["Idempotency-Key"] = idempotencyKey;
|
|
107
|
+
if (client?.config?.apiVersion) builtHeaders["Accept-Version"] = client.config.apiVersion;
|
|
108
|
+
if (client?.config?.internalApiKey) builtHeaders["x-internal-api-key"] = client.config.internalApiKey;
|
|
109
|
+
Object.assign(builtHeaders, client?.config?.defaultHeaders ?? {});
|
|
110
|
+
Object.assign(builtHeaders, extraHeaders ?? {});
|
|
111
|
+
for (const [name, value] of Object.entries(builtHeaders)) {
|
|
112
|
+
if (name.toLowerCase() === "content-type") continue;
|
|
113
|
+
xhr.setRequestHeader(name, value);
|
|
114
|
+
}
|
|
115
|
+
if (onProgress) xhr.upload.onprogress = (event) => {
|
|
116
|
+
const lengthComputable = event.lengthComputable;
|
|
117
|
+
const total = lengthComputable ? event.total : 0;
|
|
118
|
+
const loaded = event.loaded;
|
|
119
|
+
onProgress({
|
|
120
|
+
percent: lengthComputable && total > 0 ? Math.min(100, Math.round(loaded / total * 100)) : 0,
|
|
121
|
+
loaded,
|
|
122
|
+
total,
|
|
123
|
+
lengthComputable
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
let abortListener = null;
|
|
127
|
+
if (signal) {
|
|
128
|
+
abortListener = () => {
|
|
129
|
+
try {
|
|
130
|
+
xhr.abort();
|
|
131
|
+
} catch {}
|
|
132
|
+
reject(abortReason(signal));
|
|
133
|
+
};
|
|
134
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
135
|
+
}
|
|
136
|
+
xhr.onload = () => {
|
|
137
|
+
if (abortListener && signal) signal.removeEventListener("abort", abortListener);
|
|
138
|
+
const status = xhr.status;
|
|
139
|
+
const statusText = xhr.statusText || statusTextFromCode(status);
|
|
140
|
+
if (status === 0) {
|
|
141
|
+
reject(/* @__PURE__ */ new Error(`Network error during upload to ${fullUrl}`));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const body = parseResponseBody(xhr, responseType);
|
|
145
|
+
if (status >= 200 && status < 300) {
|
|
146
|
+
resolve(body);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
reject(new ArcApiError(extractErrorMessage(body, statusText), {
|
|
150
|
+
status,
|
|
151
|
+
statusText,
|
|
152
|
+
json: body,
|
|
153
|
+
endpoint: url,
|
|
154
|
+
method
|
|
155
|
+
}));
|
|
156
|
+
};
|
|
157
|
+
xhr.onerror = () => {
|
|
158
|
+
if (abortListener && signal) signal.removeEventListener("abort", abortListener);
|
|
159
|
+
reject(/* @__PURE__ */ new Error(`Network error during upload to ${fullUrl}`));
|
|
160
|
+
};
|
|
161
|
+
xhr.ontimeout = () => {
|
|
162
|
+
if (abortListener && signal) signal.removeEventListener("abort", abortListener);
|
|
163
|
+
reject(/* @__PURE__ */ new Error(`Upload timed out: ${fullUrl}`));
|
|
164
|
+
};
|
|
165
|
+
xhr.send(formData);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* React hook that wraps {@link uploadWithProgress} with TanStack-Query-style
|
|
170
|
+
* mutation ergonomics. Progress lives in React state — every progress tick
|
|
171
|
+
* re-renders the consumer so binding `progress.percent` to a `<ProgressBar>`
|
|
172
|
+
* "just works" with no extra plumbing.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* const { upload, progress, isUploading, cancel, error } = useUploadWithProgress<
|
|
176
|
+
* { url: string }[],
|
|
177
|
+
* { files: File[]; folder?: string }
|
|
178
|
+
* >({
|
|
179
|
+
* url: '/api/v1/media/upload-multiple',
|
|
180
|
+
* buildFormData: ({ files, folder }) => {
|
|
181
|
+
* const fd = new FormData();
|
|
182
|
+
* if (folder) fd.append('folder', folder);
|
|
183
|
+
* files.forEach((f) => fd.append('files[]', f));
|
|
184
|
+
* return fd;
|
|
185
|
+
* },
|
|
186
|
+
* invalidateQueries: [mediaKeys.lists()],
|
|
187
|
+
* messages: { success: 'Uploaded', error: 'Upload failed' },
|
|
188
|
+
* });
|
|
189
|
+
*
|
|
190
|
+
* <ProgressBar value={progress?.percent ?? 0} />
|
|
191
|
+
* <button onClick={() => upload({ files })}>Upload</button>
|
|
192
|
+
* {isUploading && <button onClick={cancel}>Cancel</button>}
|
|
193
|
+
*/
|
|
194
|
+
function useUploadWithProgress(options) {
|
|
195
|
+
const queryClient = useQueryClient();
|
|
196
|
+
const [progress, setProgress] = useState(null);
|
|
197
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
198
|
+
const [data, setData] = useState(null);
|
|
199
|
+
const [error, setError] = useState(null);
|
|
200
|
+
const [isSuccess, setIsSuccess] = useState(false);
|
|
201
|
+
const optsRef = useRef(options);
|
|
202
|
+
optsRef.current = options;
|
|
203
|
+
const abortRef = useRef(null);
|
|
204
|
+
const reset = useCallback(() => {
|
|
205
|
+
setProgress(null);
|
|
206
|
+
setIsUploading(false);
|
|
207
|
+
setData(null);
|
|
208
|
+
setError(null);
|
|
209
|
+
setIsSuccess(false);
|
|
210
|
+
}, []);
|
|
211
|
+
const cancel = useCallback(() => {
|
|
212
|
+
abortRef.current?.abort();
|
|
213
|
+
}, []);
|
|
214
|
+
return {
|
|
215
|
+
upload: useCallback(async (vars) => {
|
|
216
|
+
const opts = optsRef.current;
|
|
217
|
+
const url = typeof opts.url === "function" ? opts.url(vars) : opts.url;
|
|
218
|
+
const formData = opts.buildFormData(vars);
|
|
219
|
+
const headers = typeof opts.headers === "function" ? opts.headers(vars) : opts.headers;
|
|
220
|
+
const elevated = typeof opts.elevated === "function" ? opts.elevated(vars) : opts.elevated;
|
|
221
|
+
const idempotencyKey = typeof opts.idempotencyKey === "function" ? opts.idempotencyKey(vars) : opts.idempotencyKey;
|
|
222
|
+
abortRef.current?.abort();
|
|
223
|
+
const controller = new AbortController();
|
|
224
|
+
abortRef.current = controller;
|
|
225
|
+
setError(null);
|
|
226
|
+
setIsSuccess(false);
|
|
227
|
+
setIsUploading(true);
|
|
228
|
+
setProgress({
|
|
229
|
+
percent: 0,
|
|
230
|
+
loaded: 0,
|
|
231
|
+
total: 0,
|
|
232
|
+
lengthComputable: false
|
|
233
|
+
});
|
|
234
|
+
try {
|
|
235
|
+
const result = await uploadWithProgress({
|
|
236
|
+
url,
|
|
237
|
+
formData,
|
|
238
|
+
method: opts.method,
|
|
239
|
+
client: opts.client,
|
|
240
|
+
headers,
|
|
241
|
+
elevated,
|
|
242
|
+
idempotencyKey,
|
|
243
|
+
responseType: opts.responseType,
|
|
244
|
+
signal: controller.signal,
|
|
245
|
+
onProgress: (p) => {
|
|
246
|
+
setProgress(p);
|
|
247
|
+
opts.onProgress?.(p, vars);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
if (abortRef.current === controller) {
|
|
251
|
+
setData(result);
|
|
252
|
+
setIsSuccess(true);
|
|
253
|
+
setIsUploading(false);
|
|
254
|
+
}
|
|
255
|
+
if (opts.invalidateQueries?.length) for (const key of opts.invalidateQueries) queryClient.invalidateQueries({ queryKey: key });
|
|
256
|
+
const successMsg = opts.messages?.success;
|
|
257
|
+
if (successMsg) {
|
|
258
|
+
const text = typeof successMsg === "function" ? successMsg(result, vars) : successMsg;
|
|
259
|
+
(opts.toastHandler ?? getToastHandler()).success(text);
|
|
260
|
+
}
|
|
261
|
+
opts.onSuccess?.(result, vars);
|
|
262
|
+
opts.onSettled?.(result, null, vars);
|
|
263
|
+
return result;
|
|
264
|
+
} catch (err) {
|
|
265
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
266
|
+
if (abortRef.current === controller) {
|
|
267
|
+
setError(e);
|
|
268
|
+
setIsSuccess(false);
|
|
269
|
+
setIsUploading(false);
|
|
270
|
+
}
|
|
271
|
+
const errorMsg = opts.messages?.error;
|
|
272
|
+
if (errorMsg) {
|
|
273
|
+
const text = typeof errorMsg === "function" ? errorMsg(e, vars) : errorMsg;
|
|
274
|
+
(opts.toastHandler ?? getToastHandler()).error(text);
|
|
275
|
+
}
|
|
276
|
+
opts.onError?.(e, vars);
|
|
277
|
+
opts.onSettled?.(void 0, e, vars);
|
|
278
|
+
throw e;
|
|
279
|
+
} finally {
|
|
280
|
+
if (abortRef.current === controller) abortRef.current = null;
|
|
281
|
+
}
|
|
282
|
+
}, [queryClient]),
|
|
283
|
+
progress,
|
|
284
|
+
isUploading,
|
|
285
|
+
isPending: isUploading,
|
|
286
|
+
data,
|
|
287
|
+
error,
|
|
288
|
+
isSuccess,
|
|
289
|
+
isError: error !== null,
|
|
290
|
+
cancel,
|
|
291
|
+
reset
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Resolve a possibly-relative URL against the configured base. Mirrors
|
|
296
|
+
* `executeRequest` so the SDK presents a consistent URL contract regardless
|
|
297
|
+
* of transport.
|
|
298
|
+
*/
|
|
299
|
+
function resolveUrl(url, client) {
|
|
300
|
+
if (/^https?:\/\//i.test(url)) return url;
|
|
301
|
+
const base = client?.config?.baseUrl ?? getBaseUrl();
|
|
302
|
+
if (!base) return url;
|
|
303
|
+
if (base.endsWith("/") && url.startsWith("/")) return base + url.slice(1);
|
|
304
|
+
if (!base.endsWith("/") && !url.startsWith("/")) return `${base}/${url}`;
|
|
305
|
+
return base + url;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Parse XHR response body. Returns the raw value the caller asked for —
|
|
309
|
+
* `'json'` parses (and falls back to text on parse failure); `'text'` returns
|
|
310
|
+
* the string as-is; `'blob'` returns the binary directly.
|
|
311
|
+
*/
|
|
312
|
+
function parseResponseBody(xhr, kind) {
|
|
313
|
+
if (kind === "blob") return xhr.response;
|
|
314
|
+
const raw = xhr.responseText;
|
|
315
|
+
if (kind === "text") return raw;
|
|
316
|
+
if (!raw) return null;
|
|
317
|
+
try {
|
|
318
|
+
return JSON.parse(raw);
|
|
319
|
+
} catch {
|
|
320
|
+
return { rawBody: raw };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function extractErrorMessage(body, fallback) {
|
|
324
|
+
if (body && typeof body === "object") {
|
|
325
|
+
const obj = body;
|
|
326
|
+
if (typeof obj.error === "string" && obj.error) return obj.error;
|
|
327
|
+
if (typeof obj.message === "string" && obj.message) return obj.message;
|
|
328
|
+
}
|
|
329
|
+
return fallback;
|
|
330
|
+
}
|
|
331
|
+
function abortReason(signal) {
|
|
332
|
+
const reason = signal.reason;
|
|
333
|
+
if (reason !== void 0) return reason;
|
|
334
|
+
if (typeof DOMException !== "undefined") return new DOMException("The operation was aborted.", "AbortError");
|
|
335
|
+
const err = /* @__PURE__ */ new Error("The operation was aborted.");
|
|
336
|
+
err.name = "AbortError";
|
|
337
|
+
return err;
|
|
338
|
+
}
|
|
339
|
+
function statusTextFromCode(code) {
|
|
340
|
+
if (code >= 500) return "Internal Server Error";
|
|
341
|
+
if (code >= 400) return "Request Error";
|
|
342
|
+
return "OK";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
//#endregion
|
|
346
|
+
export { uploadWithProgress, useUploadWithProgress };
|
package/dist/ws.d.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { QueryKey } from "@tanstack/react-query";
|
|
2
|
+
|
|
3
|
+
//#region src/ws.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Inbound message shape from Arc's `websocketPlugin` broadcasts.
|
|
6
|
+
*
|
|
7
|
+
* Arc auto-broadcasts CRUD events for resources listed in
|
|
8
|
+
* `websocketPlugin({ resources: [...] })` as:
|
|
9
|
+
* `{ type: '<resource>.<op>', data: { resource, operation, data: doc, timestamp }, meta }`
|
|
10
|
+
*
|
|
11
|
+
* Custom messages from your handlers can have any shape — typed as `unknown` data.
|
|
12
|
+
*/
|
|
13
|
+
interface ArcWsMessage<TData = unknown> {
|
|
14
|
+
type: string;
|
|
15
|
+
data?: TData;
|
|
16
|
+
meta?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Subscribe-frame shape Arc's `websocketPlugin` accepts.
|
|
20
|
+
* Wire format (per backend tests): `{ type: 'subscribe', resource: '<resource>' }`.
|
|
21
|
+
* Backend also accepts `channel` as alias.
|
|
22
|
+
*/
|
|
23
|
+
interface ArcSubscribeFrame {
|
|
24
|
+
type: 'subscribe' | 'unsubscribe';
|
|
25
|
+
resource?: string;
|
|
26
|
+
channel?: string;
|
|
27
|
+
/** Free-form additional fields the backend may use (token, filters, etc.). */
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
interface ConnectWsOptions<TData = unknown> {
|
|
31
|
+
/** Full ws/wss URL. When set, overrides `path` and `baseUrl`. */
|
|
32
|
+
url?: string;
|
|
33
|
+
/** WS path relative to baseUrl (default: `/ws`). Auto-converts http(s)→ws(s). */
|
|
34
|
+
path?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Resources to subscribe to on connect — sends `{type:'subscribe', resource: <name>}`
|
|
37
|
+
* for each. Auto-resubscribes after reconnect.
|
|
38
|
+
*/
|
|
39
|
+
subscribe?: string[];
|
|
40
|
+
/**
|
|
41
|
+
* Message types to listen for. When omitted, all messages reach `onMessage`.
|
|
42
|
+
* Pattern matches the type prefix when ending with a dot
|
|
43
|
+
* (e.g. `'todo.'` matches `'todo.created'`), otherwise exact match.
|
|
44
|
+
*/
|
|
45
|
+
patterns?: string[];
|
|
46
|
+
/** Callback per inbound message (after pattern filtering). */
|
|
47
|
+
onMessage?: (message: ArcWsMessage<TData>) => void;
|
|
48
|
+
/** Connection-state callback. */
|
|
49
|
+
onConnectionChange?: (connected: boolean) => void;
|
|
50
|
+
/** Reconnect delay in ms. Default: 3000 (capped at 30000 with backoff). */
|
|
51
|
+
reconnectDelay?: number;
|
|
52
|
+
/** Max reconnect attempts. Default: Infinity. */
|
|
53
|
+
maxReconnectAttempts?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Application-level heartbeat interval in ms. When > 0, sends `{type:'ping'}`
|
|
56
|
+
* every N ms so the backend can detect dead clients. Default: 0 (disabled).
|
|
57
|
+
*/
|
|
58
|
+
heartbeatInterval?: number;
|
|
59
|
+
/** WebSocket subprotocols (some auth schemes pass tokens here). */
|
|
60
|
+
protocols?: string | string[];
|
|
61
|
+
}
|
|
62
|
+
/** Per-type listener handle returned by {@link ConnectWsHandle.on}. Call to remove. */
|
|
63
|
+
type WsOffHandle = () => void;
|
|
64
|
+
/**
|
|
65
|
+
* Imperative handle returned by {@link connectWs}. Stable across reconnects.
|
|
66
|
+
*
|
|
67
|
+
* Use `on(eventType, handler)` to register an additional per-type listener
|
|
68
|
+
* alongside the global `onMessage` callback. Returns an unsubscribe function.
|
|
69
|
+
*/
|
|
70
|
+
interface ConnectWsHandle<TData = unknown> {
|
|
71
|
+
/** True when the WS is OPEN. */
|
|
72
|
+
isConnected: () => boolean;
|
|
73
|
+
/** Send any JSON-serializable payload. Returns false if not connected. */
|
|
74
|
+
send: (payload: unknown) => boolean;
|
|
75
|
+
/** Subscribe to a resource. Persists across reconnects. */
|
|
76
|
+
subscribe: (resource: string) => void;
|
|
77
|
+
/** Unsubscribe from a resource. */
|
|
78
|
+
unsubscribe: (resource: string) => void;
|
|
79
|
+
/**
|
|
80
|
+
* Listen for messages whose type matches `eventType` exactly. Use `'*'` for
|
|
81
|
+
* a catch-all listener. Returns an unsubscribe function.
|
|
82
|
+
*/
|
|
83
|
+
on: (eventType: string, handler: (message: ArcWsMessage<TData>) => void) => WsOffHandle;
|
|
84
|
+
/** Close manually (no auto-reconnect after this). */
|
|
85
|
+
close: () => void;
|
|
86
|
+
/** Reopen after a manual close. */
|
|
87
|
+
reconnect: () => void;
|
|
88
|
+
}
|
|
89
|
+
interface WebSocketOptions<TData = unknown> extends ConnectWsOptions<TData> {
|
|
90
|
+
/** Query keys to invalidate when any matching message is received. */
|
|
91
|
+
invalidateQueries?: QueryKey[];
|
|
92
|
+
/** Whether the socket is active. Default: true. */
|
|
93
|
+
enabled?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Whether to track `lastMessage` in React state. Default: true.
|
|
96
|
+
*
|
|
97
|
+
* Set to `false` for high-volume streams (chat, telemetry) where the consumer
|
|
98
|
+
* uses `onMessage` for fire-and-forget handling and never reads
|
|
99
|
+
* `result.lastMessage`. Each inbound frame skips a `setState` call,
|
|
100
|
+
* eliminating per-message re-renders.
|
|
101
|
+
*/
|
|
102
|
+
trackLastMessage?: boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Whether to track `messageCount` in React state. Default: true.
|
|
105
|
+
*
|
|
106
|
+
* Same trade-off as `trackLastMessage`: skip the counter `setState` for
|
|
107
|
+
* high-volume streams that don't read it.
|
|
108
|
+
*/
|
|
109
|
+
trackMessageCount?: boolean;
|
|
110
|
+
}
|
|
111
|
+
interface WebSocketResult<TData = unknown> {
|
|
112
|
+
isConnected: boolean;
|
|
113
|
+
lastMessage: ArcWsMessage<TData> | null;
|
|
114
|
+
messageCount: number;
|
|
115
|
+
send: (payload: unknown) => boolean;
|
|
116
|
+
subscribe: (resource: string) => void;
|
|
117
|
+
unsubscribe: (resource: string) => void;
|
|
118
|
+
close: () => void;
|
|
119
|
+
reconnect: () => void;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Build an authenticated WebSocket URL using the global client + auth singletons.
|
|
123
|
+
*
|
|
124
|
+
* Thin alias for {@link import('./client.js').buildStreamUrl} with the WS
|
|
125
|
+
* protocol — rewrites `http(s)://` → `ws(s)://` and attaches the same auth
|
|
126
|
+
* params SSE uses, so the two transports stay in lock-step.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* const socket = new WebSocket(buildWsUrl('/ws'));
|
|
130
|
+
*/
|
|
131
|
+
declare function buildWsUrl(path?: string, params?: Record<string, string | number | boolean | null | undefined>): string;
|
|
132
|
+
/**
|
|
133
|
+
* Connect to an Arc WebSocket channel from any JS context (React, Node, Bun, tests).
|
|
134
|
+
* Pure function — no React hook required. Returns a handle with `send()`,
|
|
135
|
+
* `subscribe()`, `on()`, `close()`, `reconnect()`, `isConnected()`.
|
|
136
|
+
*
|
|
137
|
+
* Reconnect uses exponential backoff (×1.5 per attempt, capped at 30s).
|
|
138
|
+
* Subscriptions persist across reconnect.
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* const ws = connectWs<CrudEvent<Todo>>({
|
|
142
|
+
* subscribe: ['todo'],
|
|
143
|
+
* onMessage: (m) => console.log(m.type, m.data),
|
|
144
|
+
* });
|
|
145
|
+
*
|
|
146
|
+
* const off = ws.on('todo.created', (m) => console.log('new todo:', m.data));
|
|
147
|
+
* // ...later
|
|
148
|
+
* off();
|
|
149
|
+
* ws.close();
|
|
150
|
+
*/
|
|
151
|
+
declare function connectWs<TData = unknown>(options?: ConnectWsOptions<TData>): ConnectWsHandle<TData>;
|
|
152
|
+
/**
|
|
153
|
+
* Connect to an Arc WebSocket channel with auto-reconnect, subscription
|
|
154
|
+
* persistence, and TanStack Query invalidation on inbound messages.
|
|
155
|
+
*
|
|
156
|
+
* Internally delegates to {@link connectWs} — for non-React contexts (Node,
|
|
157
|
+
* tests, plain JS) call that directly.
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* const { isConnected, lastMessage } = useWebSocket<CrudEvent<Todo>>({
|
|
161
|
+
* subscribe: ['todo'],
|
|
162
|
+
* invalidateQueries: [todoKeys.lists()],
|
|
163
|
+
* });
|
|
164
|
+
*/
|
|
165
|
+
declare function useWebSocket<TData = unknown>(options: WebSocketOptions<TData>): WebSocketResult<TData>;
|
|
166
|
+
//#endregion
|
|
167
|
+
export { ArcSubscribeFrame, ArcWsMessage, ConnectWsHandle, ConnectWsOptions, WebSocketOptions, WebSocketResult, WsOffHandle, buildWsUrl, connectWs, useWebSocket };
|