@tinvar/react 0.0.0-stage → 0.4.0
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,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
|
+
}
|