@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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// A document load the page has begun: the router sending the browser off to
|
|
2
|
+
// another origin or a full load, an action's X-Location, a sign-out.
|
|
3
|
+
//
|
|
4
|
+
// What ends the session on this client loads the page again, so nothing of
|
|
5
|
+
// the old session stays on screen: a 401 heard by the live stream's trade
|
|
6
|
+
// (live.js), by a <Deferred> boundary's fetch (deferred-boundary.js), by a
|
|
7
|
+
// form, a useHttp call or an upload (endSession() in router-hooks.js), or by
|
|
8
|
+
// the router for the page on screen -- a poll, a prefetch, a list's next
|
|
9
|
+
// page, a reload(), a page fetched in the background drawn for another
|
|
10
|
+
// session (router.js). Begun after a load was, that reload replaced it: an
|
|
11
|
+
// action that replaced the session and sent the browser on landed it back
|
|
12
|
+
// on the page it left. So a load begun is left to arrive, for this long;
|
|
13
|
+
// one that never does -- a download, a navigation stopped -- is loaded over
|
|
14
|
+
// once its time is up (reloadUnlessLoading()).
|
|
15
|
+
|
|
16
|
+
/** How long a document load begun is waited for before the page is reloaded over it, in ms. */
|
|
17
|
+
export const DOCUMENT_LOAD_MS = 10_000;
|
|
18
|
+
|
|
19
|
+
/** @type {{ url: string, at: number } | null} */
|
|
20
|
+
let begun = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The page is being replaced by a document load of $url.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} url
|
|
26
|
+
* @param {number} [at] when, in ms; now unless given
|
|
27
|
+
*/
|
|
28
|
+
export function markDocumentLoad(url, at = Date.now()) {
|
|
29
|
+
begun = { url, at };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How long, in ms, a document load begun in the last DOCUMENT_LOAD_MS is
|
|
34
|
+
* still waited for; 0 when none is.
|
|
35
|
+
*
|
|
36
|
+
* @param {number} [now]
|
|
37
|
+
* @returns {number}
|
|
38
|
+
*/
|
|
39
|
+
export function documentLoadWait(now = Date.now()) {
|
|
40
|
+
return begun === null ? 0 : Math.max(0, begun.at + DOCUMENT_LOAD_MS - now);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Forgets a load begun: the page is on screen again, restored from the back-forward cache. */
|
|
44
|
+
export function clearDocumentLoad() {
|
|
45
|
+
begun = null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Loads the page on screen again, without its fragment, for what heard the
|
|
50
|
+
* session end -- unless a document load has begun, which is the page being
|
|
51
|
+
* replaced already: an action that ended the session and sent the browser
|
|
52
|
+
* on. Loading this page over it would replace that load with this page, and
|
|
53
|
+
* land the browser back on the page it was leaving. Then the reload waits
|
|
54
|
+
* for it, and is made only if it has not come once its time is up -- or, if
|
|
55
|
+
* another was begun meanwhile, once that one's is.
|
|
56
|
+
*
|
|
57
|
+
* Only the load waits. What the session held is the caller's to clear, and
|
|
58
|
+
* at once (revokeSession()): an action sent, or a page shown, while the
|
|
59
|
+
* load is on its way must not carry it.
|
|
60
|
+
*
|
|
61
|
+
* @param {(url: string) => void} load the document load, given the page's URL
|
|
62
|
+
* @param {{ location?: { href: string } }} [win] whose page it is; the window's unless given
|
|
63
|
+
* @returns {() => void} forgets the reload, while it still waits
|
|
64
|
+
*/
|
|
65
|
+
export function reloadUnlessLoading(load, win = globalThis) {
|
|
66
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
67
|
+
let timer = null;
|
|
68
|
+
|
|
69
|
+
const reload = () => {
|
|
70
|
+
timer = null;
|
|
71
|
+
const wait = documentLoadWait();
|
|
72
|
+
if (wait > 0) {
|
|
73
|
+
timer = setTimeout(reload, wait);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const href = win.location?.href;
|
|
78
|
+
if (href === undefined) return;
|
|
79
|
+
// Without the fragment: assigning the URL the document already has,
|
|
80
|
+
// fragment and all, scrolls to it and loads nothing.
|
|
81
|
+
const url = new URL(href);
|
|
82
|
+
url.hash = '';
|
|
83
|
+
load(url.href);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
reload();
|
|
87
|
+
return () => {
|
|
88
|
+
if (timer !== null) clearTimeout(timer);
|
|
89
|
+
timer = null;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
globalThis.addEventListener?.('pageshow', (/** @type {PageTransitionEvent} */ event) => {
|
|
94
|
+
if (event.persisted) clearDocumentLoad();
|
|
95
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** What one endpoint takes and answers with, as codegen describes it. */
|
|
2
|
+
export interface EndpointShape {
|
|
3
|
+
params: object;
|
|
4
|
+
input: object;
|
|
5
|
+
returns: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Endpoint name => what it answers, where, and with what. */
|
|
9
|
+
export type EndpointTable<M> = {
|
|
10
|
+
readonly [N in keyof M]: {
|
|
11
|
+
readonly methods: readonly string[];
|
|
12
|
+
readonly segments: readonly string[];
|
|
13
|
+
/** The host pattern of one on a host of its own (ADR 0149); none for the primary host. */
|
|
14
|
+
readonly host?: string;
|
|
15
|
+
readonly returns: 'data' | 'nothing' | 'response';
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** An endpoint that answered with anything but a 2xx: its status, its body, and a 422's field errors. */
|
|
20
|
+
export declare class EndpointError extends Error {
|
|
21
|
+
readonly endpoint: string;
|
|
22
|
+
readonly status: number;
|
|
23
|
+
readonly body: unknown;
|
|
24
|
+
readonly errors: Record<string, string[]>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What `useForm().withPrecognition()` and `<Form validateAgainst>` ask
|
|
29
|
+
* instead of an action (ADR 0125): an endpoint, by `endpoint.precognitive()`.
|
|
30
|
+
*/
|
|
31
|
+
export interface PrecognitionTarget {
|
|
32
|
+
readonly endpoint?: string;
|
|
33
|
+
/** Each field's errors, none when they would do; null for an answer that says nothing about them. */
|
|
34
|
+
precognition(
|
|
35
|
+
fields: string[],
|
|
36
|
+
input: Record<string, unknown>,
|
|
37
|
+
signal: AbortSignal,
|
|
38
|
+
): Promise<Record<string, string[]> | null>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CallOptions<S extends EndpointShape> {
|
|
42
|
+
params: S['params'];
|
|
43
|
+
input?: S['input'];
|
|
44
|
+
/** One of the methods it answers; its first unless given. */
|
|
45
|
+
method?: string;
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
fetch?: typeof fetch;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface Endpoints<M extends { [N in keyof M]: EndpointShape }> {
|
|
51
|
+
/** The URL of an endpoint, with a GET's input as its query string. */
|
|
52
|
+
url<N extends keyof M & string>(name: N, params: M[N]['params'], query?: M[N]['input']): string;
|
|
53
|
+
/**
|
|
54
|
+
* Calls an endpoint: its Data parsed, nothing for a void one, or the
|
|
55
|
+
* Response for one that returns a Response. Anything but a 2xx throws an
|
|
56
|
+
* EndpointError.
|
|
57
|
+
*/
|
|
58
|
+
call<N extends keyof M & string>(name: N, options: CallOptions<M[N]>): Promise<M[N]['returns']>;
|
|
59
|
+
/**
|
|
60
|
+
* Precognition (ADR 0074): the endpoint's checks and form requests -- or
|
|
61
|
+
* only the fields in `only` -- without its body. Each field's errors;
|
|
62
|
+
* none when the input would do.
|
|
63
|
+
*/
|
|
64
|
+
validate<N extends keyof M & string>(
|
|
65
|
+
name: N,
|
|
66
|
+
options: CallOptions<M[N]> & { input?: Partial<M[N]['input']>; only?: readonly string[] },
|
|
67
|
+
): Promise<Record<string, string[]>>;
|
|
68
|
+
/**
|
|
69
|
+
* The endpoint as a form's validate() asks it (ADR 0125):
|
|
70
|
+
* `useForm(data).withPrecognition(endpoint.precognitive('racks.resize', { params: { rack } }))`,
|
|
71
|
+
* or `<Form validateAgainst={...}>`.
|
|
72
|
+
*/
|
|
73
|
+
precognitive<N extends keyof M & string>(
|
|
74
|
+
name: N,
|
|
75
|
+
options?: Omit<CallOptions<M[N]>, 'input' | 'signal'>,
|
|
76
|
+
): PrecognitionTarget;
|
|
77
|
+
/**
|
|
78
|
+
* Whether a URL is one of these endpoints', at any method: what the
|
|
79
|
+
* router is given as `endpoints`, so a link to one is left to the browser.
|
|
80
|
+
*/
|
|
81
|
+
matches(url: URL | string): boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Builds the typed `endpoint` codegen exports from its table. */
|
|
85
|
+
export declare function endpoints<M extends { [N in keyof M]: EndpointShape }>(
|
|
86
|
+
table: EndpointTable<M>,
|
|
87
|
+
): Endpoints<M>;
|
package/src/endpoint.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Calling the application's #[Get], #[Post] and #[Endpoint] routes, from the
|
|
2
|
+
// table codegen writes (ADR 0067).
|
|
3
|
+
//
|
|
4
|
+
// `endpoint.url('orders.receipt', { order })` builds a link, by the rules
|
|
5
|
+
// route() builds a page's by; `endpoint.call('products.stock', { params:
|
|
6
|
+
// { product } })` fetches one and answers with what the PHP method returns:
|
|
7
|
+
// its Data object, parsed, for one that returns Data; nothing, for one that
|
|
8
|
+
// returns void; the Response itself for one that returns a Response, which
|
|
9
|
+
// is the caller's to read -- a download, a file, a body of its own type.
|
|
10
|
+
//
|
|
11
|
+
// A method that changes something carries the session's CSRF token, read
|
|
12
|
+
// from the cookie as the request is sent, as every action does. The URL is
|
|
13
|
+
// always a path on this origin -- the table holds nothing else -- so the
|
|
14
|
+
// token never goes anywhere else. A GET's input goes in the query string,
|
|
15
|
+
// anything else's in a JSON body. No method is ever spoofed: a method is
|
|
16
|
+
// sent as itself, and PHP reads nothing that says otherwise.
|
|
17
|
+
//
|
|
18
|
+
// What a call that may write invalidated -- its answer's X-Invalidated-Tags
|
|
19
|
+
// -- is flushed from every router's prefetch cache, as an action's is.
|
|
20
|
+
//
|
|
21
|
+
// No React and nothing from Node: a server component can build a URL with
|
|
22
|
+
// it, and a client component can call one.
|
|
23
|
+
|
|
24
|
+
import { CSRF_HEADER, csrfToken } from './csrf.js';
|
|
25
|
+
import { revalidateDeferred } from './deferred-revalidation.js';
|
|
26
|
+
import { routes } from './route.js';
|
|
27
|
+
import { flushInvalidated } from './router.js';
|
|
28
|
+
|
|
29
|
+
/** An endpoint that answered with anything but a 2xx. */
|
|
30
|
+
export class EndpointError extends Error {
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} name
|
|
33
|
+
* @param {number} status
|
|
34
|
+
* @param {unknown} body
|
|
35
|
+
*/
|
|
36
|
+
constructor(name, status, body) {
|
|
37
|
+
super(`[tinvar] The endpoint '${name}' answered ${status}.`);
|
|
38
|
+
this.name = 'EndpointError';
|
|
39
|
+
this.endpoint = name;
|
|
40
|
+
this.status = status;
|
|
41
|
+
this.body = body;
|
|
42
|
+
/** @type {Record<string, string[]>} */
|
|
43
|
+
this.errors =
|
|
44
|
+
status === 422 && body !== null && typeof body === 'object' && 'errors' in body
|
|
45
|
+
? /** @type {Record<string, string[]>} */ (body.errors)
|
|
46
|
+
: {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {{ methods: readonly string[], segments: readonly string[], host?: string, returns: 'data' | 'nothing' | 'response' }} EndpointEntry
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {Record<string, EndpointEntry>} table endpoint name => what it answers, where, and with what --
|
|
56
|
+
* with the host pattern of one on a host of its own (ADR 0149), which route() writes a path on
|
|
57
|
+
* that host and an absolute URL anywhere else, as it does for a page
|
|
58
|
+
*/
|
|
59
|
+
export function endpoints(table) {
|
|
60
|
+
/** @type {Record<string, readonly string[] | { segments: readonly string[], host: string }>} */
|
|
61
|
+
const paths = {};
|
|
62
|
+
for (const [name, entry] of Object.entries(table)) {
|
|
63
|
+
paths[name] =
|
|
64
|
+
typeof entry.host === 'string' ? { segments: entry.segments, host: entry.host } : entry.segments;
|
|
65
|
+
}
|
|
66
|
+
const path = routes(paths);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string} name
|
|
70
|
+
* @returns {EndpointEntry}
|
|
71
|
+
*/
|
|
72
|
+
const entryOf = (name) => {
|
|
73
|
+
if (!Object.hasOwn(table, name)) throw new Error(`[tinvar] No endpoint is named '${name}'.`);
|
|
74
|
+
return table[name];
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} name
|
|
79
|
+
* @param {Record<string, unknown>} [params]
|
|
80
|
+
* @param {Record<string, unknown>} [query]
|
|
81
|
+
*/
|
|
82
|
+
const url = (name, params, query) => {
|
|
83
|
+
entryOf(name);
|
|
84
|
+
const base = path(name, params ?? {});
|
|
85
|
+
const search = toQuery(query);
|
|
86
|
+
return search === '' ? base : `${base}?${search}`;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} name
|
|
91
|
+
* @param {{ params?: Record<string, unknown>, input?: Record<string, unknown>, method?: string, signal?: AbortSignal, fetch?: typeof fetch }} [options]
|
|
92
|
+
*/
|
|
93
|
+
const call = async (name, options = {}) => {
|
|
94
|
+
const entry = entryOf(name);
|
|
95
|
+
const method = (options.method ?? entry.methods[0]).toUpperCase();
|
|
96
|
+
if (!entry.methods.includes(method)) {
|
|
97
|
+
throw new Error(`[tinvar] The endpoint '${name}' does not answer ${method}.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const safe = method === 'GET';
|
|
101
|
+
/** @type {Record<string, string>} */
|
|
102
|
+
const headers = { accept: 'application/json' };
|
|
103
|
+
const target = safe ? url(name, options.params, options.input) : url(name, options.params);
|
|
104
|
+
assertStaysPut(name, target, entry.segments.length);
|
|
105
|
+
/** @type {string | undefined} */
|
|
106
|
+
let body;
|
|
107
|
+
if (!safe) {
|
|
108
|
+
headers['content-type'] = 'application/json';
|
|
109
|
+
body = JSON.stringify(options.input ?? {});
|
|
110
|
+
const token = csrfToken();
|
|
111
|
+
if (token !== null) headers[CSRF_HEADER] = token;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
115
|
+
const response = await doFetch(target, {
|
|
116
|
+
method,
|
|
117
|
+
headers,
|
|
118
|
+
body,
|
|
119
|
+
signal: options.signal,
|
|
120
|
+
credentials: 'same-origin',
|
|
121
|
+
// Nothing is followed: a redirect is the caller's to read, never a
|
|
122
|
+
// second request somewhere else with the token on it.
|
|
123
|
+
redirect: 'manual',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// What a call that may write invalidated, as an action's answer says
|
|
127
|
+
// it: the pages prefetched under those tags are dropped from every
|
|
128
|
+
// router on the page, and the fragments on screen fetched again
|
|
129
|
+
// (ADR 0106). Read before anything else, whatever the status -- a
|
|
130
|
+
// redirect or an error may follow writes that are in.
|
|
131
|
+
if (!safe && flushInvalidated(response.headers).length > 0) revalidateDeferred();
|
|
132
|
+
|
|
133
|
+
if (entry.returns === 'response' && response.ok) return response;
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
const text = await response.text();
|
|
136
|
+
let parsed = /** @type {unknown} */ (text);
|
|
137
|
+
try {
|
|
138
|
+
parsed = text === '' ? null : JSON.parse(text);
|
|
139
|
+
} catch {
|
|
140
|
+
// Not JSON: the text is the body.
|
|
141
|
+
}
|
|
142
|
+
throw new EndpointError(name, response.status, parsed);
|
|
143
|
+
}
|
|
144
|
+
if (entry.returns === 'nothing') return undefined;
|
|
145
|
+
|
|
146
|
+
return response.json();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Precognition (ADR 0074): asks whether input would do, without running
|
|
151
|
+
* the endpoint -- its checks and its form requests, all of them or only
|
|
152
|
+
* the fields in `only`. Resolves with each field's errors, none when the
|
|
153
|
+
* input would do; anything but a 204 or a 422 throws an EndpointError.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} name
|
|
156
|
+
* @param {{ params?: Record<string, unknown>, input?: Record<string, unknown>, only?: readonly string[], method?: string, signal?: AbortSignal, fetch?: typeof fetch }} [options]
|
|
157
|
+
* @returns {Promise<Record<string, string[]>>}
|
|
158
|
+
*/
|
|
159
|
+
const validate = async (name, options = {}) => {
|
|
160
|
+
const entry = entryOf(name);
|
|
161
|
+
const method = (options.method ?? entry.methods[0]).toUpperCase();
|
|
162
|
+
if (!entry.methods.includes(method)) {
|
|
163
|
+
throw new Error(`[tinvar] The endpoint '${name}' does not answer ${method}.`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const safe = method === 'GET';
|
|
167
|
+
/** @type {Record<string, string>} */
|
|
168
|
+
const headers = { accept: 'application/json', Precognition: 'true' };
|
|
169
|
+
if (options.only !== undefined && options.only.length > 0) {
|
|
170
|
+
headers['Precognition-Validate-Only'] = options.only.join(',');
|
|
171
|
+
}
|
|
172
|
+
const target = safe ? url(name, options.params, options.input) : url(name, options.params);
|
|
173
|
+
assertStaysPut(name, target, entry.segments.length);
|
|
174
|
+
/** @type {string | undefined} */
|
|
175
|
+
let body;
|
|
176
|
+
if (!safe) {
|
|
177
|
+
headers['content-type'] = 'application/json';
|
|
178
|
+
body = JSON.stringify(options.input ?? {});
|
|
179
|
+
const token = csrfToken();
|
|
180
|
+
if (token !== null) headers[CSRF_HEADER] = token;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
184
|
+
const response = await doFetch(target, {
|
|
185
|
+
method,
|
|
186
|
+
headers,
|
|
187
|
+
body,
|
|
188
|
+
signal: options.signal,
|
|
189
|
+
credentials: 'same-origin',
|
|
190
|
+
redirect: 'manual',
|
|
191
|
+
});
|
|
192
|
+
if (response.status === 204) return {};
|
|
193
|
+
|
|
194
|
+
const text = await response.text();
|
|
195
|
+
let parsed = /** @type {unknown} */ (text);
|
|
196
|
+
try {
|
|
197
|
+
parsed = text === '' ? null : JSON.parse(text);
|
|
198
|
+
} catch {
|
|
199
|
+
// Not JSON: the text is the body.
|
|
200
|
+
}
|
|
201
|
+
const error = new EndpointError(name, response.status, parsed);
|
|
202
|
+
if (response.status === 422 && Object.keys(error.errors).length > 0) return error.errors;
|
|
203
|
+
throw error;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* What `useForm().withPrecognition()` and `<Form validateAgainst>` take
|
|
208
|
+
* to ask an endpoint, not an action, whether fields would do (ADR 0125):
|
|
209
|
+
* the endpoint's name and where it is, and the fields asked about with
|
|
210
|
+
* the form's data at the time. Resolves with each field's errors, or
|
|
211
|
+
* null for an answer that says nothing about them -- a 403, a 500, no
|
|
212
|
+
* answer -- so the fields are left as they were.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} name
|
|
215
|
+
* @param {{ params?: Record<string, unknown>, method?: string, fetch?: typeof fetch }} [options]
|
|
216
|
+
*/
|
|
217
|
+
const precognitive = (name, options = {}) => {
|
|
218
|
+
entryOf(name);
|
|
219
|
+
return Object.freeze({
|
|
220
|
+
endpoint: name,
|
|
221
|
+
/**
|
|
222
|
+
* @param {string[]} fields
|
|
223
|
+
* @param {Record<string, unknown>} input
|
|
224
|
+
* @param {AbortSignal} signal
|
|
225
|
+
*/
|
|
226
|
+
precognition: async (fields, input, signal) => {
|
|
227
|
+
try {
|
|
228
|
+
return await validate(name, { ...options, input, only: fields, signal });
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (
|
|
231
|
+
error instanceof EndpointError ||
|
|
232
|
+
/** @type {{ name?: unknown }} */ (error)?.name === 'AbortError'
|
|
233
|
+
)
|
|
234
|
+
return null;
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Whether a URL is one of these endpoints': a path one of them answers,
|
|
243
|
+
* by the rules PHP matches it by -- each literal segment equal, each
|
|
244
|
+
* parameter some segment, and never under `/_`. Any method: a link is
|
|
245
|
+
* followed with GET, and whatever answers is not a page. The router
|
|
246
|
+
* leaves such a link to the browser (ADR 0074).
|
|
247
|
+
*
|
|
248
|
+
* @param {URL | string} target a URL, or a path
|
|
249
|
+
*/
|
|
250
|
+
const matches = (target) => {
|
|
251
|
+
let pathname;
|
|
252
|
+
try {
|
|
253
|
+
pathname = new URL(String(target), 'http://placeholder').pathname;
|
|
254
|
+
} catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
const parts = pathname.split('/').filter((part) => part !== '');
|
|
258
|
+
if (parts.length > 0 && parts[0].startsWith('_')) return false;
|
|
259
|
+
return Object.values(table).some(
|
|
260
|
+
(entry) =>
|
|
261
|
+
entry.segments.length === parts.length &&
|
|
262
|
+
entry.segments.every((segment, index) =>
|
|
263
|
+
segment.startsWith('{') && segment.endsWith('}') ? parts[index] !== '' : segment === parts[index],
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
return { url, call, validate, precognitive, matches };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Defence in depth behind route()'s segment rule: the path the browser will
|
|
273
|
+
* actually request -- after it has resolved dots and backslashes -- must be
|
|
274
|
+
* the one built, on this origin, with the table's number of segments.
|
|
275
|
+
* Anything else is refused before a request carrying the CSRF token leaves.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} name
|
|
278
|
+
* @param {string} target
|
|
279
|
+
* @param {number} segments
|
|
280
|
+
*/
|
|
281
|
+
function assertStaysPut(name, target, segments) {
|
|
282
|
+
const base = globalThis.location?.origin ?? 'http://localhost';
|
|
283
|
+
const path = target.split('?', 1)[0];
|
|
284
|
+
const resolved = new URL(target, base);
|
|
285
|
+
const parts = path === '/' ? 0 : path.split('/').length - 1;
|
|
286
|
+
if (resolved.origin !== new URL(base).origin || resolved.pathname !== path || parts !== segments) {
|
|
287
|
+
throw new Error(`[tinvar] The endpoint '${name}' would be sent somewhere else than ${path}.`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* A query string from plain values, each key once; a list repeats its key.
|
|
293
|
+
*
|
|
294
|
+
* @param {Record<string, unknown> | undefined} query
|
|
295
|
+
*/
|
|
296
|
+
function toQuery(query) {
|
|
297
|
+
if (query === undefined) return '';
|
|
298
|
+
const search = new URLSearchParams();
|
|
299
|
+
for (const [key, value] of Object.entries(query)) {
|
|
300
|
+
if (value === undefined || value === null) continue;
|
|
301
|
+
for (const item of Array.isArray(value) ? value : [value]) search.append(key, String(item));
|
|
302
|
+
}
|
|
303
|
+
return search.toString();
|
|
304
|
+
}
|