@uniflowed/router 0.0.0-alpha.9 → 0.2.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/action.js +344 -0
- package/client.js +263 -7
- package/handler.js +113 -99
- package/http-client.js +104 -0
- package/index.js +49 -6
- package/instrumentation.js +92 -0
- package/internal/action-endpoint.js +646 -0
- package/internal/action-wire.js +608 -0
- package/internal/base-path.js +175 -0
- package/internal/boundaries.js +481 -0
- package/internal/boundary-data.js +88 -0
- package/internal/compose.js +490 -0
- package/internal/deployment.js +160 -0
- package/internal/devtools.js +131 -0
- package/internal/diagnostics.js +169 -0
- package/internal/error-view.js +193 -0
- package/internal/flight-browser.js +242 -0
- package/internal/flight-chunks.js +205 -0
- package/internal/flight-rows.js +135 -0
- package/internal/flight-ssr.js +91 -0
- package/internal/flight.js +192 -0
- package/internal/form-action.js +243 -0
- package/internal/head.js +219 -0
- package/internal/hydrate-options.js +38 -0
- package/internal/hydration.js +1085 -0
- package/internal/inspector.js +626 -0
- package/internal/native-links.js +67 -0
- package/internal/native-tree.js +89 -0
- package/internal/navigation-cache.js +181 -0
- package/internal/payload-rows.js +270 -0
- package/internal/payload.js +685 -0
- package/internal/prepare-document.js +54 -0
- package/internal/react-version.js +77 -0
- package/internal/resolve.js +1617 -0
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +478 -0
- package/internal/runtime.js +1593 -1341
- package/internal/server-instrumentation.js +12 -0
- package/internal/server-route.js +58 -0
- package/internal/shell.js +132 -0
- package/internal/stream.js +766 -21
- package/middleware.js +274 -22
- package/native-navigation.js +217 -0
- package/native.js +416 -0
- package/package.json +48 -7
- package/routing.js +51 -0
- package/rsc-client.js +122 -0
- package/rsc-ssr.js +641 -0
- package/rsc.js +402 -0
- package/server-components.js +159 -0
- package/server.js +263 -106
package/middleware.js
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Middleware: what runs before a path answers, whatever answers it.
|
|
4
4
|
//
|
|
5
|
-
// `app/dashboard
|
|
5
|
+
// `app/dashboard/$middleware.js` guards `/dashboard` and everything under
|
|
6
6
|
// it — the pages, the route handlers, and the paths under it that match
|
|
7
7
|
// nothing at all. There is no `matcher` to write because the directory the
|
|
8
8
|
// file sits in *is* the matcher, which is the same composition rule layouts
|
|
9
9
|
// already use and the reason uf does not inherit Next's regular expressions.
|
|
10
10
|
//
|
|
11
|
-
// // app/dashboard
|
|
11
|
+
// // app/dashboard/$middleware.js
|
|
12
12
|
// // @flow
|
|
13
13
|
// import { cookies } from "@uniflowed/server";
|
|
14
14
|
//
|
|
@@ -27,10 +27,64 @@
|
|
|
27
27
|
// that could only observe would not be able to reject, and one that had to
|
|
28
28
|
// answer could not be a logger.
|
|
29
29
|
//
|
|
30
|
-
// There is no `next()
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
30
|
+
// There is no `next()`. A third answer is `rewrite(destination)`: serve another
|
|
31
|
+
// route of this application at the address the visitor asked for.
|
|
32
|
+
//
|
|
33
|
+
// // app/$middleware.js
|
|
34
|
+
// import { rewrite } from "@uniflowed/router/middleware";
|
|
35
|
+
//
|
|
36
|
+
// export default function middleware(request: Request) {
|
|
37
|
+
// if (cookies().get("beta") != null) return rewrite("/beta" + new URL(request.url).pathname);
|
|
38
|
+
// }
|
|
39
|
+
//
|
|
40
|
+
// A returned value rather than a returned `Request`, which was the other
|
|
41
|
+
// spelling on the table. A `Request` could change the method and the headers
|
|
42
|
+
// too, and `headers()` reads the request the host began — so a middleware that
|
|
43
|
+
// added a header would have handed the page one set of headers and `headers()`
|
|
44
|
+
// another. A rewrite changes the path, the query when it names one, and
|
|
45
|
+
// nothing else.
|
|
46
|
+
//
|
|
47
|
+
// # A rewrite runs the destination's middleware
|
|
48
|
+
//
|
|
49
|
+
// The chain starts again from the root over the middleware that has not run
|
|
50
|
+
// yet, against the new path. So a rewrite into `/admin` passes the guard on
|
|
51
|
+
// `/admin` exactly as a request for it would, and is never an unguarded way to
|
|
52
|
+
// a guarded page; a middleware that already ran for this request does not run
|
|
53
|
+
// a second time, which is also what makes the loop finite.
|
|
54
|
+
//
|
|
55
|
+
// # A payload request is its document
|
|
56
|
+
//
|
|
57
|
+
// A browser navigating a React Server Components application asks for
|
|
58
|
+
// `/pricing/__uf.flight` rather than `/pricing`. The chain is matched against,
|
|
59
|
+
// and every middleware is handed, the document's URL — so the check a
|
|
60
|
+
// middleware writes against `/pricing` holds for a client navigation too, and
|
|
61
|
+
// a rewrite of the document becomes a rewrite of its payload.
|
|
62
|
+
//
|
|
63
|
+
// # A payload that renders over another page runs that page's guards too
|
|
64
|
+
//
|
|
65
|
+
// An intercepted navigation — a modal slot opening over the page it was clicked
|
|
66
|
+
// from — asks for the payload of the URL it opens, and names the page it
|
|
67
|
+
// renders over in `uf-intercepted-from`. The answer is *both* pages: the one
|
|
68
|
+
// the URL names, in the slot, and the one the header names, underneath it,
|
|
69
|
+
// with that page's loader run and its data in the payload.
|
|
70
|
+
//
|
|
71
|
+
// So the chain matched against the URL alone would be the wrong chain. A guard
|
|
72
|
+
// on `/feed` holds for a request *for* `/feed`, and a payload for `/photo/1`
|
|
73
|
+
// rendered over `/feed` is `/feed` as much as it is `/photo/1`. After the
|
|
74
|
+
// ordinary chain has admitted the request, every middleware guarding the page
|
|
75
|
+
// underneath runs as well — against a `GET` for that page, with its params and
|
|
76
|
+
// query — including one that already ran for the URL, because a middleware
|
|
77
|
+
// that decides by reading the pathname (one root `$middleware.js` guarding
|
|
78
|
+
// several sections is a common shape) has only been asked about the other
|
|
79
|
+
// path. If every one of them declines, the interception stands. If any
|
|
80
|
+
// answers or rewrites, the page underneath is not this request's to render:
|
|
81
|
+
// the header is taken off, and what renders is the page the URL names on its
|
|
82
|
+
// own — exactly what a reader who could not open the page underneath would
|
|
83
|
+
// see after a reload. The guard's own answer is not sent: the request was for
|
|
84
|
+
// `/photo/1`, which that guard does not cover.
|
|
85
|
+
//
|
|
86
|
+
// The header is taken off whenever it is not a path on this origin, too, so
|
|
87
|
+
// the renderer is never handed a value this module has not judged.
|
|
34
88
|
//
|
|
35
89
|
// # The request it runs inside
|
|
36
90
|
//
|
|
@@ -38,9 +92,18 @@
|
|
|
38
92
|
// `@uniflowed/server/host`, once per request, around everything that answers
|
|
39
93
|
// it — and the guard, the handler or page underneath it, and the render all
|
|
40
94
|
// see that one context. So `cookies()` in a guard and `cookies()` in the page
|
|
41
|
-
// it guards are the same cookies, `draftMode().
|
|
42
|
-
//
|
|
43
|
-
// the host drains after the response has gone.
|
|
95
|
+
// it guards are the same cookies, `draftMode().isEnabled` gives a guard and
|
|
96
|
+
// the page under it the same answer, and every `after()` on the request is one
|
|
97
|
+
// ordered list the host drains after the response has gone.
|
|
98
|
+
//
|
|
99
|
+
// *Changing* draft mode is not a guard's to do, and that is a separate rule
|
|
100
|
+
// with a separate reason: `enable()` writes a cookie, a cookie is part of a
|
|
101
|
+
// response, and a guard may decline — so a guard that turned draft mode on and
|
|
102
|
+
// then let the request through would have made a decision with nowhere to be
|
|
103
|
+
// written. `asResponder` marks the two calls that do own a response, a route
|
|
104
|
+
// handler and a server action, and `draftMode().enable()` refuses anywhere
|
|
105
|
+
// else by name. A guard that wants draft mode on answers with a redirect to
|
|
106
|
+
// the handler that turns it on. See ubugeeei-prod/uf#282.
|
|
44
107
|
//
|
|
45
108
|
// It used to be the other way, and it is worth saying why that was wrong
|
|
46
109
|
// rather than merely different: this module built its own context and drained
|
|
@@ -59,6 +122,7 @@
|
|
|
59
122
|
// `routesModuleSource` keeps the middleware table in an export the client
|
|
60
123
|
// never imports, for the same reason it does that with route handlers.
|
|
61
124
|
|
|
125
|
+
import { INTERCEPTED_FROM_HEADER, documentPathOf, flightUrl } from "./internal/flight.js";
|
|
62
126
|
import { requireRequest } from "./internal/request.js";
|
|
63
127
|
import type { RouteParams } from "./internal/runtime.js";
|
|
64
128
|
|
|
@@ -70,11 +134,39 @@ export type MiddlewareContext = {|
|
|
|
70
134
|
readonly searchParams: URLSearchParams,
|
|
71
135
|
|};
|
|
72
136
|
|
|
137
|
+
/**
|
|
138
|
+
* What a middleware returns to serve another route at the requested address.
|
|
139
|
+
*
|
|
140
|
+
* Built by [`rewrite`] and read by the runner; a class so that the runner can
|
|
141
|
+
* tell it from a `Response` without trusting the shape of an object.
|
|
142
|
+
*/
|
|
143
|
+
export class Rewrite {
|
|
144
|
+
readonly destination: string;
|
|
145
|
+
|
|
146
|
+
constructor(destination: string) {
|
|
147
|
+
this.destination = destination;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Serve `destination` — a path of this application — in place of the path the
|
|
153
|
+
* request named.
|
|
154
|
+
*
|
|
155
|
+
* Relative to the request, so `"/beta/pricing"` and `"../pricing"` both work.
|
|
156
|
+
* A destination that names no query keeps the request's; one that names a
|
|
157
|
+
* query replaces it. Another origin is refused when the middleware returns it:
|
|
158
|
+
* sending a visitor elsewhere is `Response.redirect`, and proxying to another
|
|
159
|
+
* server is a route handler that fetches.
|
|
160
|
+
*/
|
|
161
|
+
export function rewrite(destination: string | URL): Rewrite {
|
|
162
|
+
return new Rewrite(typeof destination === "string" ? destination : destination.href);
|
|
163
|
+
}
|
|
164
|
+
|
|
73
165
|
/** One middleware function. */
|
|
74
166
|
export type Middleware = (
|
|
75
167
|
request: Request,
|
|
76
168
|
context: MiddlewareContext,
|
|
77
|
-
) => Response | void | Promise<Response | void>;
|
|
169
|
+
) => Response | Rewrite | void | Promise<Response | Rewrite | void>;
|
|
78
170
|
|
|
79
171
|
/** A middleware module, as the generated table loads it. */
|
|
80
172
|
export type MiddlewareModule = { readonly [name: string]: mixed };
|
|
@@ -91,7 +183,13 @@ export type MiddlewareRecord = {|
|
|
|
91
183
|
* Build the middleware runner for one application.
|
|
92
184
|
*
|
|
93
185
|
* Returns `null` when every middleware on the path declined, which is the
|
|
94
|
-
* caller's signal to carry on to the handler or the page.
|
|
186
|
+
* caller's signal to carry on to the handler or the page. Returns a `Request`
|
|
187
|
+
* when one of them rewrote: the same request at the destination, which the
|
|
188
|
+
* caller carries on with instead — and which has already been past the
|
|
189
|
+
* destination's middleware. It also returns a `Request` when a payload request
|
|
190
|
+
* names a page to render over and that page's guards did not all admit it: the
|
|
191
|
+
* same request without `uf-intercepted-from`. The host must carry on with that
|
|
192
|
+
* request and read the header off nothing else, or the decision is undone.
|
|
95
193
|
*
|
|
96
194
|
* The runner is called once per request, above both the dispatcher and the
|
|
97
195
|
* renderer, rather than from inside each of them. Putting the call inside
|
|
@@ -103,7 +201,7 @@ export type MiddlewareRecord = {|
|
|
|
103
201
|
*/
|
|
104
202
|
export function createMiddlewareRunner(options: {|
|
|
105
203
|
readonly middleware: $ReadOnlyArray<MiddlewareRecord>,
|
|
106
|
-
|}): (request: Request) => Promise<Response | null> {
|
|
204
|
+
|}): (request: Request) => Promise<Response | Request | null> {
|
|
107
205
|
// Root first, so an application-wide check runs before the one that guards a
|
|
108
206
|
// section of it. A shorter path is always an ancestor of a longer one that
|
|
109
207
|
// also matched, so segment count is the whole of the ordering.
|
|
@@ -111,7 +209,7 @@ export function createMiddlewareRunner(options: {|
|
|
|
111
209
|
(a, b) => segmentsOf(a.path).length - segmentsOf(b.path).length,
|
|
112
210
|
);
|
|
113
211
|
|
|
114
|
-
return async function runMiddleware(request: Request): Promise<Response | null> {
|
|
212
|
+
return async function runMiddleware(request: Request): Promise<Response | Request | null> {
|
|
115
213
|
// Checked rather than assumed, and checked before the table so that a host
|
|
116
214
|
// is caught on its first request whether or not this project happens to
|
|
117
215
|
// have a middleware. `createApplicationHandler` makes the same argument
|
|
@@ -129,39 +227,193 @@ export function createMiddlewareRunner(options: {|
|
|
|
129
227
|
return null;
|
|
130
228
|
}
|
|
131
229
|
|
|
132
|
-
const
|
|
230
|
+
const arrived = new URL(request.url);
|
|
231
|
+
const document = documentPathOf(arrived.pathname);
|
|
232
|
+
// The URL the chain is matched against and every middleware is handed: the
|
|
233
|
+
// document's, for a payload request. See "A payload request is its
|
|
234
|
+
// document" above.
|
|
235
|
+
let url = document == null ? arrived : withPathname(arrived, document);
|
|
236
|
+
let seen = document == null ? request : requestAt(request, url);
|
|
237
|
+
let rewritten = false;
|
|
238
|
+
const ran: Set<MiddlewareRecord> = new Set();
|
|
133
239
|
|
|
134
|
-
for (
|
|
240
|
+
for (let index = 0; index < table.length; index += 1) {
|
|
241
|
+
const record = table[index];
|
|
242
|
+
if (ran.has(record)) {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
135
245
|
const params = matchPrefix(record.path, url.pathname);
|
|
136
246
|
if (params == null) {
|
|
137
247
|
continue;
|
|
138
248
|
}
|
|
249
|
+
ran.add(record);
|
|
139
250
|
|
|
140
251
|
const middleware = pick(await record.load(), record.file);
|
|
141
252
|
// In the host's context, not one of this module's own. Two middleware on
|
|
142
253
|
// the same path see the same cookies, and so does the handler or the page
|
|
143
|
-
// underneath them: `draftMode().
|
|
144
|
-
//
|
|
254
|
+
// underneath them: `draftMode().isEnabled` is one answer for the whole
|
|
255
|
+
// request, and every `after()` on the request lands in one ordered list
|
|
145
256
|
// that the host drains once, after the response has gone.
|
|
146
|
-
const result = await middleware(
|
|
147
|
-
if (result
|
|
148
|
-
|
|
257
|
+
const result = await middleware(seen, { params, searchParams: url.searchParams });
|
|
258
|
+
if (result == null) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (result instanceof Rewrite) {
|
|
262
|
+
url = destinationOf(result.destination, url, record.file);
|
|
263
|
+
seen = requestAt(seen, url);
|
|
264
|
+
rewritten = true;
|
|
265
|
+
// From the root again, over what has not run: the destination's guards
|
|
266
|
+
// are owed their say, and the ones that already had it are not asked
|
|
267
|
+
// twice.
|
|
268
|
+
index = -1;
|
|
269
|
+
continue;
|
|
149
270
|
}
|
|
271
|
+
return result;
|
|
150
272
|
}
|
|
151
273
|
|
|
152
|
-
|
|
274
|
+
const admitted: Request | null = !rewritten
|
|
275
|
+
? null
|
|
276
|
+
: document == null
|
|
277
|
+
? seen
|
|
278
|
+
: requestAt(seen, new URL(flightUrl(url.pathname + url.search), url));
|
|
279
|
+
|
|
280
|
+
// A payload rendered over another page: that page's guards have their say
|
|
281
|
+
// too. See "A payload that renders over another page" above.
|
|
282
|
+
if (document != null && request.headers.has(INTERCEPTED_FROM_HEADER)) {
|
|
283
|
+
const carried = admitted ?? request;
|
|
284
|
+
const underneath = interceptionBase(request.headers.get(INTERCEPTED_FROM_HEADER), url);
|
|
285
|
+
if (underneath == null || !(await guardsAdmit(table, carried, underneath))) {
|
|
286
|
+
return withoutInterception(carried);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return admitted;
|
|
153
290
|
};
|
|
154
291
|
}
|
|
155
292
|
|
|
293
|
+
/**
|
|
294
|
+
* The page an intercepted payload names in its header, as a URL on `base`'s
|
|
295
|
+
* origin, or `null` when the value is not a path on this origin.
|
|
296
|
+
*
|
|
297
|
+
* The same shape the renderer accepts (`usableInterceptionBase` in `./rsc.js`,
|
|
298
|
+
* `interceptedFrom` in `@uniflowed/server`): a path, not a network-path
|
|
299
|
+
* reference, with any fragment dropped. Parsed rather than compared as text,
|
|
300
|
+
* so the pathname the guards are matched against is the one the renderer's
|
|
301
|
+
* route matching will read.
|
|
302
|
+
*/
|
|
303
|
+
function interceptionBase(header: string | null, base: URL): URL | null {
|
|
304
|
+
if (header == null || !header.startsWith("/") || header.startsWith("//")) {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
let parsed: URL;
|
|
308
|
+
try {
|
|
309
|
+
parsed = new URL(header, base);
|
|
310
|
+
} catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
if (parsed.origin !== base.origin) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
parsed.hash = "";
|
|
317
|
+
return parsed;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Whether every middleware guarding `underneath` lets this request see it.
|
|
322
|
+
*
|
|
323
|
+
* Each one is asked exactly as it would be asked by a request for that page: a
|
|
324
|
+
* `GET` at its URL carrying this request's headers, with the params of the
|
|
325
|
+
* directory it guards and the page's query. All of them, root first, whether
|
|
326
|
+
* or not they already ran for the URL the request names — a guard that reads
|
|
327
|
+
* the pathname has so far only been asked about the other one.
|
|
328
|
+
*
|
|
329
|
+
* A `Response` is a refusal and so is a `rewrite()`: the page a guard would
|
|
330
|
+
* serve instead is not the page the header names, and the renderer has no way
|
|
331
|
+
* to render one under the other. Nothing either returns is sent; the caller
|
|
332
|
+
* only learns that the page underneath is not this request's to render.
|
|
333
|
+
*/
|
|
334
|
+
async function guardsAdmit(
|
|
335
|
+
table: $ReadOnlyArray<MiddlewareRecord>,
|
|
336
|
+
request: Request,
|
|
337
|
+
underneath: URL,
|
|
338
|
+
): Promise<boolean> {
|
|
339
|
+
const asked = new Request(underneath.href, { method: "GET", headers: request.headers });
|
|
340
|
+
for (const record of table) {
|
|
341
|
+
const params = matchPrefix(record.path, underneath.pathname);
|
|
342
|
+
if (params == null) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const middleware = pick(await record.load(), record.file);
|
|
346
|
+
const result = await middleware(asked, { params, searchParams: underneath.searchParams });
|
|
347
|
+
if (result != null) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* `request` with its `uf-intercepted-from` header taken off, so the renderer
|
|
356
|
+
* answers with the page its URL names and nothing underneath it.
|
|
357
|
+
*
|
|
358
|
+
* Only a payload request reaches here, and a payload is a `GET` or a `HEAD`,
|
|
359
|
+
* so there is no body to hand on.
|
|
360
|
+
*/
|
|
361
|
+
function withoutInterception(request: Request): Request {
|
|
362
|
+
const headers = new Headers(request.headers);
|
|
363
|
+
headers.delete(INTERCEPTED_FROM_HEADER);
|
|
364
|
+
return new Request(request.url, { method: request.method, headers, signal: request.signal });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Where a rewrite goes, resolved against the URL the middleware was handed.
|
|
369
|
+
*
|
|
370
|
+
* Refused by name when it leaves the origin, because the one thing a rewrite
|
|
371
|
+
* promises is that this application answers.
|
|
372
|
+
*/
|
|
373
|
+
function destinationOf(destination: string, base: URL, file: string): URL {
|
|
374
|
+
const next = new URL(destination, base);
|
|
375
|
+
if (next.origin !== base.origin) {
|
|
376
|
+
throw new Error(
|
|
377
|
+
`${file} rewrote ${base.pathname} to ${destination}, which is another origin. A rewrite ` +
|
|
378
|
+
"serves another route of this application: answer with `Response.redirect` to send the " +
|
|
379
|
+
"visitor elsewhere, or fetch the other server from a route handler.",
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (!destination.includes("?")) {
|
|
383
|
+
next.search = base.search;
|
|
384
|
+
}
|
|
385
|
+
next.hash = "";
|
|
386
|
+
return next;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function withPathname(url: URL, pathname: string): URL {
|
|
390
|
+
const next = new URL(url.href);
|
|
391
|
+
next.pathname = pathname;
|
|
392
|
+
return next;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* `request` at another URL: same method, headers, signal and body.
|
|
397
|
+
*
|
|
398
|
+
* A `Request` is a valid `RequestInit`, so a streamed body is handed on rather
|
|
399
|
+
* than read.
|
|
400
|
+
*/
|
|
401
|
+
function requestAt(request: Request, url: URL): Request {
|
|
402
|
+
// $FlowFixMe[incompatible-call] - a `Request` is read as the `RequestInit` it satisfies.
|
|
403
|
+
return new Request(url.href, request);
|
|
404
|
+
}
|
|
405
|
+
|
|
156
406
|
/**
|
|
157
407
|
* The function a middleware module exports.
|
|
158
408
|
*
|
|
159
409
|
* `default` or `middleware`, the same two spellings a page offers for its
|
|
160
410
|
* component. Anything else is an authoring mistake and throws rather than
|
|
161
|
-
* being skipped: a file named
|
|
411
|
+
* being skipped: a file named `$middleware.js` that the router quietly
|
|
162
412
|
* ignored is the bug this whole module exists to stop happening.
|
|
163
413
|
*/
|
|
164
414
|
function pick(module: MiddlewareModule, file: string): Middleware {
|
|
415
|
+
// `rewrite` is an export a middleware module may well import, and is never
|
|
416
|
+
// the middleware itself.
|
|
165
417
|
const exported = typeof module.default === "function" ? module.default : module.middleware;
|
|
166
418
|
if (typeof exported !== "function") {
|
|
167
419
|
throw new Error(
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { Pressable } from "react-native";
|
|
4
|
+
import {
|
|
5
|
+
CommonActions,
|
|
6
|
+
NavigationContainer,
|
|
7
|
+
StackActions,
|
|
8
|
+
createNavigationContainerRef,
|
|
9
|
+
useRoute,
|
|
10
|
+
} from "@react-navigation/native";
|
|
11
|
+
import { resolveNativeNavigation, createNativeRouter, createNativeLinking } from "./native.js";
|
|
12
|
+
import type { NativeLinkSource } from "./native.js";
|
|
13
|
+
import type { RouteTable } from "./internal/routing.js";
|
|
14
|
+
import type {
|
|
15
|
+
NativeLayout,
|
|
16
|
+
NativeNode,
|
|
17
|
+
NativeTree,
|
|
18
|
+
NavigationPayload,
|
|
19
|
+
NavigationState,
|
|
20
|
+
} from "./internal/native-tree.js";
|
|
21
|
+
import { nativeTree, stateForPath, hrefFromState, paramsForPath } from "./internal/native-tree.js";
|
|
22
|
+
|
|
23
|
+
export interface NavigatorPair {
|
|
24
|
+
readonly Navigator: React.ComponentType<{ ... }>;
|
|
25
|
+
readonly Screen: React.ComponentType<{ ... }>;
|
|
26
|
+
}
|
|
27
|
+
export type FileRouter = {
|
|
28
|
+
readonly push: (href: string) => void,
|
|
29
|
+
readonly replace: (href: string) => void,
|
|
30
|
+
readonly back: () => void,
|
|
31
|
+
readonly prefetch: (href: string) => Promise<void>,
|
|
32
|
+
};
|
|
33
|
+
type Scope = {
|
|
34
|
+
tree: NativeTree,
|
|
35
|
+
node: NativeNode,
|
|
36
|
+
screens: Map<NativeNode, React.ComponentType<{ ... }>>,
|
|
37
|
+
stack: NavigatorPair,
|
|
38
|
+
tabs: ?NavigatorPair,
|
|
39
|
+
router: FileRouter,
|
|
40
|
+
};
|
|
41
|
+
const ScopeContext: React.Context<Scope | null> = React.createContext(null);
|
|
42
|
+
|
|
43
|
+
function useScope(): Scope {
|
|
44
|
+
const scope = React.useContext(ScopeContext);
|
|
45
|
+
if (scope == null)
|
|
46
|
+
throw new Error(
|
|
47
|
+
"Native navigation: mount createNativeNavigation().Root before using its navigator or hooks",
|
|
48
|
+
);
|
|
49
|
+
return scope;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The application supplies its own React Navigation navigator factories. */
|
|
53
|
+
export function createNativeNavigation(options: {
|
|
54
|
+
table: RouteTable<>,
|
|
55
|
+
layouts: $ReadOnlyArray<NativeLayout>,
|
|
56
|
+
stack: NavigatorPair,
|
|
57
|
+
tabs?: NavigatorPair,
|
|
58
|
+
initialHref?: string,
|
|
59
|
+
links?: NativeLinkSource,
|
|
60
|
+
}): { Root: React.ComponentType<{ ... }>, router: FileRouter, linking: { ... } } {
|
|
61
|
+
const { table, layouts, stack, tabs } = options;
|
|
62
|
+
const tree = nativeTree(table, layouts);
|
|
63
|
+
const ref = createNavigationContainerRef();
|
|
64
|
+
const screens = new Map<NativeNode, React.ComponentType<{ ... }>>();
|
|
65
|
+
const resolve = (href: string): { names: $ReadOnlyArray<string>, payload: NavigationPayload } => {
|
|
66
|
+
const event = resolveNativeNavigation(table, href);
|
|
67
|
+
const names = tree.paths.get(event.route);
|
|
68
|
+
if (names == null) throw new Error(`Native navigation: no screen for ${event.route}`);
|
|
69
|
+
return { names, payload: { ufHref: event.href, ufParams: event.params } };
|
|
70
|
+
};
|
|
71
|
+
function actionFor(
|
|
72
|
+
names: $ReadOnlyArray<string>,
|
|
73
|
+
payload: NavigationPayload,
|
|
74
|
+
kind: "push" | "replace",
|
|
75
|
+
): { readonly [string]: mixed } {
|
|
76
|
+
if (!ref.isReady()) throw new Error("Native navigation: NavigationContainer is not ready");
|
|
77
|
+
let state: NavigationState = ref.getRootState();
|
|
78
|
+
let depth = 0;
|
|
79
|
+
while (depth < names.length - 1) {
|
|
80
|
+
const active = state.routes[state.index ?? 0];
|
|
81
|
+
if (active?.name !== names[depth] || active?.state == null) break;
|
|
82
|
+
state = active.state;
|
|
83
|
+
depth += 1;
|
|
84
|
+
}
|
|
85
|
+
const name = names[depth];
|
|
86
|
+
const params = paramsForPath(names.slice(depth + 1), payload);
|
|
87
|
+
const action =
|
|
88
|
+
state.type === "stack"
|
|
89
|
+
? StackActions[kind](name, params)
|
|
90
|
+
: CommonActions.navigate({ name, params });
|
|
91
|
+
return { ...action, target: state.key };
|
|
92
|
+
}
|
|
93
|
+
function navigate(href: string, kind: "push" | "replace"): void {
|
|
94
|
+
const { names, payload } = resolve(href);
|
|
95
|
+
ref.dispatch(actionFor(names, payload, kind));
|
|
96
|
+
}
|
|
97
|
+
const router: FileRouter = {
|
|
98
|
+
push: (href) => navigate(href, "push"),
|
|
99
|
+
replace: (href) => navigate(href, "replace"),
|
|
100
|
+
back: () => {
|
|
101
|
+
if (ref.canGoBack()) ref.goBack();
|
|
102
|
+
},
|
|
103
|
+
prefetch: createNativeRouter(table, {}).prefetch,
|
|
104
|
+
};
|
|
105
|
+
const source = options.links == null ? null : createNativeLinking(table, options.links);
|
|
106
|
+
const linking = {
|
|
107
|
+
prefixes: [""],
|
|
108
|
+
getInitialURL: source?.getInitialURL ?? (async () => null),
|
|
109
|
+
subscribe: source?.subscribe ?? (() => () => {}),
|
|
110
|
+
getStateFromPath: (href: string) => {
|
|
111
|
+
const { names, payload } = resolve(
|
|
112
|
+
href.startsWith("/") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(href) ? href : `/${href}`,
|
|
113
|
+
);
|
|
114
|
+
return stateForPath(names, payload);
|
|
115
|
+
},
|
|
116
|
+
getPathFromState: (state: NavigationState) => hrefFromState(state) ?? "/",
|
|
117
|
+
getActionFromState: (state: NavigationState) => {
|
|
118
|
+
const href = hrefFromState(state);
|
|
119
|
+
if (href == null) return undefined;
|
|
120
|
+
const { names, payload } = resolve(href);
|
|
121
|
+
return actionFor(names, payload, "push");
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
function build(node: NativeNode): React.ComponentType<{ ... }> {
|
|
125
|
+
const load = node.module;
|
|
126
|
+
const Content =
|
|
127
|
+
load == null
|
|
128
|
+
? Stack
|
|
129
|
+
: React.lazy(async () => {
|
|
130
|
+
const module: $FlowFixMe = await load();
|
|
131
|
+
const Component = module.default ?? (node.layout ? module.Layout : module.Page);
|
|
132
|
+
if (
|
|
133
|
+
typeof Component !== "function" &&
|
|
134
|
+
(Component == null || typeof Component !== "object")
|
|
135
|
+
) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Native navigation: ${node.name} must export ${node.layout ? "Layout" : "Page"} or default`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return { default: Component };
|
|
141
|
+
});
|
|
142
|
+
function Screen(props: { ... }): React.Node {
|
|
143
|
+
return (
|
|
144
|
+
<ScopeContext value={{ tree, node, screens, stack, tabs, router }}>
|
|
145
|
+
<React.Suspense fallback={null}>
|
|
146
|
+
<Content {...props} />
|
|
147
|
+
</React.Suspense>
|
|
148
|
+
</ScopeContext>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
screens.set(node, Screen);
|
|
152
|
+
for (const child of node.children) build(child);
|
|
153
|
+
return Screen;
|
|
154
|
+
}
|
|
155
|
+
const RootScreen = build(tree.root);
|
|
156
|
+
const initialState =
|
|
157
|
+
options.initialHref == null ? undefined : linking.getStateFromPath(options.initialHref);
|
|
158
|
+
function Root(props: { ... }): React.Node {
|
|
159
|
+
return (
|
|
160
|
+
<NavigationContainer {...props} ref={ref} linking={linking} initialState={initialState}>
|
|
161
|
+
<RootScreen />
|
|
162
|
+
</NavigationContainer>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return { Root, router, linking };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Any React Navigation navigator can consume this segment's direct children. */
|
|
169
|
+
export component Navigator(navigator: NavigatorPair, ...props: { ... }) {
|
|
170
|
+
const scope = useScope();
|
|
171
|
+
const Container = navigator.Navigator;
|
|
172
|
+
const Screen = navigator.Screen;
|
|
173
|
+
return (
|
|
174
|
+
<Container {...props}>
|
|
175
|
+
{scope.node.children.map((node) => (
|
|
176
|
+
<Screen key={node.name} name={node.name} component={scope.screens.get(node)} />
|
|
177
|
+
))}
|
|
178
|
+
</Container>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export component Stack(...props: { ... }) {
|
|
183
|
+
return <Navigator {...props} navigator={useScope().stack} />;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export component Tabs(...props: { ... }) {
|
|
187
|
+
const tabs = useScope().tabs;
|
|
188
|
+
if (tabs == null)
|
|
189
|
+
throw new Error("Native navigation: supply the app's tab navigator to createNativeNavigation");
|
|
190
|
+
return <Navigator {...props} navigator={tabs} />;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function useNativeRouter(): FileRouter {
|
|
194
|
+
return useScope().router;
|
|
195
|
+
}
|
|
196
|
+
export function useParams(): { readonly [string]: string | $ReadOnlyArray<string> } {
|
|
197
|
+
const route = useRoute();
|
|
198
|
+
return route.params?.ufParams ?? {};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export component Link(
|
|
202
|
+
href: string,
|
|
203
|
+
children: React.Node,
|
|
204
|
+
replace: boolean = false,
|
|
205
|
+
...props: { ... }
|
|
206
|
+
) {
|
|
207
|
+
const router = useNativeRouter();
|
|
208
|
+
return (
|
|
209
|
+
<Pressable
|
|
210
|
+
{...props}
|
|
211
|
+
accessibilityRole="link"
|
|
212
|
+
onPress={() => (replace ? router.replace(href) : router.push(href))}
|
|
213
|
+
>
|
|
214
|
+
{children}
|
|
215
|
+
</Pressable>
|
|
216
|
+
);
|
|
217
|
+
}
|