@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/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 };