@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.
- package/LICENSE +21 -0
- package/package.json +168 -4
- package/src/action-server.js +41 -0
- package/src/action.d.ts +68 -0
- package/src/action.js +167 -0
- package/src/bootstrap.js +153 -0
- package/src/channel.d.ts +46 -0
- package/src/channel.js +132 -0
- package/src/client-modules.d.ts +8 -0
- package/src/client-modules.js +88 -0
- package/src/client.d.ts +36 -0
- package/src/client.js +42 -0
- package/src/compose-route.d.ts +33 -0
- package/src/compose-route.js +107 -0
- package/src/csrf.d.ts +11 -0
- package/src/csrf.js +49 -0
- package/src/deferred-boundary.d.ts +61 -0
- package/src/deferred-boundary.js +646 -0
- package/src/deferred-groups.d.ts +24 -0
- package/src/deferred-groups.js +198 -0
- package/src/deferred-revalidation.d.ts +37 -0
- package/src/deferred-revalidation.js +108 -0
- package/src/deferred-triggers.d.ts +26 -0
- package/src/deferred-triggers.js +172 -0
- package/src/deferred.d.ts +24 -0
- package/src/deferred.jsx +100 -0
- package/src/document-load.js +95 -0
- package/src/endpoint.d.ts +87 -0
- package/src/endpoint.js +304 -0
- package/src/form-data.js +219 -0
- package/src/form.d.ts +177 -0
- package/src/form.js +615 -0
- package/src/forms.d.ts +4 -0
- package/src/forms.js +10 -0
- package/src/fragment-client.d.ts +70 -0
- package/src/fragment-client.js +510 -0
- package/src/head.d.ts +85 -0
- package/src/head.js +360 -0
- package/src/inbox-view.d.ts +17 -0
- package/src/inbox-view.js +105 -0
- package/src/inbox.d.ts +108 -0
- package/src/inbox.js +293 -0
- package/src/index.combined.d.ts +31 -0
- package/src/index.d.ts +5 -0
- package/src/index.js +29 -0
- package/src/index.server.d.ts +4 -0
- package/src/index.server.js +8 -0
- package/src/infinite-scroll.d.ts +108 -0
- package/src/infinite-scroll.js +715 -0
- package/src/layout-props.d.ts +30 -0
- package/src/layout-props.js +143 -0
- package/src/link-click.d.ts +3 -0
- package/src/link-click.js +68 -0
- package/src/link.d.ts +63 -0
- package/src/link.js +274 -0
- package/src/live.d.ts +65 -0
- package/src/live.js +977 -0
- package/src/merge-slot.d.ts +41 -0
- package/src/merge-slot.js +253 -0
- package/src/merge.d.ts +111 -0
- package/src/merge.js +149 -0
- package/src/once-slot.d.ts +14 -0
- package/src/once-slot.js +75 -0
- package/src/once-store.d.ts +26 -0
- package/src/once-store.js +211 -0
- package/src/once.d.ts +44 -0
- package/src/once.js +65 -0
- package/src/optimistic.d.ts +14 -0
- package/src/optimistic.js +159 -0
- package/src/optional.d.ts +10 -0
- package/src/optional.js +31 -0
- package/src/page-root.d.ts +56 -0
- package/src/page-root.js +455 -0
- package/src/payload.d.ts +15 -0
- package/src/payload.js +101 -0
- package/src/poll.d.ts +55 -0
- package/src/poll.js +224 -0
- package/src/precognition.js +190 -0
- package/src/prefetch-cache.d.ts +46 -0
- package/src/prefetch-cache.js +256 -0
- package/src/progress.d.ts +38 -0
- package/src/progress.js +243 -0
- package/src/remember-store.d.ts +50 -0
- package/src/remember-store.js +486 -0
- package/src/rescue.d.ts +10 -0
- package/src/rescue.js +30 -0
- package/src/resumable.js +822 -0
- package/src/resume-store.js +364 -0
- package/src/route.d.ts +50 -0
- package/src/route.js +227 -0
- package/src/router-hooks.js +234 -0
- package/src/router.d.ts +586 -0
- package/src/router.js +3555 -0
- package/src/segment.d.ts +36 -0
- package/src/segment.js +116 -0
- package/src/server.d.ts +71 -0
- package/src/server.js +242 -0
- package/src/submit.js +333 -0
- package/src/telemetry.d.ts +71 -0
- package/src/telemetry.js +851 -0
- package/src/testing.d.ts +89 -0
- package/src/testing.js +634 -0
- package/src/transfer.d.ts +48 -0
- package/src/transfer.js +135 -0
- package/src/transport.js +390 -0
- package/src/upload.d.ts +342 -0
- package/src/upload.js +1240 -0
- package/src/use-channel.d.ts +21 -0
- package/src/use-channel.js +51 -0
- package/src/use-form.d.ts +86 -0
- package/src/use-form.js +432 -0
- package/src/use-http.d.ts +56 -0
- package/src/use-http.js +248 -0
- package/src/use-inbox.d.ts +41 -0
- package/src/use-inbox.js +201 -0
- package/src/use-remember.d.ts +10 -0
- package/src/use-remember.js +132 -0
- package/src/when-mounted.d.ts +15 -0
- package/src/when-mounted.js +27 -0
- package/README.md +0 -3
package/src/transfer.js
ADDED
|
@@ -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
|
+
}
|
package/src/transport.js
ADDED
|
@@ -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
|
+
}
|