@tinvar/react 0.0.0-stage → 0.4.1

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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +168 -4
  3. package/src/action-server.js +41 -0
  4. package/src/action.d.ts +68 -0
  5. package/src/action.js +167 -0
  6. package/src/bootstrap.js +153 -0
  7. package/src/channel.d.ts +46 -0
  8. package/src/channel.js +132 -0
  9. package/src/client-modules.d.ts +8 -0
  10. package/src/client-modules.js +88 -0
  11. package/src/client.d.ts +36 -0
  12. package/src/client.js +42 -0
  13. package/src/compose-route.d.ts +33 -0
  14. package/src/compose-route.js +107 -0
  15. package/src/csrf.d.ts +11 -0
  16. package/src/csrf.js +49 -0
  17. package/src/deferred-boundary.d.ts +61 -0
  18. package/src/deferred-boundary.js +646 -0
  19. package/src/deferred-groups.d.ts +24 -0
  20. package/src/deferred-groups.js +198 -0
  21. package/src/deferred-revalidation.d.ts +37 -0
  22. package/src/deferred-revalidation.js +108 -0
  23. package/src/deferred-triggers.d.ts +26 -0
  24. package/src/deferred-triggers.js +172 -0
  25. package/src/deferred.d.ts +24 -0
  26. package/src/deferred.jsx +100 -0
  27. package/src/document-load.js +95 -0
  28. package/src/endpoint.d.ts +87 -0
  29. package/src/endpoint.js +304 -0
  30. package/src/form-data.js +219 -0
  31. package/src/form.d.ts +177 -0
  32. package/src/form.js +615 -0
  33. package/src/forms.d.ts +4 -0
  34. package/src/forms.js +10 -0
  35. package/src/fragment-client.d.ts +70 -0
  36. package/src/fragment-client.js +510 -0
  37. package/src/head.d.ts +85 -0
  38. package/src/head.js +360 -0
  39. package/src/inbox-view.d.ts +17 -0
  40. package/src/inbox-view.js +105 -0
  41. package/src/inbox.d.ts +108 -0
  42. package/src/inbox.js +293 -0
  43. package/src/index.combined.d.ts +31 -0
  44. package/src/index.d.ts +5 -0
  45. package/src/index.js +29 -0
  46. package/src/index.server.d.ts +4 -0
  47. package/src/index.server.js +8 -0
  48. package/src/infinite-scroll.d.ts +108 -0
  49. package/src/infinite-scroll.js +715 -0
  50. package/src/layout-props.d.ts +30 -0
  51. package/src/layout-props.js +143 -0
  52. package/src/link-click.d.ts +3 -0
  53. package/src/link-click.js +68 -0
  54. package/src/link.d.ts +63 -0
  55. package/src/link.js +274 -0
  56. package/src/live.d.ts +65 -0
  57. package/src/live.js +977 -0
  58. package/src/merge-slot.d.ts +41 -0
  59. package/src/merge-slot.js +253 -0
  60. package/src/merge.d.ts +111 -0
  61. package/src/merge.js +149 -0
  62. package/src/once-slot.d.ts +14 -0
  63. package/src/once-slot.js +75 -0
  64. package/src/once-store.d.ts +26 -0
  65. package/src/once-store.js +211 -0
  66. package/src/once.d.ts +44 -0
  67. package/src/once.js +65 -0
  68. package/src/optimistic.d.ts +14 -0
  69. package/src/optimistic.js +159 -0
  70. package/src/optional.d.ts +10 -0
  71. package/src/optional.js +31 -0
  72. package/src/page-root.d.ts +56 -0
  73. package/src/page-root.js +455 -0
  74. package/src/payload.d.ts +15 -0
  75. package/src/payload.js +101 -0
  76. package/src/poll.d.ts +55 -0
  77. package/src/poll.js +224 -0
  78. package/src/precognition.js +190 -0
  79. package/src/prefetch-cache.d.ts +46 -0
  80. package/src/prefetch-cache.js +256 -0
  81. package/src/progress.d.ts +38 -0
  82. package/src/progress.js +243 -0
  83. package/src/remember-store.d.ts +50 -0
  84. package/src/remember-store.js +486 -0
  85. package/src/rescue.d.ts +10 -0
  86. package/src/rescue.js +30 -0
  87. package/src/resumable.js +822 -0
  88. package/src/resume-store.js +364 -0
  89. package/src/route.d.ts +50 -0
  90. package/src/route.js +227 -0
  91. package/src/router-hooks.js +234 -0
  92. package/src/router.d.ts +586 -0
  93. package/src/router.js +3555 -0
  94. package/src/segment.d.ts +36 -0
  95. package/src/segment.js +116 -0
  96. package/src/server.d.ts +71 -0
  97. package/src/server.js +242 -0
  98. package/src/submit.js +333 -0
  99. package/src/telemetry.d.ts +71 -0
  100. package/src/telemetry.js +851 -0
  101. package/src/testing.d.ts +89 -0
  102. package/src/testing.js +634 -0
  103. package/src/transfer.d.ts +48 -0
  104. package/src/transfer.js +135 -0
  105. package/src/transport.js +390 -0
  106. package/src/upload.d.ts +342 -0
  107. package/src/upload.js +1240 -0
  108. package/src/use-channel.d.ts +21 -0
  109. package/src/use-channel.js +51 -0
  110. package/src/use-form.d.ts +86 -0
  111. package/src/use-form.js +432 -0
  112. package/src/use-http.d.ts +56 -0
  113. package/src/use-http.js +248 -0
  114. package/src/use-inbox.d.ts +41 -0
  115. package/src/use-inbox.js +201 -0
  116. package/src/use-remember.d.ts +10 -0
  117. package/src/use-remember.js +132 -0
  118. package/src/when-mounted.d.ts +15 -0
  119. package/src/when-mounted.js +27 -0
  120. package/README.md +0 -3
@@ -0,0 +1,135 @@
1
+ // Starting the application's exports and imports, by name, from the maps
2
+ // codegen writes (ADR 0070).
3
+ //
4
+ // `transfer.export('orders', { format: 'csv' })` POSTs to /_export/orders;
5
+ // `transfer.import('stock', file)` POSTs the file, as multipart, to
6
+ // /_import/stock. Each answers with the record's id once the server has
7
+ // queued it -- the work itself is a job, and its progress is a page's to
8
+ // show, from the loader that reads the user's transfers. Both carry the
9
+ // session's CSRF token, read from the cookie as the request is sent, as
10
+ // every action does; the URL is a path on this origin and nothing else, so
11
+ // the token never goes anywhere else.
12
+ //
13
+ // No React and nothing from Node.
14
+
15
+ import { CSRF_HEADER, csrfToken } from './csrf.js';
16
+
17
+ /** A start the server refused: its status, its error code, and a 422's field errors. */
18
+ export class TransferError extends Error {
19
+ /**
20
+ * @param {string} name
21
+ * @param {number} status
22
+ * @param {unknown} body
23
+ */
24
+ constructor(name, status, body) {
25
+ const error =
26
+ body !== null &&
27
+ typeof body === 'object' &&
28
+ 'error' in body &&
29
+ body.error !== null &&
30
+ typeof body.error === 'object'
31
+ ? /** @type {{ code?: unknown, message?: unknown }} */ (body.error)
32
+ : {};
33
+ super(
34
+ typeof error.message === 'string'
35
+ ? error.message
36
+ : `[tinvar] '${name}' could not be started (${status}).`,
37
+ );
38
+ this.name = 'TransferError';
39
+ this.transfer = name;
40
+ this.status = status;
41
+ /** @type {string | null} */
42
+ this.code = typeof error.code === 'string' ? error.code : null;
43
+ /** @type {Record<string, string[]>} */
44
+ this.errors =
45
+ status === 422 && body !== null && typeof body === 'object' && 'errors' in body
46
+ ? /** @type {Record<string, string[]>} */ (body.errors)
47
+ : {};
48
+ }
49
+ }
50
+
51
+ const NAME = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/;
52
+
53
+ /**
54
+ * @param {string} prefix
55
+ * @param {string} name
56
+ */
57
+ function pathOf(prefix, name) {
58
+ if (!NAME.test(name)) throw new Error(`[tinvar] '${name}' is not an export or import name.`);
59
+ return `${prefix}${name}`;
60
+ }
61
+
62
+ /**
63
+ * @param {string} name
64
+ * @param {Response} response
65
+ * @returns {Promise<{ transfer: string, status: string }>}
66
+ */
67
+ async function answer(name, response) {
68
+ const text = await response.text();
69
+ let body = /** @type {unknown} */ (null);
70
+ try {
71
+ body = text === '' ? null : JSON.parse(text);
72
+ } catch {
73
+ body = text;
74
+ }
75
+ if (!response.ok) throw new TransferError(name, response.status, body);
76
+
77
+ return /** @type {{ transfer: string, status: string }} */ (body);
78
+ }
79
+
80
+ /** @param {Record<string, string>} headers */
81
+ function withToken(headers) {
82
+ const token = csrfToken();
83
+ if (token !== null) headers[CSRF_HEADER] = token;
84
+ return headers;
85
+ }
86
+
87
+ export function transfers() {
88
+ return {
89
+ /**
90
+ * @param {string} name
91
+ * @param {{ args?: Record<string, unknown>, format?: string, signal?: AbortSignal, fetch?: typeof fetch }} [options]
92
+ */
93
+ async export(name, options = {}) {
94
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
95
+ const response = await doFetch(pathOf('/_export/', name), {
96
+ method: 'POST',
97
+ headers: withToken({ accept: 'application/json', 'content-type': 'application/json' }),
98
+ body: JSON.stringify({
99
+ args: options.args ?? {},
100
+ ...(options.format === undefined ? {} : { format: options.format }),
101
+ }),
102
+ signal: options.signal,
103
+ credentials: 'same-origin',
104
+ redirect: 'manual',
105
+ });
106
+
107
+ return answer(name, response);
108
+ },
109
+
110
+ /**
111
+ * @param {string} name
112
+ * @param {Blob} file
113
+ * @param {{ args?: Record<string, string | number | boolean | null>, signal?: AbortSignal, fetch?: typeof fetch }} [options]
114
+ */
115
+ async import(name, file, options = {}) {
116
+ const form = new FormData();
117
+ for (const [key, value] of Object.entries(options.args ?? {})) {
118
+ if (value !== null && value !== undefined) form.append(`args[${key}]`, String(value));
119
+ }
120
+ form.append('file', file, file instanceof File ? file.name : 'import.csv');
121
+
122
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
123
+ const response = await doFetch(pathOf('/_import/', name), {
124
+ method: 'POST',
125
+ headers: withToken({ accept: 'application/json' }),
126
+ body: form,
127
+ signal: options.signal,
128
+ credentials: 'same-origin',
129
+ redirect: 'manual',
130
+ });
131
+
132
+ return answer(name, response);
133
+ },
134
+ };
135
+ }
@@ -0,0 +1,390 @@
1
+ // One POST to an action or query endpoint, and what came back, classified.
2
+ //
3
+ // tinvar-spec.md, "Forms and mutations": "Keep three error channels: 422 ->
4
+ // errors, other statuses -> onHttpException, connectivity -> onNetworkError."
5
+ // Everything that sends to /_action or /_query -- <Form>, useForm, useHttp,
6
+ // Precognition, a generated action called directly -- comes through here, so
7
+ // the three channels are told apart in one place and mean the same thing to
8
+ // each of them.
9
+ //
10
+ // Never throws for an outcome. A 500 and a dropped connection are answers the
11
+ // caller has to route somewhere, not exceptions to be caught at a distance.
12
+
13
+ import { CSRF_HEADER, csrfToken } from './csrf.js';
14
+ import { hasFiles, toFormData } from './form-data.js';
15
+
16
+ /**
17
+ * @typedef {{ loaded: number, total: number | undefined, percentage: number | undefined }} Progress
18
+ *
19
+ * @typedef {(
20
+ * | { type: 'success', status: number, body: any, headers: Headers }
21
+ * | { type: 'redirect', location: string, headers: Headers }
22
+ * | { type: 'location', location: string, headers: Headers }
23
+ * | { type: 'validation', status: 422, errors: Record<string, string[]>, body: any, headers: Headers }
24
+ * | { type: 'http', status: number, body: any, headers: Headers, code: string | null, reference: string | null, retryAfter: number | null }
25
+ * | { type: 'expired', headers: Headers }
26
+ * | { type: 'network', error: unknown }
27
+ * | { type: 'cancelled' }
28
+ * )} Outcome
29
+ *
30
+ * @typedef {{
31
+ * url: string,
32
+ * data?: Record<string, unknown>,
33
+ * body?: Blob,
34
+ * headers?: Record<string, string>,
35
+ * signal?: AbortSignal,
36
+ * onUploadProgress?: (progress: Progress) => void,
37
+ * stallTimeout?: number,
38
+ * fetch?: typeof fetch,
39
+ * }} SendOptions
40
+ */
41
+
42
+ /**
43
+ * Sends one request.
44
+ *
45
+ * JSON unless the data holds a file, then multipart -- "payloads with files
46
+ * become multipart automatically, with upload progress" -- which goes by
47
+ * XMLHttpRequest, since fetch reports nothing of an upload.
48
+ *
49
+ * Or `body`, a Blob sent as it is -- a chunk of a resumable upload, under
50
+ * the headers given -- by XMLHttpRequest too, for its progress.
51
+ *
52
+ * `stallTimeout`, for a request sent by XMLHttpRequest, gives it up as a
53
+ * network failure once it has sent nothing for that many milliseconds: a
54
+ * connection that hangs without ever failing would otherwise wait forever.
55
+ * Only while the body goes; the answer is waited for as long as it takes.
56
+ *
57
+ * `RSC: 1` says it is the client router asking. A redirect is then answered
58
+ * 409 + X-Redirect, as a navigation's is, rather than a 303 whose Location
59
+ * the fetch below -- redirect: 'manual', so that nothing is followed off-site
60
+ * with the CSRF header -- could never read.
61
+ *
62
+ * @param {SendOptions} options
63
+ * @returns {Promise<Outcome>}
64
+ */
65
+ export async function send({
66
+ url,
67
+ data = {},
68
+ body: blob,
69
+ headers = {},
70
+ signal,
71
+ onUploadProgress,
72
+ stallTimeout,
73
+ fetch: doFetch,
74
+ }) {
75
+ const multipart = blob === undefined && hasFiles(data);
76
+
77
+ /** @param {string | null} token */
78
+ const attempt = (token) => {
79
+ const all = {
80
+ accept: 'application/json',
81
+ RSC: '1',
82
+ ...(token === null ? {} : { [CSRF_HEADER]: token }),
83
+ ...headers,
84
+ ...(multipart || blob !== undefined ? {} : { 'content-type': 'application/json' }),
85
+ };
86
+ if (blob !== undefined) return viaXhr(url, blob, all, signal, onUploadProgress, stallTimeout);
87
+ // Built for each attempt: a FormData or a string, either can be sent
88
+ // only once.
89
+ const body = multipart ? toFormData(data) : JSON.stringify(data);
90
+ return multipart
91
+ ? viaXhr(url, /** @type {FormData} */ (body), all, signal, onUploadProgress, stallTimeout)
92
+ : viaFetch(doFetch ?? globalThis.fetch.bind(globalThis), url, body, all, signal);
93
+ };
94
+
95
+ if (signal?.aborted) return { type: 'cancelled' };
96
+
97
+ // The token is the session's, and goes only where the session is: a URL
98
+ // an application passes that names another origin is sent the request
99
+ // without it, so nothing off-site is handed a token it could replay here.
100
+ const ours = sameOrigin(url);
101
+
102
+ /** @type {{ status: number, headers: Headers, text: string }} */
103
+ let raw;
104
+ try {
105
+ // Read from the cookie now, never kept: a sign-in rotates it.
106
+ const sent = ours ? csrfToken() : null;
107
+ raw = await attempt(sent);
108
+
109
+ // A 419 is a token the session no longer has: it expired, or rotated
110
+ // in another tab. The refusal sets the session's token as a cookie on
111
+ // its way back, so the cookie is read again and the request sent once
112
+ // more with what it holds now. Only when it holds something new: the
113
+ // same token again would be refused the same way.
114
+ if (raw.status === 419 && ours) {
115
+ const fresh = csrfToken();
116
+ if (fresh !== null && fresh !== sent) raw = await attempt(fresh);
117
+ }
118
+ } catch (error) {
119
+ if (signal?.aborted || /** @type {any} */ (error)?.name === 'AbortError') return { type: 'cancelled' };
120
+ return { type: 'network', error };
121
+ }
122
+
123
+ // Still refused. "A 419 becomes a same-origin redirect back with a 'page
124
+ // expired' flash, not an error screen" -- which the caller arranges,
125
+ // since only it knows what page it is on.
126
+ if (raw.status === 419) return { type: 'expired', headers: raw.headers };
127
+
128
+ return classify(raw);
129
+ }
130
+
131
+ /**
132
+ * Whether $url resolves to this page's own origin. A relative URL always
133
+ * does; with no page to resolve against, only a relative one does.
134
+ *
135
+ * @param {string} url
136
+ */
137
+ function sameOrigin(url) {
138
+ const here = globalThis.location?.href;
139
+ if (here === undefined) return !/^[a-z][a-z0-9+.-]*:|^\/\//i.test(url);
140
+ try {
141
+ return new URL(url, here).origin === new URL(here).origin;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * @param {typeof fetch} doFetch
149
+ * @param {string} url
150
+ * @param {BodyInit} body
151
+ * @param {Record<string, string>} headers
152
+ * @param {AbortSignal | undefined} signal
153
+ */
154
+ async function viaFetch(doFetch, url, body, headers, signal) {
155
+ const response = await doFetch(url, {
156
+ method: 'POST',
157
+ headers,
158
+ body,
159
+ credentials: 'same-origin',
160
+ redirect: 'manual',
161
+ signal,
162
+ });
163
+
164
+ // An unreadable body is still an answer; its status says what happened.
165
+ const text = await response.text().catch(() => '');
166
+ return {
167
+ status: response.status,
168
+ headers: response.headers,
169
+ text,
170
+ opaque: response.type === 'opaqueredirect',
171
+ };
172
+ }
173
+
174
+ /**
175
+ * @param {string} url
176
+ * @param {FormData | Blob} body
177
+ * @param {Record<string, string>} headers
178
+ * @param {AbortSignal | undefined} signal
179
+ * @param {((progress: Progress) => void) | undefined} onUploadProgress
180
+ * @param {number | undefined} stallTimeout
181
+ * @returns {Promise<{ status: number, headers: Headers, text: string, opaque?: boolean }>}
182
+ */
183
+ function viaXhr(url, body, headers, signal, onUploadProgress, stallTimeout) {
184
+ return new Promise((resolve, reject) => {
185
+ const xhr = new XMLHttpRequest();
186
+ xhr.open('POST', url);
187
+ xhr.withCredentials = true;
188
+ for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value);
189
+ const stall = watchStall(xhr, stallTimeout);
190
+
191
+ if (onUploadProgress && xhr.upload) {
192
+ xhr.upload.addEventListener('progress', (event) => {
193
+ const total = event.lengthComputable ? event.total : undefined;
194
+ onUploadProgress({
195
+ loaded: event.loaded,
196
+ total,
197
+ percentage: total ? Math.round((event.loaded / total) * 100) : undefined,
198
+ });
199
+ });
200
+ }
201
+
202
+ const abort = () => xhr.abort();
203
+ signal?.addEventListener('abort', abort, { once: true });
204
+
205
+ xhr.addEventListener('load', () => {
206
+ stall.stop();
207
+ signal?.removeEventListener('abort', abort);
208
+ // XHR follows a redirect itself, where the fetch above is told not
209
+ // to: a 303 from something in front of the endpoint ended at the page
210
+ // it pointed to -- a sign-in page's 200, say -- and was taken for the
211
+ // action's success when the action had never run. Told by where the
212
+ // answer came from, and answered as the fetch path answers a redirect
213
+ // it could not read.
214
+ if (redirected(xhr.responseURL, url)) {
215
+ resolve({ status: 0, headers: new Headers(), text: '', opaque: true });
216
+ return;
217
+ }
218
+ resolve({
219
+ status: xhr.status,
220
+ headers: parseHeaders(xhr.getAllResponseHeaders()),
221
+ text: xhr.responseText,
222
+ });
223
+ });
224
+ xhr.addEventListener('error', () => {
225
+ stall.stop();
226
+ signal?.removeEventListener('abort', abort);
227
+ reject(new TypeError('The request could not be sent.'));
228
+ });
229
+ xhr.addEventListener('abort', () => {
230
+ stall.stop();
231
+ signal?.removeEventListener('abort', abort);
232
+ reject(
233
+ stall.stalled()
234
+ ? new TypeError('The request sent nothing for too long.')
235
+ : Object.assign(new Error('The request was cancelled.'), { name: 'AbortError' }),
236
+ );
237
+ });
238
+
239
+ xhr.send(body);
240
+ });
241
+ }
242
+
243
+ /**
244
+ * Watches an XMLHttpRequest's upload, and aborts it once it has sent
245
+ * nothing for $ms: from the start, and from each progress event, until the
246
+ * body has gone. `stalled()` then tells that abort from a cancel, for the
247
+ * caller to answer it as a network failure. Nothing is watched without $ms.
248
+ *
249
+ * @param {XMLHttpRequest} xhr
250
+ * @param {number | undefined} ms
251
+ * @returns {{ stop: () => void, stalled: () => boolean }}
252
+ */
253
+ export function watchStall(xhr, ms) {
254
+ if (typeof ms !== 'number' || !(ms > 0)) return { stop: () => {}, stalled: () => false };
255
+ let stalled = false;
256
+ /** @type {ReturnType<typeof setTimeout> | null} */
257
+ let timer = null;
258
+ const stop = () => {
259
+ if (timer !== null) clearTimeout(timer);
260
+ timer = null;
261
+ };
262
+ const arm = () => {
263
+ stop();
264
+ timer = setTimeout(() => {
265
+ timer = null;
266
+ stalled = true;
267
+ xhr.abort();
268
+ }, ms);
269
+ };
270
+ xhr.upload?.addEventListener('progress', () => {
271
+ if (!stalled) arm();
272
+ });
273
+ // The body has gone: the answer -- a last chunk's scan, say -- takes as long as it takes.
274
+ xhr.upload?.addEventListener('load', stop);
275
+ arm();
276
+ return { stop, stalled: () => stalled };
277
+ }
278
+
279
+ /**
280
+ * Whether an XHR's answer came from somewhere other than where it was
281
+ * sent: a redirect it followed. An empty responseURL says nothing either
282
+ * way, and is taken as the answer of the URL asked.
283
+ *
284
+ * @param {string} responseUrl
285
+ * @param {string} url
286
+ */
287
+ function redirected(responseUrl, url) {
288
+ if (!responseUrl) return false;
289
+ try {
290
+ return new URL(responseUrl).href !== new URL(url, globalThis.location?.href).href;
291
+ } catch {
292
+ return true;
293
+ }
294
+ }
295
+
296
+ /** @param {string} block */
297
+ function parseHeaders(block) {
298
+ const headers = new Headers();
299
+ for (const line of block.trim().split(/[\r\n]+/)) {
300
+ const at = line.indexOf(':');
301
+ if (at > 0) {
302
+ try {
303
+ headers.append(line.slice(0, at).trim(), line.slice(at + 1).trim());
304
+ } catch {
305
+ // A header Headers refuses is one nothing here reads.
306
+ }
307
+ }
308
+ }
309
+ return headers;
310
+ }
311
+
312
+ /**
313
+ * @param {{ status: number, headers: Headers, text: string, opaque?: boolean }} raw
314
+ * @returns {Outcome}
315
+ */
316
+ function classify({ status, headers, text, opaque }) {
317
+ let body = null;
318
+ if (text !== '') {
319
+ try {
320
+ body = JSON.parse(text);
321
+ } catch {
322
+ body = null;
323
+ }
324
+ }
325
+
326
+ if (status === 409) {
327
+ const redirect = headers.get('x-redirect');
328
+ if (redirect) return { type: 'redirect', location: redirect, headers };
329
+ const location = headers.get('x-location');
330
+ if (location) return { type: 'location', location, headers };
331
+ }
332
+
333
+ // A raw redirect the fetch did not follow. The server answers 409 to an
334
+ // `RSC: 1` caller instead; a 303 here came from something in between, and
335
+ // where it pointed cannot be read. Nothing was shown as succeeding.
336
+ if (opaque || status === 0) {
337
+ return {
338
+ type: 'http',
339
+ status: 0,
340
+ body: null,
341
+ headers,
342
+ code: 'opaque_redirect',
343
+ reference: null,
344
+ retryAfter: null,
345
+ };
346
+ }
347
+
348
+ if (status >= 200 && status < 300) return { type: 'success', status, body, headers };
349
+
350
+ // "422 -> errors", every 422: a query refusing its arguments answers with
351
+ // a code and no field to put it on, and is still the input's fault rather
352
+ // than the server's -- the errors channel, with no fields in it.
353
+ if (status === 422) {
354
+ return { type: 'validation', status: 422, errors: normalizeErrors(body?.errors), body, headers };
355
+ }
356
+
357
+ const retryAfter = Number(headers.get('retry-after'));
358
+ return {
359
+ type: 'http',
360
+ status,
361
+ body,
362
+ headers,
363
+ code: typeof body?.error?.code === 'string' ? body.error.code : null,
364
+ reference: typeof body?.error?.digest === 'string' ? body.error.digest : null,
365
+ retryAfter: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : null,
366
+ };
367
+ }
368
+
369
+ /**
370
+ * Field errors as the server sends them, a list of messages per field, made
371
+ * safe to index: a PHP empty array arrives as a JSON list, and a field whose
372
+ * messages are a single string is read as a list of one.
373
+ *
374
+ * @param {unknown} errors
375
+ * @returns {Record<string, string[]>}
376
+ */
377
+ export function normalizeErrors(errors) {
378
+ /** @type {Record<string, string[]>} */
379
+ const out = {};
380
+ if (errors === null || typeof errors !== 'object' || Array.isArray(errors)) return out;
381
+ for (const [field, messages] of Object.entries(errors)) {
382
+ if (Array.isArray(messages)) {
383
+ const list = messages.filter((m) => typeof m === 'string');
384
+ if (list.length > 0) out[field] = list;
385
+ } else if (typeof messages === 'string') {
386
+ out[field] = [messages];
387
+ }
388
+ }
389
+ return out;
390
+ }