@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,364 @@
1
+ // Where a resumable upload is remembered across a refresh, a closed tab or
2
+ // a crash (ADR 0143): in IndexedDB, one record per file, keyed by what the
3
+ // upload is for and a fingerprint of the file.
4
+ //
5
+ // A record holds the session the server signed and what the page needs to
6
+ // say "Resume uploading <name> (n% done)" -- the name, the size, how far it
7
+ // had got -- and, where the browser has the File System Access API, the
8
+ // FileSystemFileHandle the file was chosen through, so it can be read again
9
+ // at the cost of a permission prompt. Never the file's bytes.
10
+ //
11
+ // A record is the session's that began it, and outlives neither: when the
12
+ // database opens, every record that has run out goes, whatever its scope,
13
+ // and the store keeps whose records it holds -- the session and user of the
14
+ // router's binding -- so wherever the router finds the session over or
15
+ // another -- a sign-out, a sign-in, a 401 -- every record goes, its file
16
+ // handle with it (purge()).
17
+ //
18
+ // The database is opened only by an upload that reads or writes a record,
19
+ // never by the router: a purge asked for before that is kept, and run when
20
+ // the database first opens, before anything is read from it.
21
+ //
22
+ // Every access is wrapped: a browser with no IndexedDB, a private window
23
+ // that refuses it, a quota that is full or a database that is corrupt all
24
+ // read as "nothing remembered", and the upload starts over. Nothing here
25
+ // ever throws.
26
+
27
+ const DATABASE = 'tinvar-uploads';
28
+ const STORE = 'resumable';
29
+ const VERSION = 1;
30
+
31
+ /** The entry that says whose records the store holds (purge()): no record, by its shape. */
32
+ const OWNER = '\u0000owner';
33
+
34
+ /**
35
+ * @typedef {{
36
+ * id: string,
37
+ * scope: string,
38
+ * fingerprint: string,
39
+ * session: string,
40
+ * mode: 'chunks' | 'parts',
41
+ * name: string,
42
+ * size: number,
43
+ * type: string,
44
+ * lastModified: number,
45
+ * offset: number,
46
+ * createdAt: number,
47
+ * expiresAt: number,
48
+ * handle?: unknown,
49
+ * }} ResumeRecord
50
+ *
51
+ * @typedef {{
52
+ * get: (id: string) => Promise<ResumeRecord | null>,
53
+ * put: (record: ResumeRecord) => Promise<void>,
54
+ * delete: (id: string) => Promise<void>,
55
+ * inScope: (scope: string) => Promise<ResumeRecord[]>,
56
+ * purge?: (binding: string | null) => Promise<void>,
57
+ * }} ResumeStore
58
+ */
59
+
60
+ /**
61
+ * The browser's resume store, in IndexedDB; or one that remembers nothing
62
+ * where IndexedDB cannot be had.
63
+ *
64
+ * @returns {ResumeStore}
65
+ */
66
+ export function indexedDbResumeStore() {
67
+ /** @type {Promise<IDBDatabase | null> | null} */
68
+ let opening = null;
69
+ /**
70
+ * The purge the router asked for before anything opened the database:
71
+ * the owner it last named, and whether one asked for between would have
72
+ * emptied the store whoever that owner is -- a session ended, or another
73
+ * bound -- since the purges before it were never run.
74
+ *
75
+ * @type {{ owner: string | null, force: boolean } | null}
76
+ */
77
+ let pending = null;
78
+ // Opened once, by the first upload that reads or writes a record -- never
79
+ // by the router, so a page with no upload opens no database -- and swept
80
+ // of every record that has run out, in any scope: a record is otherwise
81
+ // read, and deleted, only by the field it is for. Then the purge the
82
+ // router left, before anything is read.
83
+ const database = () =>
84
+ (opening ??= open().then(async (db) => {
85
+ if (db !== null) {
86
+ await sweep(db, expired);
87
+ if (pending !== null) {
88
+ const { owner, force } = pending;
89
+ pending = null;
90
+ await purgeFor(db, owner, force);
91
+ }
92
+ }
93
+ return db;
94
+ }));
95
+
96
+ /**
97
+ * @template T
98
+ * @param {IDBTransactionMode} mode
99
+ * @param {(store: IDBObjectStore) => IDBRequest<T>} work
100
+ * @returns {Promise<T | null>}
101
+ */
102
+ const run = async (mode, work) => {
103
+ const db = await database();
104
+ return db === null ? null : request(db, mode, work);
105
+ };
106
+
107
+ return {
108
+ async get(id) {
109
+ const found = /** @type {ResumeRecord | undefined | null} */ (await run('readonly', (s) => s.get(id)));
110
+ if (!isRecord(found)) return null;
111
+ if (expired(found)) {
112
+ await run('readwrite', (s) => s.delete(id));
113
+ return null;
114
+ }
115
+ return found;
116
+ },
117
+ async put(record) {
118
+ // A file handle the browser will not clone is left out, not the
119
+ // record: the upload can still be gone on with, the file chosen again.
120
+ if ((await run('readwrite', (s) => s.put(record))) === null && record.handle !== undefined) {
121
+ const { handle: _dropped, ...without } = record;
122
+ await run('readwrite', (s) => s.put(without));
123
+ }
124
+ },
125
+ async delete(id) {
126
+ await run('readwrite', (s) => s.delete(id));
127
+ },
128
+ async inScope(scope) {
129
+ const all = /** @type {unknown[] | null} */ (
130
+ await run('readonly', (s) => s.index('scope').getAll(scope))
131
+ );
132
+ const records = (all ?? []).filter(isRecord);
133
+ for (const record of records.filter(expired)) await run('readwrite', (s) => s.delete(record.id));
134
+ return records.filter((r) => !expired(r)).sort((a, b) => b.createdAt - a.createdAt);
135
+ },
136
+ async purge(binding) {
137
+ const owner = ownerOf(binding);
138
+ // Not opened on this page: nothing has read a record, so the purge
139
+ // waits for the first open (database()), which runs it first.
140
+ if (opening === null) {
141
+ pending = { owner, force: pending !== null && (pending.force || pending.owner !== owner) };
142
+ return;
143
+ }
144
+ const db = await database();
145
+ if (db !== null) await purgeFor(db, owner, false);
146
+ },
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Empties the store unless its entry already names $owner -- and always
152
+ * for no owner, or where $force says a purge between would have.
153
+ *
154
+ * @param {IDBDatabase} db
155
+ * @param {string | null} owner
156
+ * @param {boolean} force
157
+ */
158
+ async function purgeFor(db, owner, force) {
159
+ const marker = /** @type {{ owner?: unknown } | undefined | null} */ (
160
+ await request(db, 'readonly', (s) => s.get(OWNER))
161
+ );
162
+ if (!force && owner !== null && marker?.owner === owner) return;
163
+ // Emptied and marked as the new owner's in one transaction: both, or
164
+ // neither. Failed, the mark is taken off, so the next purge empties the
165
+ // store again rather than read it as clean.
166
+ if (!(await clearFor(db, owner))) await request(db, 'readwrite', (s) => s.delete(OWNER));
167
+ }
168
+
169
+ /**
170
+ * @template T
171
+ * @param {IDBDatabase} db
172
+ * @param {IDBTransactionMode} mode
173
+ * @param {(store: IDBObjectStore) => IDBRequest<T>} work
174
+ * @returns {Promise<T | null>}
175
+ */
176
+ async function request(db, mode, work) {
177
+ try {
178
+ return await new Promise((resolve) => {
179
+ const pending = work(db.transaction(STORE, mode).objectStore(STORE));
180
+ pending.onsuccess = () => resolve(pending.result);
181
+ pending.onerror = () => resolve(null);
182
+ });
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Empties the store and, for an owner, marks it as theirs, in one readwrite
190
+ * transaction. Whether it committed.
191
+ *
192
+ * @param {IDBDatabase} db
193
+ * @param {string | null} owner
194
+ * @returns {Promise<boolean>}
195
+ */
196
+ function clearFor(db, owner) {
197
+ return new Promise((resolve) => {
198
+ try {
199
+ const transaction = db.transaction(STORE, 'readwrite');
200
+ transaction.oncomplete = () => resolve(true);
201
+ transaction.onerror = () => resolve(false);
202
+ transaction.onabort = () => resolve(false);
203
+ const store = transaction.objectStore(STORE);
204
+ store.clear();
205
+ if (owner !== null) store.put({ id: OWNER, owner });
206
+ } catch {
207
+ resolve(false);
208
+ }
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Deletes every record $doomed says, in any scope -- its file handle with it.
214
+ *
215
+ * @param {IDBDatabase} db
216
+ * @param {(record: ResumeRecord) => boolean} doomed
217
+ */
218
+ async function sweep(db, doomed) {
219
+ const all = /** @type {unknown[] | null} */ (await request(db, 'readonly', (s) => s.getAll()));
220
+ for (const record of (all ?? []).filter(isRecord).filter(doomed)) {
221
+ await request(db, 'readwrite', (s) => s.delete(record.id));
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Whose records the store holds, from the router's binding (bindingOf()):
227
+ * its session and user hashes -- who is signed in, at which auth epoch --
228
+ * without the tenant and panel, which a record's own scope keeps apart
229
+ * already, so two tabs in two panels do not wipe each other's. Null for a
230
+ * session over. A record's scope cannot be held to the binding itself: a
231
+ * ticket's hashes are keyed apart from a route token's.
232
+ *
233
+ * @param {string | null} binding
234
+ */
235
+ function ownerOf(binding) {
236
+ if (binding === null) return null;
237
+ try {
238
+ const [session, user] = JSON.parse(binding);
239
+ return typeof session === 'string' && session !== ''
240
+ ? JSON.stringify([session, String(user ?? '')])
241
+ : null;
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Forgets, in the page's resume store, every resumable upload unless the
249
+ * store's records are already the session and user $binding names -- all
250
+ * of them for null: what the router calls where it finds the session over
251
+ * or changed. Never throws.
252
+ *
253
+ * @param {string | null} binding
254
+ * @param {ResumeStore} [store]
255
+ */
256
+ export function purgeResumeStore(binding, store) {
257
+ try {
258
+ void (store ?? defaultResumeStore()).purge?.(binding)?.catch(() => {});
259
+ } catch {
260
+ // Nothing remembered can be forgotten either.
261
+ }
262
+ }
263
+
264
+ /** @returns {Promise<IDBDatabase | null>} */
265
+ function open() {
266
+ try {
267
+ const idb = globalThis.indexedDB;
268
+ if (!idb || typeof idb.open !== 'function') return Promise.resolve(null);
269
+ return new Promise((resolve) => {
270
+ let request;
271
+ try {
272
+ request = idb.open(DATABASE, VERSION);
273
+ } catch {
274
+ resolve(null);
275
+ return;
276
+ }
277
+ request.onupgradeneeded = () => {
278
+ try {
279
+ const store = request.result.createObjectStore(STORE, { keyPath: 'id' });
280
+ store.createIndex('scope', 'scope', { unique: false });
281
+ } catch {
282
+ // A database that cannot be shaped is one that holds nothing.
283
+ }
284
+ };
285
+ request.onsuccess = () => resolve(request.result);
286
+ request.onerror = () => resolve(null);
287
+ request.onblocked = () => resolve(null);
288
+ });
289
+ } catch {
290
+ return Promise.resolve(null);
291
+ }
292
+ }
293
+
294
+ /** A store in memory: for a page with no IndexedDB, and for tests. @returns {ResumeStore} */
295
+ export function memoryResumeStore() {
296
+ /** @type {Map<string, ResumeRecord>} */
297
+ const records = new Map();
298
+ /** @type {string | null} */
299
+ let owner = null;
300
+ return {
301
+ async get(id) {
302
+ const found = records.get(id) ?? null;
303
+ if (found !== null && expired(found)) {
304
+ records.delete(id);
305
+ return null;
306
+ }
307
+ return found;
308
+ },
309
+ async put(record) {
310
+ records.set(record.id, record);
311
+ },
312
+ async delete(id) {
313
+ records.delete(id);
314
+ },
315
+ async inScope(scope) {
316
+ return [...records.values()]
317
+ .filter((r) => r.scope === scope && !expired(r))
318
+ .sort((a, b) => b.createdAt - a.createdAt);
319
+ },
320
+ async purge(binding) {
321
+ const next = ownerOf(binding);
322
+ if (next !== null && next === owner) return;
323
+ records.clear();
324
+ owner = next;
325
+ },
326
+ };
327
+ }
328
+
329
+ /** @param {ResumeRecord} record */
330
+ function expired(record) {
331
+ return record.expiresAt * 1000 <= Date.now();
332
+ }
333
+
334
+ /**
335
+ * A record as this code wrote it -- anything else in the database, from an
336
+ * older version or another hand, is passed over.
337
+ *
338
+ * @param {unknown} value
339
+ * @returns {value is ResumeRecord}
340
+ */
341
+ function isRecord(value) {
342
+ const r = /** @type {any} */ (value);
343
+ return (
344
+ r !== null &&
345
+ typeof r === 'object' &&
346
+ typeof r.id === 'string' &&
347
+ typeof r.scope === 'string' &&
348
+ typeof r.fingerprint === 'string' &&
349
+ typeof r.session === 'string' &&
350
+ (r.mode === 'chunks' || r.mode === 'parts') &&
351
+ typeof r.name === 'string' &&
352
+ typeof r.size === 'number' &&
353
+ typeof r.offset === 'number' &&
354
+ typeof r.createdAt === 'number' &&
355
+ typeof r.expiresAt === 'number'
356
+ );
357
+ }
358
+
359
+ let shared = /** @type {ResumeStore | null} */ (null);
360
+
361
+ /** The page's one resume store. @returns {ResumeStore} */
362
+ export function defaultResumeStore() {
363
+ return (shared ??= indexedDbResumeStore());
364
+ }
package/src/route.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ /** Route name => its path segments, `{name}` for a parameter; and its host pattern, for a route on a host of its own. */
2
+ export type RouteTable<M> = {
3
+ readonly [N in keyof M]:
4
+ readonly string[] | { readonly segments: readonly string[]; readonly host: string };
5
+ };
6
+
7
+ /** The host a page is drawn on: its #[Domain] pattern (null for the primary host) and what it took. */
8
+ export interface CurrentHost {
9
+ pattern: string | null;
10
+ params: Record<string, string> | readonly never[];
11
+ }
12
+
13
+ /** What the server routes by host with (ADR 0149): PHP's Domains::forClient(). */
14
+ export interface Hosts {
15
+ scheme: string;
16
+ port: string;
17
+ roots: Record<string, string>;
18
+ primary: string;
19
+ current?: CurrentHost;
20
+ }
21
+
22
+ /** The global the browser keeps the page's hosts in, once read from its metadata. */
23
+ export declare const HOSTS_GLOBAL: '__TINVAR_HOSTS__';
24
+
25
+ /** The global the renderer keeps the AsyncLocalStorage each render's hosts are read from under. */
26
+ export declare const HOSTS_STORE: '__TINVAR_HOSTS_STORE__';
27
+
28
+ /** The server's hosts, or undefined where it routes on no host. */
29
+ export declare function hosts(): Hosts | undefined;
30
+
31
+ /** The origin of a host pattern for these parameters, as PHP's Domains::origin() builds it. */
32
+ export declare function originOf(
33
+ pattern: string,
34
+ values: Record<string, unknown>,
35
+ config: Hosts,
36
+ name: string,
37
+ ): string;
38
+
39
+ /**
40
+ * Builds the typed `route()` codegen exports from its table of named routes.
41
+ * Missing parameters are a type error; at runtime they, an unknown name and
42
+ * a value PHP's router could not match all throw. Where the server routes by
43
+ * host, a URL to a page on another host than the one drawn is absolute.
44
+ */
45
+ export declare function routes<M extends object>(
46
+ table: RouteTable<M>,
47
+ ): <N extends keyof M & string>(name: N, params: M[N]) => string;
48
+
49
+ /** PHP's Request::isPathSegment(), on a decoded value, and not empty. */
50
+ export declare function isPathSegment(value: string): boolean;
package/src/route.js ADDED
@@ -0,0 +1,227 @@
1
+ // Building a URL for a named route, from the table codegen writes.
2
+ //
3
+ // tinvar-spec.md: "Codegen emits a typed `route('racks.show', { rack })`
4
+ // helper for TypeScript." Codegen declared that function and nothing ever
5
+ // defined it, so the first import of it failed the build. The generated
6
+ // module now calls routes() with each named page's path segments, and the
7
+ // URL is built here -- by the same rules PHP's CompiledRoute::url() applies,
8
+ // so a link built in a component is the URL the server would have built.
9
+ //
10
+ // Where the application routes by host (ADR 0149), a page may live on a host
11
+ // of its own: its table entry carries the host pattern -- `admin.{root}`,
12
+ // `{account}.{root}` -- and a URL to a page on another host than the one
13
+ // being drawn is absolute, written with the scheme, port and configured
14
+ // domains the server hands each render and each page (hosts()). A page on
15
+ // the same host -- the same pattern, with the same host parameters -- is a
16
+ // path, as it always was. Which host is being drawn comes with the render
17
+ // and with the page, never from the browser's address bar, so the server
18
+ // render and the browser write the same link and hydration holds. The
19
+ // client router fetches a URL on its own origin as a visit and loads any
20
+ // other as a document, so a link to another host is a full navigation.
21
+ //
22
+ // No React and nothing from Node: server components and the browser both
23
+ // import it.
24
+
25
+ /** The global the browser keeps the page's hosts in, once read from its metadata. */
26
+ export const HOSTS_GLOBAL = '__TINVAR_HOSTS__';
27
+
28
+ /**
29
+ * The global the renderer puts an AsyncLocalStorage under, each render run
30
+ * inside it with its frame's hosts: one renderer draws pages of several
31
+ * hosts at once, and each render reads its own. Read through the global,
32
+ * not imported, as this module imports nothing of Node's.
33
+ */
34
+ export const HOSTS_STORE = '__TINVAR_HOSTS_STORE__';
35
+
36
+ /**
37
+ * @typedef {{ pattern: string | null, params: Record<string, string> }} CurrentHost
38
+ * @typedef {{ scheme: string, port: string, roots: Record<string, string>, primary: string, current?: CurrentHost }} Hosts
39
+ */
40
+
41
+ /**
42
+ * The server's hosts, where it routes by them, and the host being drawn:
43
+ * the render's own in the renderer, or read once from the page's metadata
44
+ * in a browser. Undefined where the application routes on no host.
45
+ *
46
+ * @returns {Hosts | undefined}
47
+ */
48
+ export function hosts() {
49
+ const g = /** @type {Record<string, any>} */ (globalThis);
50
+ const stored = typeof g[HOSTS_STORE]?.getStore === 'function' ? g[HOSTS_STORE].getStore() : undefined;
51
+ if (isHosts(stored)) return stored;
52
+ const set = g[HOSTS_GLOBAL];
53
+ if (isHosts(set)) return set;
54
+
55
+ const doc = /** @type {{ document?: Document }} */ (globalThis).document;
56
+ if (typeof doc?.querySelector !== 'function') return undefined;
57
+ const element = doc.querySelector('script[type="application/json"]#tinvar-page');
58
+ if (!element) return undefined;
59
+ try {
60
+ const page = JSON.parse(element.textContent ?? '');
61
+ if (isHosts(page?.hosts)) {
62
+ /** @type {Record<string, unknown>} */ (globalThis)[HOSTS_GLOBAL] = page.hosts;
63
+ return page.hosts;
64
+ }
65
+ } catch {
66
+ // The document is the app's, and nothing in it is assumed well-formed.
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ /** @param {unknown} value @returns {value is Hosts} */
72
+ function isHosts(value) {
73
+ const v = /** @type {any} */ (value);
74
+ return (
75
+ typeof v === 'object' &&
76
+ v !== null &&
77
+ typeof v.scheme === 'string' &&
78
+ typeof v.port === 'string' &&
79
+ typeof v.primary === 'string' &&
80
+ typeof v.roots === 'object' &&
81
+ v.roots !== null
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Whether a route on host pattern $host (null: the primary host) is on the
87
+ * host being drawn: the same pattern, and each of its parameters the value
88
+ * the current host gave it.
89
+ *
90
+ * @param {string | null} host
91
+ * @param {Record<string, unknown>} values
92
+ * @param {CurrentHost | undefined} current
93
+ */
94
+ function onCurrentHost(host, values, current) {
95
+ if (current === undefined || (current.pattern ?? null) !== host) return false;
96
+ if (host === null) return true;
97
+ const params = Array.isArray(current.params) ? {} : (current.params ?? {});
98
+ const labels = host.split('.');
99
+ return labels.every((label, index) => {
100
+ if (index === labels.length - 1 || !(label.startsWith('{') && label.endsWith('}'))) return true;
101
+ const key = label.slice(1, -1);
102
+ return Object.hasOwn(params, key) && String(values[key]) === params[key];
103
+ });
104
+ }
105
+
106
+ /** A host label, as PHP's HostPattern holds one. */
107
+ const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
108
+
109
+ /**
110
+ * The origin of a host pattern for these parameters: PHP's Domains::origin().
111
+ *
112
+ * @param {string} pattern
113
+ * @param {Record<string, unknown>} values
114
+ * @param {Hosts} config
115
+ * @param {string} name the route, for the errors
116
+ */
117
+ export function originOf(pattern, values, config, name) {
118
+ const labels = pattern.split('.');
119
+ const out = labels.map((label, index) => {
120
+ if (!(label.startsWith('{') && label.endsWith('}'))) return label;
121
+ const key = label.slice(1, -1);
122
+ if (index === labels.length - 1) {
123
+ const root = config.roots[key];
124
+ if (typeof root !== 'string') {
125
+ throw new Error(
126
+ `[tinvar] The route '${name}' is on ${pattern}, and the server configures no domain '${key}'.`,
127
+ );
128
+ }
129
+ return root;
130
+ }
131
+ if (!Object.hasOwn(values, key) || values[key] === undefined || values[key] === null) {
132
+ throw new Error(`[tinvar] The route '${name}' needs a '${key}' parameter.`);
133
+ }
134
+ const value = String(values[key]);
135
+ if (!LABEL.test(value)) {
136
+ throw new Error(
137
+ `[tinvar] '${value}' cannot be the '${key}' parameter of the route '${name}': it is one host label.`,
138
+ );
139
+ }
140
+ return value;
141
+ });
142
+
143
+ return `${config.scheme}://${out.join('.')}${config.port === '' ? '' : `:${config.port}`}`;
144
+ }
145
+
146
+ /**
147
+ * @template {object} M route name => its parameters
148
+ * @param {{ readonly [N in keyof M]: readonly string[] | { readonly segments: readonly string[], readonly host: string } }} table
149
+ * route name => path segments, `{name}` for a parameter, with the host pattern of a route on a host of its own
150
+ * @returns {<N extends keyof M & string>(name: N, params: M[N]) => string}
151
+ */
152
+ export function routes(table) {
153
+ return (name, params) => {
154
+ if (!Object.hasOwn(table, name)) {
155
+ throw new Error(`[tinvar] No route is named '${name}'.`);
156
+ }
157
+
158
+ /** @type {Record<string, unknown>} */
159
+ const values = params ?? {};
160
+ const entry = table[name];
161
+ const segments = Array.isArray(entry)
162
+ ? entry
163
+ : /** @type {{ segments: readonly string[] }} */ (entry).segments;
164
+ const out = segments.map((segment) => {
165
+ if (!(segment.startsWith('{') && segment.endsWith('}'))) return segment;
166
+
167
+ const key = segment.slice(1, -1);
168
+ if (!Object.hasOwn(values, key) || values[key] === undefined || values[key] === null) {
169
+ throw new Error(`[tinvar] The route '${name}' needs a '${key}' parameter.`);
170
+ }
171
+
172
+ const value = String(values[key]);
173
+
174
+ // A parameter is one segment, by PHP's Request::isPathSegment() rule
175
+ // exactly. A separator, a traversal step, a backslash -- which a
176
+ // browser reads as a separator -- or a control character would build
177
+ // a URL the router refuses, or one the browser normalizes onto
178
+ // another path before it is sent: with the CSRF token on it, for an
179
+ // endpoint call. Refused here instead.
180
+ if (!isPathSegment(value)) {
181
+ throw new Error(`[tinvar] '${value}' cannot be the '${key}' parameter of the route '${name}'.`);
182
+ }
183
+
184
+ return rawUrlEncode(value);
185
+ });
186
+
187
+ const path = out.length === 0 ? '/' : `/${out.join('/')}`;
188
+ const config = hosts();
189
+ if (config === undefined) return path;
190
+
191
+ const host = Array.isArray(entry) ? null : /** @type {{ host: string }} */ (entry).host;
192
+ if (onCurrentHost(host, values, config.current)) return path;
193
+
194
+ return originOf(host ?? config.primary, values, config, name) + path;
195
+ };
196
+ }
197
+
198
+ /**
199
+ * PHP's Request::isPathSegment(), on a decoded value, and not empty.
200
+ *
201
+ * @param {string} value
202
+ */
203
+ export function isPathSegment(value) {
204
+ return (
205
+ value !== '' &&
206
+ value !== '.' &&
207
+ value !== '..' &&
208
+ !value.includes('/') &&
209
+ !value.includes('\\') &&
210
+ // eslint-disable-next-line no-control-regex
211
+ !/[\x00-\x1f\x7f]/.test(value)
212
+ );
213
+ }
214
+
215
+ /**
216
+ * PHP's rawurlencode(): everything but A-Z a-z 0-9 - _ . ~ is escaped.
217
+ * encodeURIComponent() leaves ! ' ( ) * as they are, which would make the
218
+ * same link differ by where it was built.
219
+ *
220
+ * @param {string} value
221
+ */
222
+ function rawUrlEncode(value) {
223
+ return encodeURIComponent(value).replace(
224
+ /[!'()*]/g,
225
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
226
+ );
227
+ }