@uniflowed/router 0.0.0-alpha.6 → 0.0.0-alpha.8
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/client.js +35 -2
- package/handler.js +20 -13
- package/index.js +26 -1
- package/internal/request.js +43 -0
- package/internal/runtime.js +790 -24
- package/internal/stream.js +627 -0
- package/middleware.js +211 -0
- package/package.json +5 -3
- package/server.js +310 -27
package/client.js
CHANGED
|
@@ -6,25 +6,58 @@
|
|
|
6
6
|
// The current route's chunks are loaded and its embedded loader data read
|
|
7
7
|
// *before* `hydrateRoot`, so the first client render is synchronous and
|
|
8
8
|
// matches the server's markup exactly.
|
|
9
|
+
//
|
|
10
|
+
// # A route can decline to be hydrated
|
|
11
|
+
//
|
|
12
|
+
// uf's server-component analysis decides which routes have a `"use client"`
|
|
13
|
+
// boundary anywhere in them, and `@uniflowed/vite` leaves the page out of the
|
|
14
|
+
// client route table for the ones that have none. Such a route has nothing in
|
|
15
|
+
// the browser to attach: the document the server wrote is the whole of it. So
|
|
16
|
+
// this returns without calling `hydrateRoot`, and the `<a>` elements a `Link`
|
|
17
|
+
// rendered stay what the server made them — real links the browser follows.
|
|
18
|
+
// See ubugeeei-prod/uf#350.
|
|
9
19
|
|
|
10
20
|
import * as React from "react";
|
|
11
21
|
import { startTransition } from "react";
|
|
12
22
|
import { hydrateRoot } from "react-dom/client";
|
|
13
23
|
|
|
14
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
type AppProps,
|
|
26
|
+
type RouteTable,
|
|
27
|
+
hasClientPage,
|
|
28
|
+
installRoutes,
|
|
29
|
+
matchRoute,
|
|
30
|
+
resolveMatch,
|
|
31
|
+
} from "./internal/runtime.js";
|
|
15
32
|
import { DATA_ID, ROOT_ID } from "./internal/document.js";
|
|
16
33
|
|
|
17
34
|
/**
|
|
18
35
|
* Hydrate the current document.
|
|
36
|
+
*
|
|
37
|
+
* Resolves without mounting anything when the current route ships no client
|
|
38
|
+
* page — see the header. The promise settling is not a claim that React is on
|
|
39
|
+
* the document.
|
|
19
40
|
*/
|
|
20
41
|
export async function hydrate(options: {|
|
|
21
42
|
readonly App: React.ComponentType<AppProps>,
|
|
22
43
|
readonly routes: RouteTable["routes"],
|
|
23
44
|
readonly notFound: RouteTable["notFound"],
|
|
45
|
+
readonly errors: RouteTable["errors"],
|
|
24
46
|
|}): Promise<void> {
|
|
25
|
-
const table: RouteTable = {
|
|
47
|
+
const table: RouteTable = {
|
|
48
|
+
routes: options.routes,
|
|
49
|
+
notFound: options.notFound,
|
|
50
|
+
errors: options.errors,
|
|
51
|
+
};
|
|
26
52
|
installRoutes(table);
|
|
27
53
|
|
|
54
|
+
// Before the loader data is read and before `resolveMatch` is called: both
|
|
55
|
+
// would go looking for a page module that is not in this bundle.
|
|
56
|
+
const matched = matchRoute(table.routes, window.location.pathname);
|
|
57
|
+
if (matched != null && !hasClientPage(matched.route)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
28
61
|
const url = window.location.pathname + window.location.search;
|
|
29
62
|
const embedded = document.getElementById(DATA_ID);
|
|
30
63
|
const data = embedded != null ? JSON.parse(embedded.textContent ?? "null") : undefined;
|
package/handler.js
CHANGED
|
@@ -25,8 +25,16 @@
|
|
|
25
25
|
// It does answer `405` itself when the path matches and the method does not,
|
|
26
26
|
// with the `Allow` header the specification requires — that is not the
|
|
27
27
|
// handler's business, and every handler would otherwise write it.
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
//
|
|
29
|
+
// It also does not establish the request a handler is inside. The host does,
|
|
30
|
+
// around the whole of it, so a handler and the guard above it share one
|
|
31
|
+
// context; see the same section in `./middleware.js`. This module used to
|
|
32
|
+
// build its own and drain it the moment the handler returned, which the
|
|
33
|
+
// comment there called "the response is in hand" — true, and not what
|
|
34
|
+
// `after()` promises. A handler that streams its body has not sent a byte at
|
|
35
|
+
// that point. See ubugeeei-prod/uf#389.
|
|
36
|
+
|
|
37
|
+
import { requireRequest } from "./internal/request.js";
|
|
30
38
|
import type { RouteParams } from "./internal/runtime.js";
|
|
31
39
|
|
|
32
40
|
/** What a handler is given besides the request. */
|
|
@@ -76,6 +84,9 @@ export function createDispatcher(options: {|
|
|
|
76
84
|
const table = [...options.handlers].sort((a, b) => specificity(b.path) - specificity(a.path));
|
|
77
85
|
|
|
78
86
|
return async function dispatch(request: Request): Promise<Response | null> {
|
|
87
|
+
// The host's half of the contract, checked rather than assumed; see
|
|
88
|
+
// `./internal/request.js`.
|
|
89
|
+
requireRequest("dispatch");
|
|
79
90
|
const url = new URL(request.url);
|
|
80
91
|
for (const record of table) {
|
|
81
92
|
const params = matchPath(record.path, url.pathname);
|
|
@@ -90,17 +101,13 @@ export function createDispatcher(options: {|
|
|
|
90
101
|
return methodNotAllowed(module);
|
|
91
102
|
}
|
|
92
103
|
|
|
93
|
-
//
|
|
94
|
-
// or `after()`
|
|
95
|
-
//
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
searchParams: url.searchParams,
|
|
101
|
-
}),
|
|
102
|
-
);
|
|
103
|
-
await drainDeferred(context);
|
|
104
|
+
// In the host's request, so a handler that calls `headers()`,
|
|
105
|
+
// `cookies()` or `after()` answers about the same one its guard did, and
|
|
106
|
+
// what it defers is drained once, by the host, after the bytes are out.
|
|
107
|
+
const response = await handler(request, {
|
|
108
|
+
params,
|
|
109
|
+
searchParams: url.searchParams,
|
|
110
|
+
});
|
|
104
111
|
|
|
105
112
|
// A `HEAD` answered by `GET` must not carry the body. The test is
|
|
106
113
|
// against the module's own `HEAD`, not `pick`'s — `pick` falls back to
|
package/index.js
CHANGED
|
@@ -6,17 +6,29 @@
|
|
|
6
6
|
// `_uf.layout.js`, and `app.js` exports `routerView("./app")`. The route table
|
|
7
7
|
// is generated from the directory at build time; this module is the runtime
|
|
8
8
|
// that matches, loads, navigates and renders it.
|
|
9
|
+
//
|
|
10
|
+
// `_uf.not-found.js` and `_uf.error.js` are the two boundaries: the page for a
|
|
11
|
+
// path that matched nothing, and what renders in place of a subtree that threw.
|
|
12
|
+
// Both are segment files, resolved by the nearest one above the path.
|
|
13
|
+
|
|
14
|
+
import * as React from "react";
|
|
15
|
+
|
|
16
|
+
import type { RouteError } from "./internal/runtime.js";
|
|
9
17
|
|
|
10
18
|
export type {
|
|
11
19
|
AppProps,
|
|
20
|
+
ErrorBoundary,
|
|
21
|
+
ErrorModule,
|
|
12
22
|
LayoutModule,
|
|
13
23
|
LinkPrefetch,
|
|
14
24
|
LoaderArgs,
|
|
15
25
|
Metadata,
|
|
16
26
|
MetadataArgs,
|
|
17
27
|
NavigateOptions,
|
|
28
|
+
NotFoundBoundary,
|
|
18
29
|
PageModule,
|
|
19
30
|
ResolvedRoute,
|
|
31
|
+
RouteError,
|
|
20
32
|
RouteInfo,
|
|
21
33
|
RouteMatch,
|
|
22
34
|
RouteParamSpec,
|
|
@@ -28,19 +40,26 @@ export type {
|
|
|
28
40
|
} from "./internal/runtime.js";
|
|
29
41
|
|
|
30
42
|
export {
|
|
43
|
+
ForbiddenError,
|
|
31
44
|
Link,
|
|
32
45
|
NotFoundError,
|
|
33
46
|
RedirectError,
|
|
34
47
|
RouteView,
|
|
35
48
|
RouterProvider,
|
|
49
|
+
UnauthorizedError,
|
|
50
|
+
forbidden,
|
|
51
|
+
hasClientPage,
|
|
36
52
|
matchRoute,
|
|
37
53
|
notFound,
|
|
38
54
|
parseSearch,
|
|
39
55
|
permanentRedirect,
|
|
40
56
|
redirect,
|
|
57
|
+
resolveFailure,
|
|
41
58
|
resolveMatch,
|
|
59
|
+
routeErrorStatus,
|
|
42
60
|
routerView,
|
|
43
61
|
splitUrl,
|
|
62
|
+
unauthorized,
|
|
44
63
|
useIsServer,
|
|
45
64
|
useLoaderData,
|
|
46
65
|
useRoute,
|
|
@@ -57,10 +76,16 @@ export type PageProps<
|
|
|
57
76
|
readonly data: TData,
|
|
58
77
|
|};
|
|
59
78
|
|
|
79
|
+
/** Props an `_uf.error.js` component receives. */
|
|
80
|
+
export type ErrorProps = {|
|
|
81
|
+
readonly error: RouteError,
|
|
82
|
+
readonly reset: () => void,
|
|
83
|
+
|};
|
|
84
|
+
|
|
60
85
|
/** Props a layout receives. */
|
|
61
86
|
export type LayoutProps<
|
|
62
87
|
TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
|
|
63
88
|
> = {|
|
|
64
89
|
readonly params: TParams,
|
|
65
|
-
readonly children: React
|
|
90
|
+
readonly children: React.Node,
|
|
66
91
|
|};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the request the server half runs inside.
|
|
4
|
+
//
|
|
5
|
+
// One function, and it exists because two modules need the same refusal.
|
|
6
|
+
// `createMiddlewareRunner` and `createDispatcher` both used to build a request
|
|
7
|
+
// context of their own — `contextFor`, then `runWithContext`, then
|
|
8
|
+
// `drainDeferred` — which gave one request up to two contexts and ran what
|
|
9
|
+
// `after()` deferred before the response was written, and in the middleware's
|
|
10
|
+
// case before there was a response at all. See ubugeeei-prod/uf#389.
|
|
11
|
+
//
|
|
12
|
+
// Both now run inside whatever request the host established, which means both
|
|
13
|
+
// depend on the host having established one. That dependency is checked rather
|
|
14
|
+
// than assumed, for the reason `createApplicationHandler` gives about
|
|
15
|
+
// `entry.runMiddleware` itself: a missing half of the contract should be a
|
|
16
|
+
// failure on the first request, not an application that answers strangely.
|
|
17
|
+
//
|
|
18
|
+
// Server-only, like the two modules that import it. Nothing the browser loads
|
|
19
|
+
// reaches this.
|
|
20
|
+
|
|
21
|
+
import { insideRequest } from "@uniflowed/server/host";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Refuse to run outside a request, naming what has to establish one.
|
|
25
|
+
*
|
|
26
|
+
* The message is addressed to whoever wired the host, because that is who can
|
|
27
|
+
* fix it. Left to fail on its own, a mis-wired host would surface as the first
|
|
28
|
+
* `cookies()` in somebody's guard throwing "called outside a request … a
|
|
29
|
+
* static prerender, a module's top level, or a client component" — three
|
|
30
|
+
* places to look, none of them this one — and an application that calls no
|
|
31
|
+
* server function at all would sail past that and lose every `after()` instead.
|
|
32
|
+
*/
|
|
33
|
+
export function requireRequest(entry: string): void {
|
|
34
|
+
if (insideRequest()) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
throw new Error(
|
|
38
|
+
`@uniflowed/router: ${entry}() was called outside a request. A host owns the request: ` +
|
|
39
|
+
"begin it with `beginRequest` from `@uniflowed/server/host` (a bundled application " +
|
|
40
|
+
"re-exports it from `virtual:uf/server`), run the whole request inside `run`, and call " +
|
|
41
|
+
"`settle` once the response has been sent. See ubugeeei-prod/uf#389.",
|
|
42
|
+
);
|
|
43
|
+
}
|