@c9up/aurora 0.1.7 → 0.1.9

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 CHANGED
@@ -24,7 +24,7 @@ providers: [
24
24
 
25
25
  ## Entry points
26
26
 
27
- - `@c9up/aurora` — main API: reactive primitives (`signal`/`effect`/`html`/`component`/`hydrate`) plus the client toolkit — `WebStorage`/`persistedSignal`, reactive browser signals (`prefersDark`/`online`/`windowSize`/…), SPA navigation (`navigate`/`queryParam`), `cookie`/`clipboard`/`share`, and the `HttpClient` fetch wrapper
27
+ - `@c9up/aurora` — main API: reactive primitives (`signal`/`effect`/`html`/`component`/`hydrate`) plus the client toolkit — `WebStorage`/`persistedSignal`, reactive browser signals (`prefersDark`/`online`/`windowSize`/…), SPA navigation (`navigate`/`queryParam`), `cookie`/`clipboard`/`share`, the `HttpClient` fetch wrapper, `command()` (async action + reactive loading/data/error), and `form()` (reactive form controller; optional rune validation + rosetta i18n)
28
28
  - `@c9up/aurora/provider` — Ream IoC provider
29
29
  - `@c9up/aurora/services/main` — container service accessor
30
30
  - `@c9up/aurora/relay` — realtime adapter
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `command()` — wrap an async task (typically an `HttpClient` call) with reactive
3
+ * `loading` / `data` / `error` signals plus `onSuccess` / `onFail` handlers and a
4
+ * `run(...args)` launcher, so call sites never write `then`/`catch` or
5
+ * `await`+try/catch.
6
+ *
7
+ * `run` is re-runnable with different arguments each time; the signals reflect
8
+ * the LATEST run. A superseded (slower) run that resolves after a newer one is
9
+ * silently dropped — it never overwrites the latest state — which makes
10
+ * re-running safe for search-as-you-type / retry. Node-free — part of the client
11
+ * barrel.
12
+ *
13
+ * ```js
14
+ * import { command, HttpClient, isHttpError } from '@c9up/aurora'
15
+ * const api = new HttpClient()
16
+ *
17
+ * const login = command((creds) => api.post('/auth/login', creds))
18
+ * .onSuccess((u) => { user(u); redirect('/app') })
19
+ * .onFail((e) => formError(isHttpError(e) ? e.data : 'Network error'))
20
+ *
21
+ * login.run(creds()) // launch — no try/catch
22
+ * // bind login.loading() for a spinner / disabled button
23
+ * ```
24
+ */
25
+ import { type ReadSignal } from "./reactive.js";
26
+ export interface Command<TArgs extends unknown[], TData> {
27
+ /** Latest successful result, or `null` before the first success / after `reset`. */
28
+ readonly data: ReadSignal<TData | null>;
29
+ /** Latest run's error, or `null` when none. */
30
+ readonly error: ReadSignal<unknown>;
31
+ /** Whether the latest run is in flight. */
32
+ readonly loading: ReadSignal<boolean>;
33
+ /** Register a success handler (chainable; multiple allowed). */
34
+ onSuccess(handler: (data: TData) => void): this;
35
+ /** Register a failure handler — receives the thrown error (chainable). */
36
+ onFail(handler: (error: unknown) => void): this;
37
+ /** Register a handler that runs after success OR failure (chainable). */
38
+ onSettled(handler: () => void): this;
39
+ /** Launch the task with `args`. Always resolves (errors route to `onFail`). */
40
+ run(...args: TArgs): Promise<void>;
41
+ /** Clear `data`/`error`/`loading` and invalidate any in-flight run. */
42
+ reset(): void;
43
+ }
44
+ /**
45
+ * Create a re-runnable {@link Command} around an async `task`. See the module
46
+ * doc for usage.
47
+ */
48
+ export declare function command<TArgs extends unknown[], TData>(task: (...args: TArgs) => Promise<TData>): Command<TArgs, TData>;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `command()` — wrap an async task (typically an `HttpClient` call) with reactive
3
+ * `loading` / `data` / `error` signals plus `onSuccess` / `onFail` handlers and a
4
+ * `run(...args)` launcher, so call sites never write `then`/`catch` or
5
+ * `await`+try/catch.
6
+ *
7
+ * `run` is re-runnable with different arguments each time; the signals reflect
8
+ * the LATEST run. A superseded (slower) run that resolves after a newer one is
9
+ * silently dropped — it never overwrites the latest state — which makes
10
+ * re-running safe for search-as-you-type / retry. Node-free — part of the client
11
+ * barrel.
12
+ *
13
+ * ```js
14
+ * import { command, HttpClient, isHttpError } from '@c9up/aurora'
15
+ * const api = new HttpClient()
16
+ *
17
+ * const login = command((creds) => api.post('/auth/login', creds))
18
+ * .onSuccess((u) => { user(u); redirect('/app') })
19
+ * .onFail((e) => formError(isHttpError(e) ? e.data : 'Network error'))
20
+ *
21
+ * login.run(creds()) // launch — no try/catch
22
+ * // bind login.loading() for a spinner / disabled button
23
+ * ```
24
+ */
25
+ import { signal } from "./reactive.js";
26
+ class CommandRunner {
27
+ #task;
28
+ #data = signal(null);
29
+ #error = signal(null);
30
+ #loading = signal(false);
31
+ #onSuccess = [];
32
+ #onFail = [];
33
+ #onSettled = [];
34
+ #runId = 0;
35
+ data = this.#data;
36
+ error = this.#error;
37
+ loading = this.#loading;
38
+ constructor(task) {
39
+ this.#task = task;
40
+ }
41
+ onSuccess(handler) {
42
+ this.#onSuccess.push(handler);
43
+ return this;
44
+ }
45
+ onFail(handler) {
46
+ this.#onFail.push(handler);
47
+ return this;
48
+ }
49
+ onSettled(handler) {
50
+ this.#onSettled.push(handler);
51
+ return this;
52
+ }
53
+ reset() {
54
+ this.#runId++; // invalidate any in-flight run so it can't write back
55
+ this.#data(null);
56
+ this.#error(null);
57
+ this.#loading(false);
58
+ }
59
+ async run(...args) {
60
+ const id = ++this.#runId;
61
+ this.#loading(true);
62
+ this.#error(null);
63
+ try {
64
+ const result = await this.#task(...args);
65
+ if (id !== this.#runId)
66
+ return; // superseded by a newer run — drop
67
+ this.#data(result);
68
+ for (const handler of this.#onSuccess)
69
+ handler(result);
70
+ }
71
+ catch (error) {
72
+ if (id !== this.#runId)
73
+ return; // superseded — drop
74
+ this.#error(error);
75
+ for (const handler of this.#onFail)
76
+ handler(error);
77
+ }
78
+ finally {
79
+ if (id === this.#runId) {
80
+ this.#loading(false);
81
+ for (const handler of this.#onSettled)
82
+ handler();
83
+ }
84
+ }
85
+ }
86
+ }
87
+ /**
88
+ * Create a re-runnable {@link Command} around an async `task`. See the module
89
+ * doc for usage.
90
+ */
91
+ export function command(task) {
92
+ return new CommandRunner(task);
93
+ }
package/dist/form.d.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `form()` — a minimal reactive form controller: per-field `value` / `error` /
3
+ * `touched` signals, validation, and a submit driven by {@link command} (so the
4
+ * submit's `loading` / `error` are reactive too). Call sites bind the signals in
5
+ * `html\`\`` and never hand-roll field state or try/catch.
6
+ *
7
+ * Validation is OPTIONAL and **agnostic**: pass a `validate` function returning a
8
+ * `{ field: message }` map, OR any object with a `.validate(values)` method
9
+ * (e.g. a `@c9up/rune` schema) — aurora never imports a validator, it only
10
+ * duck-types `.validate`. With no `validate`, the form simply never reports field
11
+ * errors. Node-free — part of the client barrel.
12
+ *
13
+ * ```js
14
+ * import { form, HttpClient, isHttpError } from '@c9up/aurora'
15
+ * import { rules, schema } from '@c9up/rune' // optional
16
+ * const api = new HttpClient()
17
+ *
18
+ * const f = form({
19
+ * initial: { email: '', password: '' },
20
+ * validate: schema({ email: rules.string().email(), password: rules.string().min(8) }),
21
+ * submit: (values) => api.post('/auth/login', values),
22
+ * })
23
+ * .onSuccess(() => redirect('/app'))
24
+ * .onFail((e) => { if (isHttpError(e)) f.setErrors(e.data?.errors ?? {}) })
25
+ *
26
+ * const email = f.field('email')
27
+ * // <input value=${email.value} @input=${(e) => email.set(e.target.value)} @blur=${email.markTouched}>
28
+ * // <button ?disabled=${f.submitting} @click=${(e) => f.handleSubmit(e)}>
29
+ * ```
30
+ */
31
+ import { type ReadSignal } from "./reactive.js";
32
+ /** A field-keyed error map: `{ email: "Invalid", … }`. Absent key ⇒ no error. */
33
+ export type FieldErrors<T> = Partial<Record<keyof T, string>>;
34
+ /** Anything `.validate()`-shaped (a `@c9up/rune` schema satisfies this). */
35
+ export interface FormSchema<T> {
36
+ validate(values: T): {
37
+ valid: boolean;
38
+ errors?: ReadonlyArray<{
39
+ field?: string;
40
+ message: string;
41
+ }>;
42
+ };
43
+ }
44
+ /** Validation source — a function, a schema-like object, or omitted. */
45
+ export type FormValidate<T> = ((values: T) => FieldErrors<T>) | FormSchema<T>;
46
+ export interface FormOptions<T> {
47
+ /** Initial field values; its keys define the form's fields. */
48
+ initial: T;
49
+ /** The submit task (wrapped in a {@link command}). */
50
+ submit: (values: T) => Promise<unknown>;
51
+ /** Optional, agnostic validation (function OR `.validate`-shaped object). */
52
+ validate?: FormValidate<T>;
53
+ }
54
+ /** A single field's reactive handles + setters. */
55
+ export interface FormField<V> {
56
+ readonly value: ReadSignal<V>;
57
+ readonly error: ReadSignal<string | null>;
58
+ readonly touched: ReadSignal<boolean>;
59
+ set(value: V): void;
60
+ markTouched(): void;
61
+ }
62
+ export interface Form<T> {
63
+ readonly values: ReadSignal<T>;
64
+ readonly errors: ReadSignal<FieldErrors<T>>;
65
+ /** Whether the last validation found no errors. */
66
+ readonly valid: ReadSignal<boolean>;
67
+ /** Whether the submit is in flight (the submit command's loading). */
68
+ readonly submitting: ReadSignal<boolean>;
69
+ /** The submit command's last error. */
70
+ readonly submitError: ReadSignal<unknown>;
71
+ field<K extends keyof T>(key: K): FormField<T[K]>;
72
+ set<K extends keyof T>(key: K, value: T[K]): void;
73
+ /** Run validation now, populate `errors`, and return whether it passed. */
74
+ validate(): boolean;
75
+ /** Validate then submit (no-op if invalid). Calls `event.preventDefault()`. */
76
+ handleSubmit(event?: {
77
+ preventDefault(): void;
78
+ }): Promise<void>;
79
+ /** Merge in errors (e.g. server-side field errors from `HttpError.data`). */
80
+ setErrors(errors: FieldErrors<T>): void;
81
+ /** Reset values to `initial` and clear errors / touched / submit state. */
82
+ reset(): void;
83
+ /** Submit success handler (chainable) — receives the resolved value. */
84
+ onSuccess(handler: (data: unknown) => void): this;
85
+ /** Submit failure handler (chainable) — receives the thrown error. */
86
+ onFail(handler: (error: unknown) => void): this;
87
+ }
88
+ /** Create a reactive {@link Form} controller. See the module doc for usage. */
89
+ export declare function form<T>(options: FormOptions<T>): Form<T>;
package/dist/form.js ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `form()` — a minimal reactive form controller: per-field `value` / `error` /
3
+ * `touched` signals, validation, and a submit driven by {@link command} (so the
4
+ * submit's `loading` / `error` are reactive too). Call sites bind the signals in
5
+ * `html\`\`` and never hand-roll field state or try/catch.
6
+ *
7
+ * Validation is OPTIONAL and **agnostic**: pass a `validate` function returning a
8
+ * `{ field: message }` map, OR any object with a `.validate(values)` method
9
+ * (e.g. a `@c9up/rune` schema) — aurora never imports a validator, it only
10
+ * duck-types `.validate`. With no `validate`, the form simply never reports field
11
+ * errors. Node-free — part of the client barrel.
12
+ *
13
+ * ```js
14
+ * import { form, HttpClient, isHttpError } from '@c9up/aurora'
15
+ * import { rules, schema } from '@c9up/rune' // optional
16
+ * const api = new HttpClient()
17
+ *
18
+ * const f = form({
19
+ * initial: { email: '', password: '' },
20
+ * validate: schema({ email: rules.string().email(), password: rules.string().min(8) }),
21
+ * submit: (values) => api.post('/auth/login', values),
22
+ * })
23
+ * .onSuccess(() => redirect('/app'))
24
+ * .onFail((e) => { if (isHttpError(e)) f.setErrors(e.data?.errors ?? {}) })
25
+ *
26
+ * const email = f.field('email')
27
+ * // <input value=${email.value} @input=${(e) => email.set(e.target.value)} @blur=${email.markTouched}>
28
+ * // <button ?disabled=${f.submitting} @click=${(e) => f.handleSubmit(e)}>
29
+ * ```
30
+ */
31
+ import { command } from "./command.js";
32
+ import { memo, signal } from "./reactive.js";
33
+ /** Normalize any validation source into a field-error map. */
34
+ function computeErrors(validate, values) {
35
+ if (!validate)
36
+ return {};
37
+ if (typeof validate === "function")
38
+ return validate(values);
39
+ const result = validate.validate(values);
40
+ if (result.valid)
41
+ return {};
42
+ const errors = {};
43
+ for (const issue of result.errors ?? []) {
44
+ if (issue.field && !(issue.field in errors)) {
45
+ errors[issue.field] = issue.message;
46
+ }
47
+ }
48
+ // Boundary: a schema's errors are string-keyed and it doesn't know `keyof T`,
49
+ // so narrow the validated key space to the form's field type here.
50
+ return errors;
51
+ }
52
+ class FormController {
53
+ #initial;
54
+ #validate;
55
+ #values;
56
+ #errors = signal({});
57
+ #touched = signal(new Set());
58
+ #command;
59
+ values;
60
+ errors = this.#errors;
61
+ valid;
62
+ submitting;
63
+ submitError;
64
+ constructor(options) {
65
+ this.#initial = { ...options.initial };
66
+ this.#validate = options.validate;
67
+ this.#values = signal({ ...options.initial });
68
+ this.values = this.#values;
69
+ this.#command = command((values) => options.submit(values));
70
+ this.submitting = this.#command.loading;
71
+ this.submitError = this.#command.error;
72
+ this.valid = memo(() => Object.values(this.#errors()).every((message) => !message));
73
+ }
74
+ field(key) {
75
+ return {
76
+ value: memo(() => this.#values()[key]),
77
+ error: memo(() => this.#errors()[key] ?? null),
78
+ touched: memo(() => this.#touched().has(String(key))),
79
+ set: (value) => this.set(key, value),
80
+ markTouched: () => this.#markTouched(key),
81
+ };
82
+ }
83
+ set(key, value) {
84
+ const next = { ...this.#values() };
85
+ next[key] = value;
86
+ this.#values(next);
87
+ // Clear a stale error for this field as the user edits it.
88
+ if (this.#errors()[key] !== undefined) {
89
+ const errors = { ...this.#errors() };
90
+ delete errors[key];
91
+ this.#errors(errors);
92
+ }
93
+ }
94
+ validate() {
95
+ const errors = computeErrors(this.#validate, this.#values());
96
+ this.#errors(errors);
97
+ return Object.values(errors).every((message) => !message);
98
+ }
99
+ async handleSubmit(event) {
100
+ event?.preventDefault();
101
+ this.#touched(new Set(Object.keys(this.#values())));
102
+ if (!this.validate())
103
+ return;
104
+ await this.#command.run(this.#values());
105
+ }
106
+ setErrors(errors) {
107
+ this.#errors({ ...this.#errors(), ...errors });
108
+ }
109
+ reset() {
110
+ this.#values({ ...this.#initial });
111
+ this.#errors({});
112
+ this.#touched(new Set());
113
+ this.#command.reset();
114
+ }
115
+ onSuccess(handler) {
116
+ this.#command.onSuccess(handler);
117
+ return this;
118
+ }
119
+ onFail(handler) {
120
+ this.#command.onFail(handler);
121
+ return this;
122
+ }
123
+ #markTouched(key) {
124
+ this.#touched(new Set(this.#touched()).add(String(key)));
125
+ }
126
+ }
127
+ /** Create a reactive {@link Form} controller. See the module doc for usage. */
128
+ export function form(options) {
129
+ return new FormController(options);
130
+ }
package/dist/http.d.ts CHANGED
@@ -28,6 +28,12 @@ export interface HttpClientOptions {
28
28
  token?: string | null | (() => string | null | undefined);
29
29
  /** Default `credentials` mode (e.g. `"include"` to send cookies). */
30
30
  credentials?: RequestCredentials;
31
+ /**
32
+ * Default timeout in ms — the request is aborted (rejecting with a
33
+ * `TimeoutError`) if it doesn't settle in time. Combined with a per-request
34
+ * `signal`, whichever fires first wins.
35
+ */
36
+ timeout?: number;
31
37
  }
32
38
  export interface HttpRequestOptions<T = unknown> {
33
39
  /** Query params appended to the URL. `null`/`undefined` values are skipped. */
@@ -36,8 +42,10 @@ export interface HttpRequestOptions<T = unknown> {
36
42
  headers?: Record<string, string>;
37
43
  /** Per-request bearer token override (`null` to force-omit). */
38
44
  token?: string | null;
39
- /** Abort signal. */
45
+ /** Abort signal — abort it to cancel the request (e.g. on unmount / new keystroke). */
40
46
  signal?: AbortSignal;
47
+ /** Per-request timeout in ms (overrides the client default). Aborts with a `TimeoutError`. */
48
+ timeout?: number;
41
49
  /** `credentials` mode for this request. */
42
50
  credentials?: RequestCredentials;
43
51
  /**
@@ -54,6 +62,28 @@ export declare class HttpError extends Error {
54
62
  readonly data: unknown;
55
63
  constructor(response: Response, data: unknown);
56
64
  }
65
+ /** Type guard for {@link HttpError} — clean `catch` narrowing without a cast. */
66
+ export declare function isHttpError(value: unknown): value is HttpError;
67
+ /**
68
+ * Whether `value` is an aborted/timed-out request error — an `AbortError`
69
+ * (cancelled via a `signal`) or a `TimeoutError` (the `timeout` option fired).
70
+ * Use it to silently ignore cancellations (e.g. a superseded search request).
71
+ */
72
+ export declare function isAbortError(value: unknown): boolean;
73
+ /**
74
+ * Outcome of a non-throwing request via {@link HttpClient.attempt}: a
75
+ * discriminated union a form submit can branch on (`if (r.ok)`) instead of
76
+ * wrapping every call in try/catch.
77
+ */
78
+ export type HttpResult<T> = {
79
+ ok: true;
80
+ data: T;
81
+ error: null;
82
+ } | {
83
+ ok: false;
84
+ data: null;
85
+ error: HttpError;
86
+ };
57
87
  export declare class HttpClient {
58
88
  #private;
59
89
  constructor(options?: HttpClientOptions);
@@ -72,6 +102,20 @@ export declare class HttpClient {
72
102
  patch<T>(url: string, body?: unknown, options?: HttpRequestOptions<T>): Promise<T>;
73
103
  /** Send a request and return the raw `Response` (no parsing, no throw on non-2xx). */
74
104
  raw(method: string, url: string, body?: unknown, options?: HttpRequestOptions): Promise<Response>;
105
+ /**
106
+ * Run a request without throwing on a non-2xx response: returns a
107
+ * discriminated {@link HttpResult} so a form submit can branch
108
+ * (`if (r.ok) … else r.error.data`) instead of wrapping each call in
109
+ * try/catch. Genuine transport failures (offline, DNS) still reject —
110
+ * they're exceptional, not an HTTP error.
111
+ *
112
+ * ```js
113
+ * const r = await api.attempt(api.post('/auth/login', creds))
114
+ * if (r.ok) user(r.data)
115
+ * else fieldErrors(r.error.data) // HttpError.data = parsed 4xx body
116
+ * ```
117
+ */
118
+ attempt<T>(request: Promise<T>): Promise<HttpResult<T>>;
75
119
  /** Derive a new client with merged defaults (e.g. a scope that adds a token). */
76
120
  extend(options: HttpClientOptions): HttpClient;
77
121
  }
package/dist/http.js CHANGED
@@ -28,6 +28,23 @@ export class HttpError extends Error {
28
28
  this.data = data;
29
29
  }
30
30
  }
31
+ /** Type guard for {@link HttpError} — clean `catch` narrowing without a cast. */
32
+ export function isHttpError(value) {
33
+ return value instanceof HttpError;
34
+ }
35
+ /**
36
+ * Whether `value` is an aborted/timed-out request error — an `AbortError`
37
+ * (cancelled via a `signal`) or a `TimeoutError` (the `timeout` option fired).
38
+ * Use it to silently ignore cancellations (e.g. a superseded search request).
39
+ */
40
+ export function isAbortError(value) {
41
+ const name = value instanceof Error
42
+ ? value.name
43
+ : typeof DOMException !== "undefined" && value instanceof DOMException
44
+ ? value.name
45
+ : undefined;
46
+ return name === "AbortError" || name === "TimeoutError";
47
+ }
31
48
  /** Whether `body` should be JSON-encoded (vs. passed to `fetch` untouched). */
32
49
  function shouldJsonEncode(body) {
33
50
  if (body === null || typeof body !== "object") {
@@ -54,6 +71,26 @@ function hasHeader(headers, name) {
54
71
  }
55
72
  return false;
56
73
  }
74
+ /** Merge abort signals into one (whichever fires first wins). `undefined` if none. */
75
+ function combineSignals(signals) {
76
+ const present = signals.filter((s) => s !== undefined);
77
+ if (present.length <= 1)
78
+ return present[0];
79
+ if (typeof AbortSignal.any === "function")
80
+ return AbortSignal.any(present);
81
+ // Fallback for runtimes without AbortSignal.any.
82
+ const controller = new AbortController();
83
+ for (const signal of present) {
84
+ if (signal.aborted) {
85
+ controller.abort(signal.reason);
86
+ break;
87
+ }
88
+ signal.addEventListener("abort", () => controller.abort(signal.reason), {
89
+ once: true,
90
+ });
91
+ }
92
+ return controller.signal;
93
+ }
57
94
  /** Parse a response by content-type; `null` for empty / no-content bodies. */
58
95
  async function parseBody(response) {
59
96
  if (response.status === 204 || response.status === 205)
@@ -71,11 +108,13 @@ export class HttpClient {
71
108
  #headers;
72
109
  #token;
73
110
  #credentials;
111
+ #timeout;
74
112
  constructor(options = {}) {
75
113
  this.#baseURL = options.baseURL ?? "";
76
114
  this.#headers = { ...options.headers };
77
115
  this.#token = options.token;
78
116
  this.#credentials = options.credentials;
117
+ this.#timeout = options.timeout;
79
118
  }
80
119
  /** Set a default header for every subsequent request (case-insensitive replace). Chainable. */
81
120
  setHeader(name, value) {
@@ -125,6 +164,30 @@ export class HttpClient {
125
164
  raw(method, url, body, options = {}) {
126
165
  return this.#send(method, url, body, options);
127
166
  }
167
+ /**
168
+ * Run a request without throwing on a non-2xx response: returns a
169
+ * discriminated {@link HttpResult} so a form submit can branch
170
+ * (`if (r.ok) … else r.error.data`) instead of wrapping each call in
171
+ * try/catch. Genuine transport failures (offline, DNS) still reject —
172
+ * they're exceptional, not an HTTP error.
173
+ *
174
+ * ```js
175
+ * const r = await api.attempt(api.post('/auth/login', creds))
176
+ * if (r.ok) user(r.data)
177
+ * else fieldErrors(r.error.data) // HttpError.data = parsed 4xx body
178
+ * ```
179
+ */
180
+ async attempt(request) {
181
+ try {
182
+ return { ok: true, data: await request, error: null };
183
+ }
184
+ catch (error) {
185
+ if (error instanceof HttpError) {
186
+ return { ok: false, data: null, error };
187
+ }
188
+ throw error;
189
+ }
190
+ }
128
191
  /** Derive a new client with merged defaults (e.g. a scope that adds a token). */
129
192
  extend(options) {
130
193
  return new HttpClient({
@@ -132,6 +195,7 @@ export class HttpClient {
132
195
  headers: { ...this.#headers, ...options.headers },
133
196
  token: options.token ?? this.#token,
134
197
  credentials: options.credentials ?? this.#credentials,
198
+ timeout: options.timeout ?? this.#timeout,
135
199
  });
136
200
  }
137
201
  #resolveToken(override) {
@@ -177,11 +241,16 @@ export class HttpClient {
177
241
  payload = body;
178
242
  }
179
243
  }
244
+ const timeout = options.timeout ?? this.#timeout;
245
+ const signal = combineSignals([
246
+ options.signal,
247
+ timeout !== undefined ? AbortSignal.timeout(timeout) : undefined,
248
+ ]);
180
249
  return fetch(this.#buildUrl(url, options.query), {
181
250
  method,
182
251
  headers,
183
252
  body: payload,
184
- signal: options.signal,
253
+ signal,
185
254
  credentials: options.credentials ?? this.#credentials,
186
255
  });
187
256
  }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  export type { CookieOptions, PersistedSignalOptions, ShareData, StorageArea, WebStorageOptions, WindowSize, } from "./browser.js";
2
2
  export { back, clipboard, cookie, forward, hash, mediaQuery, navigate, online, persistedSignal, prefersDark, queryParam, redirect, reload, replace, session, share, storage, visibility, WebStorage, windowSize, } from "./browser.js";
3
+ export type { Command } from "./command.js";
4
+ export { command } from "./command.js";
3
5
  export { component, onMount, onUnmount } from "./component.js";
6
+ export type { FieldErrors, Form, FormField, FormOptions, FormSchema, FormValidate, } from "./form.js";
7
+ export { form } from "./form.js";
4
8
  export { html, isTemplateResult } from "./html.js";
5
- export type { HttpClientOptions, HttpRequestOptions } from "./http.js";
6
- export { HttpClient, HttpError, http } from "./http.js";
9
+ export type { HttpClientOptions, HttpRequestOptions, HttpResult, } from "./http.js";
10
+ export { HttpClient, HttpError, http, isAbortError, isHttpError, } from "./http.js";
7
11
  export { hydrate } from "./hydrate.js";
8
12
  export { batch, effect, isSignal, memo, onCleanup, type ReadSignal, type Signal, signal, untrack, } from "./reactive.js";
9
13
  export { type Disposer, render } from "./render.js";
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  export { back, clipboard, cookie, forward, hash, mediaQuery, navigate, online, persistedSignal, prefersDark, queryParam, redirect, reload, replace, session, share, storage, visibility, WebStorage, windowSize, } from "./browser.js";
2
+ export { command } from "./command.js";
2
3
  export { component, onMount, onUnmount } from "./component.js";
4
+ export { form } from "./form.js";
3
5
  export { html, isTemplateResult } from "./html.js";
4
- export { HttpClient, HttpError, http } from "./http.js";
6
+ export { HttpClient, HttpError, http, isAbortError, isHttpError, } from "./http.js";
5
7
  export { hydrate } from "./hydrate.js";
6
8
  export { batch, effect, isSignal, memo, onCleanup, signal, untrack, } from "./reactive.js";
7
9
  export { render } from "./render.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Aurora — reactive UI runtime for the Ream framework. Tagged-template DOM, signal-based state, isomorphic SSR + hydration, zero build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/command.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `command()` — wrap an async task (typically an `HttpClient` call) with reactive
3
+ * `loading` / `data` / `error` signals plus `onSuccess` / `onFail` handlers and a
4
+ * `run(...args)` launcher, so call sites never write `then`/`catch` or
5
+ * `await`+try/catch.
6
+ *
7
+ * `run` is re-runnable with different arguments each time; the signals reflect
8
+ * the LATEST run. A superseded (slower) run that resolves after a newer one is
9
+ * silently dropped — it never overwrites the latest state — which makes
10
+ * re-running safe for search-as-you-type / retry. Node-free — part of the client
11
+ * barrel.
12
+ *
13
+ * ```js
14
+ * import { command, HttpClient, isHttpError } from '@c9up/aurora'
15
+ * const api = new HttpClient()
16
+ *
17
+ * const login = command((creds) => api.post('/auth/login', creds))
18
+ * .onSuccess((u) => { user(u); redirect('/app') })
19
+ * .onFail((e) => formError(isHttpError(e) ? e.data : 'Network error'))
20
+ *
21
+ * login.run(creds()) // launch — no try/catch
22
+ * // bind login.loading() for a spinner / disabled button
23
+ * ```
24
+ */
25
+
26
+ import { type ReadSignal, signal } from "./reactive.js";
27
+
28
+ export interface Command<TArgs extends unknown[], TData> {
29
+ /** Latest successful result, or `null` before the first success / after `reset`. */
30
+ readonly data: ReadSignal<TData | null>;
31
+ /** Latest run's error, or `null` when none. */
32
+ readonly error: ReadSignal<unknown>;
33
+ /** Whether the latest run is in flight. */
34
+ readonly loading: ReadSignal<boolean>;
35
+ /** Register a success handler (chainable; multiple allowed). */
36
+ onSuccess(handler: (data: TData) => void): this;
37
+ /** Register a failure handler — receives the thrown error (chainable). */
38
+ onFail(handler: (error: unknown) => void): this;
39
+ /** Register a handler that runs after success OR failure (chainable). */
40
+ onSettled(handler: () => void): this;
41
+ /** Launch the task with `args`. Always resolves (errors route to `onFail`). */
42
+ run(...args: TArgs): Promise<void>;
43
+ /** Clear `data`/`error`/`loading` and invalidate any in-flight run. */
44
+ reset(): void;
45
+ }
46
+
47
+ class CommandRunner<TArgs extends unknown[], TData>
48
+ implements Command<TArgs, TData>
49
+ {
50
+ readonly #task: (...args: TArgs) => Promise<TData>;
51
+ readonly #data = signal<TData | null>(null);
52
+ readonly #error = signal<unknown>(null);
53
+ readonly #loading = signal(false);
54
+ readonly #onSuccess: Array<(data: TData) => void> = [];
55
+ readonly #onFail: Array<(error: unknown) => void> = [];
56
+ readonly #onSettled: Array<() => void> = [];
57
+ #runId = 0;
58
+
59
+ readonly data: ReadSignal<TData | null> = this.#data;
60
+ readonly error: ReadSignal<unknown> = this.#error;
61
+ readonly loading: ReadSignal<boolean> = this.#loading;
62
+
63
+ constructor(task: (...args: TArgs) => Promise<TData>) {
64
+ this.#task = task;
65
+ }
66
+
67
+ onSuccess(handler: (data: TData) => void): this {
68
+ this.#onSuccess.push(handler);
69
+ return this;
70
+ }
71
+
72
+ onFail(handler: (error: unknown) => void): this {
73
+ this.#onFail.push(handler);
74
+ return this;
75
+ }
76
+
77
+ onSettled(handler: () => void): this {
78
+ this.#onSettled.push(handler);
79
+ return this;
80
+ }
81
+
82
+ reset(): void {
83
+ this.#runId++; // invalidate any in-flight run so it can't write back
84
+ this.#data(null);
85
+ this.#error(null);
86
+ this.#loading(false);
87
+ }
88
+
89
+ async run(...args: TArgs): Promise<void> {
90
+ const id = ++this.#runId;
91
+ this.#loading(true);
92
+ this.#error(null);
93
+ try {
94
+ const result = await this.#task(...args);
95
+ if (id !== this.#runId) return; // superseded by a newer run — drop
96
+ this.#data(result);
97
+ for (const handler of this.#onSuccess) handler(result);
98
+ } catch (error) {
99
+ if (id !== this.#runId) return; // superseded — drop
100
+ this.#error(error);
101
+ for (const handler of this.#onFail) handler(error);
102
+ } finally {
103
+ if (id === this.#runId) {
104
+ this.#loading(false);
105
+ for (const handler of this.#onSettled) handler();
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Create a re-runnable {@link Command} around an async `task`. See the module
113
+ * doc for usage.
114
+ */
115
+ export function command<TArgs extends unknown[], TData>(
116
+ task: (...args: TArgs) => Promise<TData>,
117
+ ): Command<TArgs, TData> {
118
+ return new CommandRunner(task);
119
+ }
package/src/form.ts ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * `form()` — a minimal reactive form controller: per-field `value` / `error` /
3
+ * `touched` signals, validation, and a submit driven by {@link command} (so the
4
+ * submit's `loading` / `error` are reactive too). Call sites bind the signals in
5
+ * `html\`\`` and never hand-roll field state or try/catch.
6
+ *
7
+ * Validation is OPTIONAL and **agnostic**: pass a `validate` function returning a
8
+ * `{ field: message }` map, OR any object with a `.validate(values)` method
9
+ * (e.g. a `@c9up/rune` schema) — aurora never imports a validator, it only
10
+ * duck-types `.validate`. With no `validate`, the form simply never reports field
11
+ * errors. Node-free — part of the client barrel.
12
+ *
13
+ * ```js
14
+ * import { form, HttpClient, isHttpError } from '@c9up/aurora'
15
+ * import { rules, schema } from '@c9up/rune' // optional
16
+ * const api = new HttpClient()
17
+ *
18
+ * const f = form({
19
+ * initial: { email: '', password: '' },
20
+ * validate: schema({ email: rules.string().email(), password: rules.string().min(8) }),
21
+ * submit: (values) => api.post('/auth/login', values),
22
+ * })
23
+ * .onSuccess(() => redirect('/app'))
24
+ * .onFail((e) => { if (isHttpError(e)) f.setErrors(e.data?.errors ?? {}) })
25
+ *
26
+ * const email = f.field('email')
27
+ * // <input value=${email.value} @input=${(e) => email.set(e.target.value)} @blur=${email.markTouched}>
28
+ * // <button ?disabled=${f.submitting} @click=${(e) => f.handleSubmit(e)}>
29
+ * ```
30
+ */
31
+
32
+ import { command } from "./command.js";
33
+ import { memo, type ReadSignal, signal } from "./reactive.js";
34
+
35
+ /** A field-keyed error map: `{ email: "Invalid", … }`. Absent key ⇒ no error. */
36
+ export type FieldErrors<T> = Partial<Record<keyof T, string>>;
37
+
38
+ /** Anything `.validate()`-shaped (a `@c9up/rune` schema satisfies this). */
39
+ export interface FormSchema<T> {
40
+ validate(values: T): {
41
+ valid: boolean;
42
+ errors?: ReadonlyArray<{ field?: string; message: string }>;
43
+ };
44
+ }
45
+
46
+ /** Validation source — a function, a schema-like object, or omitted. */
47
+ export type FormValidate<T> = ((values: T) => FieldErrors<T>) | FormSchema<T>;
48
+
49
+ export interface FormOptions<T> {
50
+ /** Initial field values; its keys define the form's fields. */
51
+ initial: T;
52
+ /** The submit task (wrapped in a {@link command}). */
53
+ submit: (values: T) => Promise<unknown>;
54
+ /** Optional, agnostic validation (function OR `.validate`-shaped object). */
55
+ validate?: FormValidate<T>;
56
+ }
57
+
58
+ /** A single field's reactive handles + setters. */
59
+ export interface FormField<V> {
60
+ readonly value: ReadSignal<V>;
61
+ readonly error: ReadSignal<string | null>;
62
+ readonly touched: ReadSignal<boolean>;
63
+ set(value: V): void;
64
+ markTouched(): void;
65
+ }
66
+
67
+ export interface Form<T> {
68
+ readonly values: ReadSignal<T>;
69
+ readonly errors: ReadSignal<FieldErrors<T>>;
70
+ /** Whether the last validation found no errors. */
71
+ readonly valid: ReadSignal<boolean>;
72
+ /** Whether the submit is in flight (the submit command's loading). */
73
+ readonly submitting: ReadSignal<boolean>;
74
+ /** The submit command's last error. */
75
+ readonly submitError: ReadSignal<unknown>;
76
+ field<K extends keyof T>(key: K): FormField<T[K]>;
77
+ set<K extends keyof T>(key: K, value: T[K]): void;
78
+ /** Run validation now, populate `errors`, and return whether it passed. */
79
+ validate(): boolean;
80
+ /** Validate then submit (no-op if invalid). Calls `event.preventDefault()`. */
81
+ handleSubmit(event?: { preventDefault(): void }): Promise<void>;
82
+ /** Merge in errors (e.g. server-side field errors from `HttpError.data`). */
83
+ setErrors(errors: FieldErrors<T>): void;
84
+ /** Reset values to `initial` and clear errors / touched / submit state. */
85
+ reset(): void;
86
+ /** Submit success handler (chainable) — receives the resolved value. */
87
+ onSuccess(handler: (data: unknown) => void): this;
88
+ /** Submit failure handler (chainable) — receives the thrown error. */
89
+ onFail(handler: (error: unknown) => void): this;
90
+ }
91
+
92
+ /** Normalize any validation source into a field-error map. */
93
+ function computeErrors<T>(
94
+ validate: FormValidate<T> | undefined,
95
+ values: T,
96
+ ): FieldErrors<T> {
97
+ if (!validate) return {};
98
+ if (typeof validate === "function") return validate(values);
99
+ const result = validate.validate(values);
100
+ if (result.valid) return {};
101
+ const errors: Record<string, string> = {};
102
+ for (const issue of result.errors ?? []) {
103
+ if (issue.field && !(issue.field in errors)) {
104
+ errors[issue.field] = issue.message;
105
+ }
106
+ }
107
+ // Boundary: a schema's errors are string-keyed and it doesn't know `keyof T`,
108
+ // so narrow the validated key space to the form's field type here.
109
+ return errors as FieldErrors<T>;
110
+ }
111
+
112
+ class FormController<T> implements Form<T> {
113
+ readonly #initial: T;
114
+ readonly #validate?: FormValidate<T>;
115
+ readonly #values: ReturnType<typeof signal<T>>;
116
+ readonly #errors = signal<FieldErrors<T>>({});
117
+ readonly #touched = signal<ReadonlySet<string>>(new Set());
118
+ readonly #command: ReturnType<typeof command<[T], unknown>>;
119
+
120
+ readonly values: ReadSignal<T>;
121
+ readonly errors: ReadSignal<FieldErrors<T>> = this.#errors;
122
+ readonly valid: ReadSignal<boolean>;
123
+ readonly submitting: ReadSignal<boolean>;
124
+ readonly submitError: ReadSignal<unknown>;
125
+
126
+ constructor(options: FormOptions<T>) {
127
+ this.#initial = { ...options.initial };
128
+ this.#validate = options.validate;
129
+ this.#values = signal<T>({ ...options.initial });
130
+ this.values = this.#values;
131
+ this.#command = command((values: T) => options.submit(values));
132
+ this.submitting = this.#command.loading;
133
+ this.submitError = this.#command.error;
134
+ this.valid = memo(() =>
135
+ Object.values(this.#errors()).every((message) => !message),
136
+ );
137
+ }
138
+
139
+ field<K extends keyof T>(key: K): FormField<T[K]> {
140
+ return {
141
+ value: memo(() => this.#values()[key]),
142
+ error: memo(() => this.#errors()[key] ?? null),
143
+ touched: memo(() => this.#touched().has(String(key))),
144
+ set: (value: T[K]) => this.set(key, value),
145
+ markTouched: () => this.#markTouched(key),
146
+ };
147
+ }
148
+
149
+ set<K extends keyof T>(key: K, value: T[K]): void {
150
+ const next = { ...this.#values() };
151
+ next[key] = value;
152
+ this.#values(next);
153
+ // Clear a stale error for this field as the user edits it.
154
+ if (this.#errors()[key] !== undefined) {
155
+ const errors = { ...this.#errors() };
156
+ delete errors[key];
157
+ this.#errors(errors);
158
+ }
159
+ }
160
+
161
+ validate(): boolean {
162
+ const errors = computeErrors(this.#validate, this.#values());
163
+ this.#errors(errors);
164
+ return Object.values(errors).every((message) => !message);
165
+ }
166
+
167
+ async handleSubmit(event?: { preventDefault(): void }): Promise<void> {
168
+ event?.preventDefault();
169
+ this.#touched(new Set(Object.keys(this.#values() as object)));
170
+ if (!this.validate()) return;
171
+ await this.#command.run(this.#values());
172
+ }
173
+
174
+ setErrors(errors: FieldErrors<T>): void {
175
+ this.#errors({ ...this.#errors(), ...errors });
176
+ }
177
+
178
+ reset(): void {
179
+ this.#values({ ...this.#initial });
180
+ this.#errors({});
181
+ this.#touched(new Set());
182
+ this.#command.reset();
183
+ }
184
+
185
+ onSuccess(handler: (data: unknown) => void): this {
186
+ this.#command.onSuccess(handler);
187
+ return this;
188
+ }
189
+
190
+ onFail(handler: (error: unknown) => void): this {
191
+ this.#command.onFail(handler);
192
+ return this;
193
+ }
194
+
195
+ #markTouched(key: keyof T): void {
196
+ this.#touched(new Set(this.#touched()).add(String(key)));
197
+ }
198
+ }
199
+
200
+ /** Create a reactive {@link Form} controller. See the module doc for usage. */
201
+ export function form<T>(options: FormOptions<T>): Form<T> {
202
+ return new FormController(options);
203
+ }
package/src/http.ts CHANGED
@@ -29,6 +29,12 @@ export interface HttpClientOptions {
29
29
  token?: string | null | (() => string | null | undefined);
30
30
  /** Default `credentials` mode (e.g. `"include"` to send cookies). */
31
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;
32
38
  }
33
39
 
34
40
  export interface HttpRequestOptions<T = unknown> {
@@ -38,8 +44,10 @@ export interface HttpRequestOptions<T = unknown> {
38
44
  headers?: Record<string, string>;
39
45
  /** Per-request bearer token override (`null` to force-omit). */
40
46
  token?: string | null;
41
- /** Abort signal. */
47
+ /** Abort signal — abort it to cancel the request (e.g. on unmount / new keystroke). */
42
48
  signal?: AbortSignal;
49
+ /** Per-request timeout in ms (overrides the client default). Aborts with a `TimeoutError`. */
50
+ timeout?: number;
43
51
  /** `credentials` mode for this request. */
44
52
  credentials?: RequestCredentials;
45
53
  /**
@@ -65,6 +73,35 @@ export class HttpError extends Error {
65
73
  }
66
74
  }
67
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
+
68
105
  /** Whether `body` should be JSON-encoded (vs. passed to `fetch` untouched). */
69
106
  function shouldJsonEncode(body: unknown): boolean {
70
107
  if (body === null || typeof body !== "object") {
@@ -94,6 +131,27 @@ function hasHeader(headers: Record<string, string>, name: string): boolean {
94
131
  return false;
95
132
  }
96
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
+
97
155
  /** Parse a response by content-type; `null` for empty / no-content bodies. */
98
156
  async function parseBody(response: Response): Promise<unknown> {
99
157
  if (response.status === 204 || response.status === 205) return null;
@@ -109,12 +167,14 @@ export class HttpClient {
109
167
  readonly #headers: Record<string, string>;
110
168
  readonly #token?: string | null | (() => string | null | undefined);
111
169
  readonly #credentials?: RequestCredentials;
170
+ readonly #timeout?: number;
112
171
 
113
172
  constructor(options: HttpClientOptions = {}) {
114
173
  this.#baseURL = options.baseURL ?? "";
115
174
  this.#headers = { ...options.headers };
116
175
  this.#token = options.token;
117
176
  this.#credentials = options.credentials;
177
+ this.#timeout = options.timeout;
118
178
  }
119
179
 
120
180
  /** Set a default header for every subsequent request (case-insensitive replace). Chainable. */
@@ -192,6 +252,30 @@ export class HttpClient {
192
252
  return this.#send(method, url, body, options);
193
253
  }
194
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
+
195
279
  /** Derive a new client with merged defaults (e.g. a scope that adds a token). */
196
280
  extend(options: HttpClientOptions): HttpClient {
197
281
  return new HttpClient({
@@ -199,6 +283,7 @@ export class HttpClient {
199
283
  headers: { ...this.#headers, ...options.headers },
200
284
  token: options.token ?? this.#token,
201
285
  credentials: options.credentials ?? this.#credentials,
286
+ timeout: options.timeout ?? this.#timeout,
202
287
  });
203
288
  }
204
289
 
@@ -250,11 +335,17 @@ export class HttpClient {
250
335
  }
251
336
  }
252
337
 
338
+ const timeout = options.timeout ?? this.#timeout;
339
+ const signal = combineSignals([
340
+ options.signal,
341
+ timeout !== undefined ? AbortSignal.timeout(timeout) : undefined,
342
+ ]);
343
+
253
344
  return fetch(this.#buildUrl(url, options.query), {
254
345
  method,
255
346
  headers,
256
347
  body: payload,
257
- signal: options.signal,
348
+ signal,
258
349
  credentials: options.credentials ?? this.#credentials,
259
350
  });
260
351
  }
package/src/index.ts CHANGED
@@ -34,10 +34,31 @@ export {
34
34
  WebStorage,
35
35
  windowSize,
36
36
  } from "./browser.js";
37
+ export type { Command } from "./command.js";
38
+ export { command } from "./command.js";
37
39
  export { component, onMount, onUnmount } from "./component.js";
40
+ export type {
41
+ FieldErrors,
42
+ Form,
43
+ FormField,
44
+ FormOptions,
45
+ FormSchema,
46
+ FormValidate,
47
+ } from "./form.js";
48
+ export { form } from "./form.js";
38
49
  export { html, isTemplateResult } from "./html.js";
39
- export type { HttpClientOptions, HttpRequestOptions } from "./http.js";
40
- export { HttpClient, HttpError, http } from "./http.js";
50
+ export type {
51
+ HttpClientOptions,
52
+ HttpRequestOptions,
53
+ HttpResult,
54
+ } from "./http.js";
55
+ export {
56
+ HttpClient,
57
+ HttpError,
58
+ http,
59
+ isAbortError,
60
+ isHttpError,
61
+ } from "./http.js";
41
62
  export { hydrate } from "./hydrate.js";
42
63
  export {
43
64
  batch,