@c9up/aurora 0.1.6 → 0.1.8

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/src/http.ts ADDED
@@ -0,0 +1,369 @@
1
+ /**
2
+ * `HttpClient` — a small typed wrapper over `fetch` so call sites read
3
+ * `await http.get<User>("/auth/me")` instead of hand-rolling headers,
4
+ * `res.json()`, and status checks.
5
+ *
6
+ * - Auto JSON: a plain-object/array body is `JSON.stringify`-d with a
7
+ * `Content-Type: application/json` header; a JSON response is parsed.
8
+ * `FormData`/`Blob`/`URLSearchParams`/`string`/binary bodies pass through
9
+ * untouched.
10
+ * - Bearer auth: a `token` (string or getter, read fresh per request) is sent
11
+ * as `Authorization: Bearer …` unless the caller set the header themselves.
12
+ * - Errors: a non-2xx response rejects with an {@link HttpError} carrying the
13
+ * status, the `Response`, and the parsed body.
14
+ *
15
+ * Node-free and isomorphic — uses the global `fetch` (browsers, Node 18+,
16
+ * Workers, Bun, Deno). Part of the client barrel.
17
+ */
18
+
19
+ export interface HttpClientOptions {
20
+ /** Prepended to every request URL, unless the URL is already absolute. */
21
+ baseURL?: string;
22
+ /** Headers merged into every request. */
23
+ headers?: Record<string, string>;
24
+ /**
25
+ * Bearer token sent as `Authorization: Bearer <token>`. A getter is read
26
+ * fresh on each request (so a rotated/late-set token is always current);
27
+ * a `null`/`undefined` result omits the header.
28
+ */
29
+ token?: string | null | (() => string | null | undefined);
30
+ /** Default `credentials` mode (e.g. `"include"` to send cookies). */
31
+ credentials?: RequestCredentials;
32
+ /**
33
+ * Default timeout in ms — the request is aborted (rejecting with a
34
+ * `TimeoutError`) if it doesn't settle in time. Combined with a per-request
35
+ * `signal`, whichever fires first wins.
36
+ */
37
+ timeout?: number;
38
+ }
39
+
40
+ export interface HttpRequestOptions<T = unknown> {
41
+ /** Query params appended to the URL. `null`/`undefined` values are skipped. */
42
+ query?: Record<string, string | number | boolean | null | undefined>;
43
+ /** Extra headers for this request (override the client defaults). */
44
+ headers?: Record<string, string>;
45
+ /** Per-request bearer token override (`null` to force-omit). */
46
+ token?: string | null;
47
+ /** Abort signal — abort it to cancel the request (e.g. on unmount / new keystroke). */
48
+ signal?: AbortSignal;
49
+ /** Per-request timeout in ms (overrides the client default). Aborts with a `TimeoutError`. */
50
+ timeout?: number;
51
+ /** `credentials` mode for this request. */
52
+ credentials?: RequestCredentials;
53
+ /**
54
+ * Runtime validator/mapper for the parsed body. When provided, the return
55
+ * type is whatever it returns — no unchecked cast. When omitted, the parsed
56
+ * body is returned as `T` (an UNCHECKED assertion of the response shape).
57
+ */
58
+ parse?: (raw: unknown) => T;
59
+ }
60
+
61
+ /** Thrown on a non-2xx response. Carries the status, the `Response`, and the parsed body. */
62
+ export class HttpError extends Error {
63
+ readonly status: number;
64
+ readonly response: Response;
65
+ readonly data: unknown;
66
+
67
+ constructor(response: Response, data: unknown) {
68
+ super(`HTTP ${response.status} ${response.statusText} for ${response.url}`);
69
+ this.name = "HttpError";
70
+ this.status = response.status;
71
+ this.response = response;
72
+ this.data = data;
73
+ }
74
+ }
75
+
76
+ /** Type guard for {@link HttpError} — clean `catch` narrowing without a cast. */
77
+ export function isHttpError(value: unknown): value is HttpError {
78
+ return value instanceof HttpError;
79
+ }
80
+
81
+ /**
82
+ * Whether `value` is an aborted/timed-out request error — an `AbortError`
83
+ * (cancelled via a `signal`) or a `TimeoutError` (the `timeout` option fired).
84
+ * Use it to silently ignore cancellations (e.g. a superseded search request).
85
+ */
86
+ export function isAbortError(value: unknown): boolean {
87
+ const name =
88
+ value instanceof Error
89
+ ? value.name
90
+ : typeof DOMException !== "undefined" && value instanceof DOMException
91
+ ? value.name
92
+ : undefined;
93
+ return name === "AbortError" || name === "TimeoutError";
94
+ }
95
+
96
+ /**
97
+ * Outcome of a non-throwing request via {@link HttpClient.attempt}: a
98
+ * discriminated union a form submit can branch on (`if (r.ok)`) instead of
99
+ * wrapping every call in try/catch.
100
+ */
101
+ export type HttpResult<T> =
102
+ | { ok: true; data: T; error: null }
103
+ | { ok: false; data: null; error: HttpError };
104
+
105
+ /** Whether `body` should be JSON-encoded (vs. passed to `fetch` untouched). */
106
+ function shouldJsonEncode(body: unknown): boolean {
107
+ if (body === null || typeof body !== "object") {
108
+ return typeof body !== "string";
109
+ }
110
+ if (
111
+ body instanceof FormData ||
112
+ body instanceof Blob ||
113
+ body instanceof URLSearchParams ||
114
+ body instanceof ArrayBuffer ||
115
+ ArrayBuffer.isView(body)
116
+ ) {
117
+ return false;
118
+ }
119
+ if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) {
120
+ return false;
121
+ }
122
+ return true;
123
+ }
124
+
125
+ /** Case-insensitive header presence check. */
126
+ function hasHeader(headers: Record<string, string>, name: string): boolean {
127
+ const lower = name.toLowerCase();
128
+ for (const key of Object.keys(headers)) {
129
+ if (key.toLowerCase() === lower) return true;
130
+ }
131
+ return false;
132
+ }
133
+
134
+ /** Merge abort signals into one (whichever fires first wins). `undefined` if none. */
135
+ function combineSignals(
136
+ signals: ReadonlyArray<AbortSignal | undefined>,
137
+ ): AbortSignal | undefined {
138
+ const present = signals.filter((s): s is AbortSignal => s !== undefined);
139
+ if (present.length <= 1) return present[0];
140
+ if (typeof AbortSignal.any === "function") return AbortSignal.any(present);
141
+ // Fallback for runtimes without AbortSignal.any.
142
+ const controller = new AbortController();
143
+ for (const signal of present) {
144
+ if (signal.aborted) {
145
+ controller.abort(signal.reason);
146
+ break;
147
+ }
148
+ signal.addEventListener("abort", () => controller.abort(signal.reason), {
149
+ once: true,
150
+ });
151
+ }
152
+ return controller.signal;
153
+ }
154
+
155
+ /** Parse a response by content-type; `null` for empty / no-content bodies. */
156
+ async function parseBody(response: Response): Promise<unknown> {
157
+ if (response.status === 204 || response.status === 205) return null;
158
+ const type = response.headers.get("content-type") ?? "";
159
+ const text = await response.text();
160
+ if (text === "") return null;
161
+ if (type.includes("application/json")) return JSON.parse(text);
162
+ return text;
163
+ }
164
+
165
+ export class HttpClient {
166
+ readonly #baseURL: string;
167
+ readonly #headers: Record<string, string>;
168
+ readonly #token?: string | null | (() => string | null | undefined);
169
+ readonly #credentials?: RequestCredentials;
170
+ readonly #timeout?: number;
171
+
172
+ constructor(options: HttpClientOptions = {}) {
173
+ this.#baseURL = options.baseURL ?? "";
174
+ this.#headers = { ...options.headers };
175
+ this.#token = options.token;
176
+ this.#credentials = options.credentials;
177
+ this.#timeout = options.timeout;
178
+ }
179
+
180
+ /** Set a default header for every subsequent request (case-insensitive replace). Chainable. */
181
+ setHeader(name: string, value: string): this {
182
+ this.#deleteHeader(name);
183
+ this.#headers[name] = value;
184
+ return this;
185
+ }
186
+
187
+ /** Merge several default headers at once. Chainable. */
188
+ setHeaders(headers: Record<string, string>): this {
189
+ for (const [name, value] of Object.entries(headers)) {
190
+ this.setHeader(name, value);
191
+ }
192
+ return this;
193
+ }
194
+
195
+ /** Remove a default header (case-insensitive). Chainable. */
196
+ removeHeader(name: string): this {
197
+ this.#deleteHeader(name);
198
+ return this;
199
+ }
200
+
201
+ /** A copy of the current default headers. */
202
+ getHeaders(): Record<string, string> {
203
+ return { ...this.#headers };
204
+ }
205
+
206
+ #deleteHeader(name: string): void {
207
+ const lower = name.toLowerCase();
208
+ for (const key of Object.keys(this.#headers)) {
209
+ if (key.toLowerCase() === lower) delete this.#headers[key];
210
+ }
211
+ }
212
+
213
+ get<T>(url: string, options?: HttpRequestOptions<T>): Promise<T> {
214
+ return this.#request("GET", url, undefined, options);
215
+ }
216
+
217
+ delete<T>(url: string, options?: HttpRequestOptions<T>): Promise<T> {
218
+ return this.#request("DELETE", url, undefined, options);
219
+ }
220
+
221
+ post<T>(
222
+ url: string,
223
+ body?: unknown,
224
+ options?: HttpRequestOptions<T>,
225
+ ): Promise<T> {
226
+ return this.#request("POST", url, body, options);
227
+ }
228
+
229
+ put<T>(
230
+ url: string,
231
+ body?: unknown,
232
+ options?: HttpRequestOptions<T>,
233
+ ): Promise<T> {
234
+ return this.#request("PUT", url, body, options);
235
+ }
236
+
237
+ patch<T>(
238
+ url: string,
239
+ body?: unknown,
240
+ options?: HttpRequestOptions<T>,
241
+ ): Promise<T> {
242
+ return this.#request("PATCH", url, body, options);
243
+ }
244
+
245
+ /** Send a request and return the raw `Response` (no parsing, no throw on non-2xx). */
246
+ raw(
247
+ method: string,
248
+ url: string,
249
+ body?: unknown,
250
+ options: HttpRequestOptions = {},
251
+ ): Promise<Response> {
252
+ return this.#send(method, url, body, options);
253
+ }
254
+
255
+ /**
256
+ * Run a request without throwing on a non-2xx response: returns a
257
+ * discriminated {@link HttpResult} so a form submit can branch
258
+ * (`if (r.ok) … else r.error.data`) instead of wrapping each call in
259
+ * try/catch. Genuine transport failures (offline, DNS) still reject —
260
+ * they're exceptional, not an HTTP error.
261
+ *
262
+ * ```js
263
+ * const r = await api.attempt(api.post('/auth/login', creds))
264
+ * if (r.ok) user(r.data)
265
+ * else fieldErrors(r.error.data) // HttpError.data = parsed 4xx body
266
+ * ```
267
+ */
268
+ async attempt<T>(request: Promise<T>): Promise<HttpResult<T>> {
269
+ try {
270
+ return { ok: true, data: await request, error: null };
271
+ } catch (error) {
272
+ if (error instanceof HttpError) {
273
+ return { ok: false, data: null, error };
274
+ }
275
+ throw error;
276
+ }
277
+ }
278
+
279
+ /** Derive a new client with merged defaults (e.g. a scope that adds a token). */
280
+ extend(options: HttpClientOptions): HttpClient {
281
+ return new HttpClient({
282
+ baseURL: options.baseURL ?? this.#baseURL,
283
+ headers: { ...this.#headers, ...options.headers },
284
+ token: options.token ?? this.#token,
285
+ credentials: options.credentials ?? this.#credentials,
286
+ timeout: options.timeout ?? this.#timeout,
287
+ });
288
+ }
289
+
290
+ #resolveToken(override?: string | null): string | null | undefined {
291
+ if (override !== undefined) return override;
292
+ return typeof this.#token === "function" ? this.#token() : this.#token;
293
+ }
294
+
295
+ #buildUrl(url: string, query?: HttpRequestOptions["query"]): string {
296
+ const base = /^[a-z][a-z\d+\-.]*:\/\//i.test(url)
297
+ ? url
298
+ : this.#baseURL + url;
299
+ if (!query) return base;
300
+ const params = new URLSearchParams();
301
+ for (const [key, value] of Object.entries(query)) {
302
+ if (value !== null && value !== undefined)
303
+ params.append(key, String(value));
304
+ }
305
+ const qs = params.toString();
306
+ if (qs === "") return base;
307
+ return `${base}${base.includes("?") ? "&" : "?"}${qs}`;
308
+ }
309
+
310
+ #send(
311
+ method: string,
312
+ url: string,
313
+ body: unknown,
314
+ options: HttpRequestOptions,
315
+ ): Promise<Response> {
316
+ const headers: Record<string, string> = {
317
+ ...this.#headers,
318
+ ...options.headers,
319
+ };
320
+ const token = this.#resolveToken(options.token);
321
+ if (token != null && !hasHeader(headers, "authorization")) {
322
+ headers.Authorization = `Bearer ${token}`;
323
+ }
324
+
325
+ let payload: BodyInit | undefined;
326
+ if (body !== undefined && body !== null) {
327
+ if (shouldJsonEncode(body)) {
328
+ payload = JSON.stringify(body);
329
+ if (!hasHeader(headers, "content-type")) {
330
+ headers["Content-Type"] = "application/json";
331
+ }
332
+ } else {
333
+ // Already a valid BodyInit (string / FormData / Blob / …).
334
+ payload = body as BodyInit;
335
+ }
336
+ }
337
+
338
+ const timeout = options.timeout ?? this.#timeout;
339
+ const signal = combineSignals([
340
+ options.signal,
341
+ timeout !== undefined ? AbortSignal.timeout(timeout) : undefined,
342
+ ]);
343
+
344
+ return fetch(this.#buildUrl(url, options.query), {
345
+ method,
346
+ headers,
347
+ body: payload,
348
+ signal,
349
+ credentials: options.credentials ?? this.#credentials,
350
+ });
351
+ }
352
+
353
+ async #request<T>(
354
+ method: string,
355
+ url: string,
356
+ body: unknown,
357
+ options: HttpRequestOptions<T> = {},
358
+ ): Promise<T> {
359
+ const response = await this.#send(method, url, body, options);
360
+ const data = await parseBody(response);
361
+ if (!response.ok) throw new HttpError(response, data);
362
+ // `parse` validates at runtime; without it, `T` is the caller's
363
+ // unchecked assertion of the response shape (the usual HTTP boundary).
364
+ return options.parse ? options.parse(data) : (data as T);
365
+ }
366
+ }
367
+
368
+ /** Default same-origin client. Configure your own via `new HttpClient({ … })`. */
369
+ export const http = new HttpClient();
package/src/index.ts CHANGED
@@ -4,9 +4,50 @@
4
4
  // node:fs / node:path / node:url live in `@c9up/aurora/server`. Keeping them off
5
5
  // this barrel is what lets a browser bundle import the client primitives without
6
6
  // the bundler dragging Node built-ins through the import graph.
7
- export { redirect, reload, replace, storage } from "./browser.js";
7
+ export type {
8
+ CookieOptions,
9
+ PersistedSignalOptions,
10
+ ShareData,
11
+ StorageArea,
12
+ WebStorageOptions,
13
+ WindowSize,
14
+ } from "./browser.js";
15
+ export {
16
+ back,
17
+ clipboard,
18
+ cookie,
19
+ forward,
20
+ hash,
21
+ mediaQuery,
22
+ navigate,
23
+ online,
24
+ persistedSignal,
25
+ prefersDark,
26
+ queryParam,
27
+ redirect,
28
+ reload,
29
+ replace,
30
+ session,
31
+ share,
32
+ storage,
33
+ visibility,
34
+ WebStorage,
35
+ windowSize,
36
+ } from "./browser.js";
8
37
  export { component, onMount, onUnmount } from "./component.js";
9
38
  export { html, isTemplateResult } from "./html.js";
39
+ export type {
40
+ HttpClientOptions,
41
+ HttpRequestOptions,
42
+ HttpResult,
43
+ } from "./http.js";
44
+ export {
45
+ HttpClient,
46
+ HttpError,
47
+ http,
48
+ isAbortError,
49
+ isHttpError,
50
+ } from "./http.js";
10
51
  export { hydrate } from "./hydrate.js";
11
52
  export {
12
53
  batch,