@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,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>;
@@ -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
+ }