@uniweb/api 0.1.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/src/client.js ADDED
@@ -0,0 +1,496 @@
1
+ /**
2
+ * @uniweb/api — the client half. No React in this module.
3
+ *
4
+ * A foundation's client for the site's own backend — the one a host declares
5
+ * as the site service `api`. Absent that declaration the site has no such
6
+ * backend and everything here is inert: no request leaves, and a component
7
+ * renders for that state rather than retrying it.
8
+ *
9
+ * What lives here: the service name, the base, the one client instance a page
10
+ * holds — its session, its request primitive, the cache keys it scopes to a
11
+ * viewer — and the plain functions a foundation calls outside React.
12
+ */
13
+
14
+ import { getUniweb, deriveCacheKey } from '@uniweb/core'
15
+ import { resolveService } from '@uniweb/core/services'
16
+ import { ApiError } from './errors.js'
17
+ import { composeUrl, isCrossOrigin, readBody, UNSAFE } from './http.js'
18
+
19
+ /** The site service this package reads its base from — the only name it owns. */
20
+ export const SERVICE_NAME = 'api'
21
+
22
+ /**
23
+ * The shape of the shared instance, as a contract between copies of this
24
+ * package on one page. Within a major, changes are additive, so a copy built
25
+ * against an older package works with a newer instance.
26
+ */
27
+ export const CONTRACT = 1
28
+
29
+ /**
30
+ * Where the site's backend is, if it has one.
31
+ *
32
+ * `resolveService` answers with the site's own declaration first (`api:` in
33
+ * `site.yml`), then the host's (`config.services.api`), and `null` when neither
34
+ * names an address. Absence is the ordinary state of a site with no backend,
35
+ * not an error.
36
+ *
37
+ * @param {object} website - the active Website, or anything shaped `{ config, basePath }`
38
+ * @returns {string|null} the base every request is made against, or null
39
+ */
40
+ export function resolveBase(website) {
41
+ return resolveService(website, SERVICE_NAME).url || null
42
+ }
43
+
44
+ /**
45
+ * Does this site have a backend the package can talk to?
46
+ *
47
+ * The question to ask before drawing a sign-in affordance or any control only a
48
+ * backend can answer. False means: draw nothing, or the static alternative the
49
+ * site already carries.
50
+ *
51
+ * @param {object} website
52
+ * @returns {boolean}
53
+ */
54
+ export function isEnabled(website) {
55
+ return resolveBase(website) !== null
56
+ }
57
+
58
+ const ANONYMOUS = Object.freeze({ status: 'anonymous', viewer: null, error: null })
59
+ const LOADING = Object.freeze({ status: 'loading', viewer: null, error: null })
60
+
61
+ /**
62
+ * The one client instance per page.
63
+ *
64
+ * Holds what has identity or lifetime — the session snapshot and its
65
+ * subscribers, the in-flight table, the cache keys written for the current
66
+ * viewer, a pending sign-in challenge — and nothing a second copy of this
67
+ * package could disagree with. The snapshot is a frozen value replaced on
68
+ * change, so React reads it through `useSyncExternalStore` with stable
69
+ * identity, and it works under `renderToString`, where no effect runs and
70
+ * nothing is fetched.
71
+ *
72
+ * Everything else is read at use, never captured at creation: the base comes
73
+ * from `website.config` on each call, so the editor's `Website.rebuild()`
74
+ * needs no hook here.
75
+ */
76
+ export class ApiClient {
77
+ /**
78
+ * @param {object} uniweb - the page's `Uniweb` singleton
79
+ * @param {object} [options]
80
+ * @param {typeof fetch} [options.fetchFn] - a `fetch` to use instead of the
81
+ * global one — a test's, or a server tool's with a cookie jar
82
+ */
83
+ constructor(uniweb, { fetchFn = null } = {}) {
84
+ this.v = CONTRACT
85
+ this._uniweb = uniweb
86
+ this.fetchFn = fetchFn
87
+ this._listeners = new Set()
88
+ this._pending = null
89
+ this._challenge = null
90
+ this._keys = new Set()
91
+ this._inflight = new Map()
92
+ this._session = this.enabled ? LOADING : ANONYMOUS
93
+ // Stable identity: `useSyncExternalStore` re-subscribes when this changes.
94
+ this.subscribe = this.subscribe.bind(this)
95
+ }
96
+
97
+ /** The active Website, read at use. */
98
+ get website() {
99
+ return this._uniweb?.activeWebsite ?? null
100
+ }
101
+
102
+ /** The base every request is made against, or null. Read at use. */
103
+ get base() {
104
+ return resolveBase(this.website)
105
+ }
106
+
107
+ /** Whether the site declares a backend at all. */
108
+ get enabled() {
109
+ return this.base !== null
110
+ }
111
+
112
+ /** The current session snapshot — `{ status, viewer, error }`, frozen. */
113
+ get session() {
114
+ return this._session
115
+ }
116
+
117
+ /** What scopes a cache key to the current viewer. */
118
+ get viewerId() {
119
+ return this._session.viewer?.uuid ?? 'anonymous'
120
+ }
121
+
122
+ /**
123
+ * Observe the session. Fires after every change of the snapshot.
124
+ *
125
+ * @param {Function} fn
126
+ * @returns {Function} unsubscribe
127
+ */
128
+ subscribe(fn) {
129
+ this._listeners.add(fn)
130
+ return () => {
131
+ this._listeners.delete(fn)
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Replace the snapshot and wake subscribers. The probe, sign-in and sign-out
137
+ * all land here; a snapshot equal in status, viewer and error is a no-op.
138
+ *
139
+ * @param {{ status: 'loading'|'anonymous'|'authenticated', viewer?: object|null, error?: Error|null }} next
140
+ * @returns {object} the snapshot now current
141
+ */
142
+ setSession(next) {
143
+ const cur = this._session
144
+ const viewer = next.viewer ?? null
145
+ const error = next.error ?? null
146
+ if (cur.status === next.status && cur.viewer === viewer && cur.error === error) return cur
147
+ this._session = Object.freeze({ status: next.status, viewer, error })
148
+ for (const fn of this._listeners) fn()
149
+ return this._session
150
+ }
151
+
152
+ // ── The wire ──────────────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * One request to the backend. The only place a URL is composed.
156
+ *
157
+ * Sends `Accept: application/json`; a JSON body when one is given; the CSRF
158
+ * header on every unsafe method, which cookie-authenticated mutations
159
+ * require; and credentials only when the base is another origin. The locale
160
+ * rides only on reads that return localized values — the backend refuses a
161
+ * parameter a route does not take (`400 "Unexpected parameters: locale"`,
162
+ * measured on `/auth/me`). A non-2xx answer becomes an `ApiError`, and a `401` —
163
+ * unless the caller says otherwise — means the session is gone: the viewer's
164
+ * cache entries leave memory and the session turns anonymous.
165
+ *
166
+ * @param {string} method
167
+ * @param {string} path - the route under `/api`
168
+ * @param {object} [options]
169
+ * @param {object} [options.query]
170
+ * @param {*} [options.body]
171
+ * @param {AbortSignal} [options.signal]
172
+ * @param {object} [options.headers]
173
+ * @param {'session-lost'|'ignore'} [options.onUnauthorized] - what a `401`
174
+ * means. The login family and the probe pass `ignore`: there a `401` is an
175
+ * answer about the credential offered, not about the session held
176
+ * @returns {Promise<*>} the parsed body
177
+ * @throws {ApiError}
178
+ */
179
+ async request(method, path, { query, body, signal, headers, onUnauthorized = 'session-lost' } = {}) {
180
+ const base = this.base
181
+ if (base === null) throw ApiError.disabled()
182
+ const fetchFn = this.fetchFn ?? globalThis.fetch
183
+ if (typeof fetchFn !== 'function') {
184
+ throw new ApiError({ status: 0, title: 'No fetch', detail: 'fetch is unavailable in this environment', kind: 'unavailable' })
185
+ }
186
+
187
+ const url = composeUrl(base, path, query)
188
+ const init = {
189
+ method,
190
+ signal,
191
+ credentials: isCrossOrigin(base) ? 'include' : 'same-origin',
192
+ headers: { accept: 'application/json', ...(headers || {}) },
193
+ }
194
+ if (UNSAFE.has(method)) init.headers['x-uniweb-csrf'] = '1'
195
+ if (body !== undefined) {
196
+ init.headers['content-type'] = 'application/json'
197
+ init.body = JSON.stringify(body)
198
+ }
199
+
200
+ let res
201
+ try {
202
+ res = await fetchFn(url, init)
203
+ } catch (err) {
204
+ throw ApiError.network(err)
205
+ }
206
+ const payload = await readBody(res)
207
+ if (res.ok) return payload
208
+
209
+ const error = ApiError.fromResponse(res, payload)
210
+ if (error.status === 401 && onUnauthorized === 'session-lost') this._sessionLost()
211
+ throw error
212
+ }
213
+
214
+ _localeQuery() {
215
+ const website = this.website
216
+ const locale = website?.getActiveLocale?.() ?? website?.activeLocale ?? null
217
+ return locale ? { locale } : null
218
+ }
219
+
220
+ // ── The session ───────────────────────────────────────────────────────────
221
+
222
+ /**
223
+ * Settle the session once. Idempotent and shared: every caller of an
224
+ * in-flight probe gets the same promise.
225
+ *
226
+ * On a site with no backend this resolves to anonymous and makes no request
227
+ * — the ordinary case. With a backend declared, it asks once who the viewer
228
+ * is; a `401` is the answer "nobody", and anything else that goes wrong
229
+ * leaves the session `loading` with the error attached, for `refresh()` to
230
+ * retry.
231
+ *
232
+ * @returns {Promise<object>} the snapshot
233
+ */
234
+ ensureSession() {
235
+ if (!this.enabled) return Promise.resolve(this.setSession(ANONYMOUS))
236
+ if (this._session.status !== 'loading') return Promise.resolve(this._session)
237
+ return this._share(() => this._probe())
238
+ }
239
+
240
+ /** Ask again who the viewer is — after a sign-in elsewhere, or on focus. */
241
+ refresh() {
242
+ if (!this.enabled) return Promise.resolve(this.setSession(ANONYMOUS))
243
+ return this._share(() => this._probe())
244
+ }
245
+
246
+ _share(run) {
247
+ if (this._pending) return this._pending
248
+ this._pending = run().finally(() => {
249
+ this._pending = null
250
+ })
251
+ return this._pending
252
+ }
253
+
254
+ async _probe() {
255
+ try {
256
+ const me = await this.request('GET', '/auth/me', { onUnauthorized: 'ignore' })
257
+ return this._authenticated(me)
258
+ } catch (err) {
259
+ if (err instanceof ApiError && err.status === 401) return this._sessionLost()
260
+ const cur = this._session
261
+ return this.setSession({ status: cur.status, viewer: cur.viewer, error: err })
262
+ }
263
+ }
264
+
265
+ _authenticated(me) {
266
+ const account = me?.account && typeof me.account === 'object' ? me.account : {}
267
+ const viewer = Object.freeze({
268
+ ...account,
269
+ roles: Array.isArray(me?.roles) ? me.roles : [],
270
+ actingUnitId: me?.acting_unit_id ?? null,
271
+ })
272
+ if (this._session.viewer && this._session.viewer.uuid !== viewer.uuid) this.forgetViewer()
273
+ return this.setSession({ status: 'authenticated', viewer, error: null })
274
+ }
275
+
276
+ _sessionLost() {
277
+ this.forgetViewer()
278
+ this._challenge = null
279
+ return this.setSession(ANONYMOUS)
280
+ }
281
+
282
+ /**
283
+ * Sign in. The credentials object is handed to the backend as the request
284
+ * body, unchanged — this package does not decide its field names.
285
+ *
286
+ * @param {object} credentials
287
+ * @returns {Promise<{ ok: boolean, viewer?: object|null, challenge?: { kind: 'totp' } }>}
288
+ * `ok: false` with a `challenge` when a second factor is required — finish
289
+ * with `completeChallenge(code)`. A refused credential throws (`kind: 'auth'`).
290
+ */
291
+ async signIn(credentials) {
292
+ const body = await this.request('POST', '/auth/login', { body: credentials, onUnauthorized: 'ignore' })
293
+ if (body?.status === 'totp_required') {
294
+ this._challenge = body.challenge_token ?? null
295
+ return { ok: false, challenge: { kind: 'totp' } }
296
+ }
297
+ this._challenge = null
298
+ const session = await this._probe()
299
+ return { ok: session.status === 'authenticated', viewer: session.viewer }
300
+ }
301
+
302
+ /**
303
+ * Finish a sign-in that asked for a second factor.
304
+ *
305
+ * @param {string} code
306
+ * @returns {Promise<{ ok: boolean, viewer: object|null }>}
307
+ */
308
+ async completeChallenge(code) {
309
+ if (!this._challenge) {
310
+ throw new ApiError({ status: 0, title: 'No Challenge', detail: 'no sign-in challenge is pending', kind: 'invalid' })
311
+ }
312
+ await this.request('POST', '/auth/login/challenge', {
313
+ body: { challenge_token: this._challenge, code },
314
+ onUnauthorized: 'ignore',
315
+ })
316
+ this._challenge = null
317
+ const session = await this._probe()
318
+ return { ok: session.status === 'authenticated', viewer: session.viewer }
319
+ }
320
+
321
+ /**
322
+ * Sign out. The session turns anonymous locally whatever the backend
323
+ * answers — the viewer asked to leave — and the viewer's entries leave the
324
+ * cache.
325
+ */
326
+ async signOut() {
327
+ try {
328
+ await this.request('POST', '/auth/logout', { onUnauthorized: 'ignore' })
329
+ } finally {
330
+ this._sessionLost()
331
+ }
332
+ }
333
+
334
+ /** Sign up. `202` semantics: the account is inert until verified. */
335
+ signUp(fields) {
336
+ return this.request('POST', '/auth/register', { body: fields, onUnauthorized: 'ignore' })
337
+ }
338
+
339
+ /** Ask for a password reset. The backend answers `202` whether or not the account exists. */
340
+ requestPasswordReset(fields) {
341
+ return this.request('POST', '/auth/reset/request', { body: fields, onUnauthorized: 'ignore' })
342
+ }
343
+
344
+ /** Confirm a password reset with the token the viewer received. */
345
+ confirmPasswordReset(fields) {
346
+ return this.request('POST', '/auth/reset/confirm', { body: fields, onUnauthorized: 'ignore' })
347
+ }
348
+
349
+ // ── The cache ─────────────────────────────────────────────────────────────
350
+
351
+ /**
352
+ * A cache key scoped to the current viewer, derived the way every other key
353
+ * in the site's `DataStore` is — so kit's `useCacheEntry` can observe it
354
+ * given the same spec. A viewer change changes every key, so mounted hooks
355
+ * refetch by themselves.
356
+ *
357
+ * @param {object} spec - `{ endpoint, schema, … }`
358
+ * @returns {string}
359
+ */
360
+ cacheKey(spec) {
361
+ return deriveCacheKey({ ...spec, endpoint: `api:${this.viewerId}:${spec.endpoint ?? ''}` })
362
+ }
363
+
364
+ /** Note a key this client wrote, so sign-out can remove it. */
365
+ remember(key) {
366
+ this._keys.add(key)
367
+ }
368
+
369
+ /** Remove every entry written for the current viewer. */
370
+ forgetViewer() {
371
+ const store = this.website?.dataStore
372
+ for (const key of this._keys) {
373
+ store?.delete(key)
374
+ this._inflight.delete(key)
375
+ }
376
+ this._keys.clear()
377
+ }
378
+
379
+ /**
380
+ * Read through the cache: a hit answers at once, a miss runs `run` once for
381
+ * every concurrent caller and writes what it returns.
382
+ *
383
+ * @param {string} key
384
+ * @param {() => Promise<*>} run
385
+ * @returns {Promise<*>}
386
+ */
387
+ load(key, run) {
388
+ const store = this.website?.dataStore
389
+ if (store?.has(key)) return Promise.resolve(store.get(key).data)
390
+ if (this._inflight.has(key)) return this._inflight.get(key)
391
+ const pending = run()
392
+ .then((data) => {
393
+ store?.set(key, { data })
394
+ this.remember(key)
395
+ return data
396
+ })
397
+ .finally(() => {
398
+ this._inflight.delete(key)
399
+ })
400
+ this._inflight.set(key, pending)
401
+ return pending
402
+ }
403
+
404
+ // ── Entities ──────────────────────────────────────────────────────────────
405
+
406
+ /**
407
+ * Read one entity by id — through a container the viewer holds an
408
+ * entitlement on, when `via` names one.
409
+ *
410
+ * `absent` is one word for not-found-and-not-permitted, by the backend's
411
+ * design; a component renders its enrol or paywall on it and never says
412
+ * "deleted". Any other refusal throws.
413
+ *
414
+ * @param {object} args
415
+ * @param {string} args.schema - the entity's Model, e.g. `@/lesson`
416
+ * @param {string} args.uuid
417
+ * @param {string} [args.via] - the granting container's uuid
418
+ * @param {AbortSignal} [args.signal]
419
+ * @returns {Promise<{ status: 'ready'|'absent', entity: object|null }>}
420
+ */
421
+ async readEntity({ schema, uuid, via, signal } = {}) {
422
+ if (!uuid) throw new ApiError({ status: 0, title: 'No Entity', detail: 'readEntity needs a uuid', kind: 'invalid' })
423
+ try {
424
+ const entity = await this.request('GET', `/entities/${encodeURIComponent(uuid)}`, {
425
+ query: { model: schema, via, ...this._localeQuery() },
426
+ signal,
427
+ })
428
+ return { status: 'ready', entity }
429
+ } catch (err) {
430
+ if (err instanceof ApiError && err.kind === 'absent') return { status: 'absent', entity: null }
431
+ throw err
432
+ }
433
+ }
434
+ }
435
+
436
+ // Reached only on a `@uniweb/core` older than the `api` slot, where the sealed
437
+ // singleton refuses the assignment. Keyed by the singleton so one page still
438
+ // gets one client per copy of this package — correct on a page with one
439
+ // foundation, and the reason to update core on one with more.
440
+ const fallback = new WeakMap()
441
+
442
+ /**
443
+ * The client for this page — created on first use, parked on `uniweb.api`, and
444
+ * adopted by every later copy of this package. Returns `null` when no runtime
445
+ * is present, which every caller treats as "no backend".
446
+ *
447
+ * @returns {ApiClient|null}
448
+ */
449
+ export function getClient() {
450
+ const uniweb = getUniweb()
451
+ if (!uniweb) return null
452
+ if (uniweb.api) return uniweb.api
453
+ const held = fallback.get(uniweb)
454
+ if (held) return held
455
+
456
+ const client = new ApiClient(uniweb)
457
+ try {
458
+ uniweb.api = client
459
+ } catch {
460
+ fallback.set(uniweb, client)
461
+ if (typeof console !== 'undefined') {
462
+ console.warn(
463
+ '@uniweb/api: this @uniweb/core has no `api` slot; update core so one client is shared per page.',
464
+ )
465
+ }
466
+ }
467
+ return client
468
+ }
469
+
470
+ // ── The functions — the same client, outside React ───────────────────────────
471
+
472
+ function required() {
473
+ const client = getClient()
474
+ if (!client) throw ApiError.disabled()
475
+ return client
476
+ }
477
+
478
+ /** Settle the session once; resolves to the snapshot. */
479
+ export const probeSession = () => required().ensureSession()
480
+ /** @see ApiClient#signIn */
481
+ export const signIn = (credentials) => required().signIn(credentials)
482
+ /** @see ApiClient#completeChallenge */
483
+ export const completeChallenge = (code) => required().completeChallenge(code)
484
+ /** @see ApiClient#signOut */
485
+ export const signOut = () => required().signOut()
486
+ /** @see ApiClient#signUp */
487
+ export const signUp = (fields) => required().signUp(fields)
488
+ /** @see ApiClient#requestPasswordReset */
489
+ export const requestPasswordReset = (fields) => required().requestPasswordReset(fields)
490
+ /** @see ApiClient#confirmPasswordReset */
491
+ export const confirmPasswordReset = (fields) => required().confirmPasswordReset(fields)
492
+ /** @see ApiClient#readEntity */
493
+ export const readEntity = (args) => required().readEntity(args)
494
+
495
+ export { ApiError, kindOf } from './errors.js'
496
+ export { Ledger } from './ledger.js'
@@ -0,0 +1,22 @@
1
+ import { useSession } from '../hooks/useSession.js'
2
+
3
+ /**
4
+ * Headless gates — control flow, not screens. Each renders its children in
5
+ * one session state and `fallback` (default: nothing) otherwise. While the
6
+ * session is still `loading`, both render the fallback, so a page never
7
+ * flashes a sign-in affordance at a viewer who is signed in.
8
+ *
9
+ * ```jsx
10
+ * <SignedIn fallback={<SignInPrompt />}><Roster /></SignedIn>
11
+ * <SignedOut><Link href="/join">Join</Link></SignedOut>
12
+ * ```
13
+ */
14
+ export function SignedIn({ children, fallback = null }) {
15
+ const { status } = useSession()
16
+ return status === 'authenticated' ? children : fallback
17
+ }
18
+
19
+ export function SignedOut({ children, fallback = null }) {
20
+ const { status } = useSession()
21
+ return status === 'anonymous' ? children : fallback
22
+ }
package/src/errors.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Errors — one class, branched by `kind`.
3
+ *
4
+ * The backend answers a refusal with problem-JSON: `{ status, title, detail,
5
+ * …extensions }`. `title` is its stable discriminator and `detail` is prose that
6
+ * changes; this module is the only reader of `title`, and a component branches
7
+ * on `kind`.
8
+ *
9
+ * What each kind means to a component:
10
+ *
11
+ * auth not signed in, or the credential is dead → offer sign-in
12
+ * absent nothing here for you — not found OR not permitted, which the
13
+ * backend keeps indistinguishable on purpose. Never say "deleted"
14
+ * forbidden you may see it, not do this to it → a capability message
15
+ * invalid a malformed request — a bug in the caller, raised before auth
16
+ * conflict a stale concurrency token; `extensions.current_updated_at`
17
+ * carries the item's current one
18
+ * csrf the mutation lacked the header this package always sends —
19
+ * a bug, not a state to handle
20
+ * step-up the credential must be re-proven for this mutation
21
+ * rate-limited `retryAfter` says when to try again
22
+ * unavailable the backend could not be reached, or failed
23
+ * disabled this site declares no backend at all
24
+ * unknown none of the above
25
+ */
26
+
27
+ const TITLE_KINDS = {
28
+ 'CSRF Header Required': 'csrf',
29
+ 'Step-Up Required': 'step-up',
30
+ }
31
+
32
+ /**
33
+ * The kind for a refusal. Title first — the two titles that refine a `403` —
34
+ * then the status.
35
+ *
36
+ * @param {number} status
37
+ * @param {string} [title]
38
+ * @returns {string}
39
+ */
40
+ export function kindOf(status, title) {
41
+ if (title && TITLE_KINDS[title]) return TITLE_KINDS[title]
42
+ if (status === 401) return 'auth'
43
+ if (status === 403) return 'forbidden'
44
+ if (status === 404) return 'absent'
45
+ if (status === 400 || status === 422) return 'invalid'
46
+ if (status === 409) return 'conflict'
47
+ if (status === 429) return 'rate-limited'
48
+ if (status === 0 || status >= 500) return 'unavailable'
49
+ return 'unknown'
50
+ }
51
+
52
+ export class ApiError extends Error {
53
+ /**
54
+ * @param {object} fields
55
+ * @param {number} [fields.status]
56
+ * @param {string} [fields.title]
57
+ * @param {string} [fields.detail]
58
+ * @param {object} [fields.extensions] - every problem-JSON key beyond the three above
59
+ * @param {string} [fields.kind] - derived from status and title when omitted
60
+ * @param {Error} [fields.cause]
61
+ */
62
+ constructor({ status = 0, title = '', detail = '', extensions = {}, kind, cause } = {}) {
63
+ super(detail || title || `HTTP ${status}`, cause ? { cause } : undefined)
64
+ this.name = 'ApiError'
65
+ this.status = status
66
+ this.title = title
67
+ this.detail = detail
68
+ this.extensions = extensions
69
+ this.kind = kind ?? kindOf(status, title)
70
+ this.retryAfter =
71
+ typeof extensions.retry_after_seconds === 'number' ? extensions.retry_after_seconds : null
72
+ }
73
+
74
+ /**
75
+ * From a non-2xx response and its parsed body. A body that is not
76
+ * problem-JSON still yields a usable error: the status decides the kind.
77
+ *
78
+ * @param {{ status: number, statusText?: string }} res
79
+ * @param {*} payload - the parsed body, or null
80
+ */
81
+ static fromResponse(res, payload) {
82
+ const p = payload && typeof payload === 'object' ? payload : {}
83
+ const { status: _ignored, title, detail, ...extensions } = p
84
+ return new ApiError({
85
+ status: res.status,
86
+ title: typeof title === 'string' ? title : res.statusText || '',
87
+ detail: typeof detail === 'string' ? detail : '',
88
+ extensions,
89
+ })
90
+ }
91
+
92
+ /** The request did not complete — no response to read. */
93
+ static network(cause) {
94
+ return new ApiError({
95
+ status: 0,
96
+ title: 'Network',
97
+ detail: cause?.message || 'the request did not complete',
98
+ kind: 'unavailable',
99
+ cause,
100
+ })
101
+ }
102
+
103
+ /** The site declares no backend; nothing was attempted. */
104
+ static disabled() {
105
+ return new ApiError({
106
+ status: 0,
107
+ title: 'No Backend',
108
+ detail: 'this site declares no backend',
109
+ kind: 'disabled',
110
+ })
111
+ }
112
+ }
@@ -0,0 +1,57 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ /**
4
+ * The `idle → submitting → success | error` lifecycle every action hook
5
+ * shares — the shape `useFormSubmit` gave kit's forms, applied to a call on the
6
+ * client. Not exported from the package; the named hooks are.
7
+ *
8
+ * `run` rejects as the action does, so a caller may `await` it and branch; the
9
+ * state carries the same outcome for the render path. Results that land after
10
+ * unmount are dropped.
11
+ *
12
+ * @param {(...args: any[]) => Promise<any>} action
13
+ * @returns {{ run: Function, status: string, error: Error|null, response: any, reset: Function }}
14
+ */
15
+ export function useAction(action) {
16
+ const [status, setStatus] = useState('idle')
17
+ const [error, setError] = useState(null)
18
+ const [response, setResponse] = useState(null)
19
+
20
+ const live = useRef(true)
21
+ useEffect(() => {
22
+ live.current = true
23
+ return () => {
24
+ live.current = false
25
+ }
26
+ }, [])
27
+
28
+ const actionRef = useRef(action)
29
+ actionRef.current = action
30
+
31
+ const run = useCallback(async (...args) => {
32
+ setStatus('submitting')
33
+ setError(null)
34
+ try {
35
+ const result = await actionRef.current(...args)
36
+ if (live.current) {
37
+ setResponse(result)
38
+ setStatus('success')
39
+ }
40
+ return result
41
+ } catch (err) {
42
+ if (live.current) {
43
+ setError(err)
44
+ setStatus('error')
45
+ }
46
+ throw err
47
+ }
48
+ }, [])
49
+
50
+ const reset = useCallback(() => {
51
+ setStatus('idle')
52
+ setError(null)
53
+ setResponse(null)
54
+ }, [])
55
+
56
+ return { run, status, error, response, reset }
57
+ }