@uniflowed/router 0.0.0-alpha.34 → 0.0.0-alpha.37
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 +24 -40
- package/index.js +1 -0
- package/internal/base-path.js +175 -0
- package/internal/boundaries.js +48 -25
- package/internal/compose.js +478 -0
- package/internal/error-view.js +189 -0
- package/internal/flight-browser.js +228 -0
- package/internal/flight-chunks.js +181 -0
- package/internal/flight-ssr.js +78 -0
- package/internal/flight.js +158 -0
- package/internal/head.js +219 -0
- package/internal/prepare-document.js +49 -0
- package/internal/react-version.js +77 -0
- package/internal/resolve.js +1615 -0
- package/internal/runtime.js +605 -2465
- package/internal/server-route.js +58 -0
- package/internal/shell.js +107 -0
- package/internal/stream.js +216 -3
- package/middleware.js +144 -14
- package/package.json +25 -6
- package/rsc-client.js +117 -0
- package/rsc-ssr.js +432 -0
- package/rsc.js +329 -0
- package/server-components.js +159 -0
- package/server.js +42 -88
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the route a server component is rendering in.
|
|
4
|
+
//
|
|
5
|
+
// `useRoute()` in the browser reads the router's context, and a server
|
|
6
|
+
// component has no context to read. The graph it renders in resolves `react`
|
|
7
|
+
// under the `react-server` condition, which is the build with no `useContext`
|
|
8
|
+
// in it (ubugeeei-prod/uf#519), and a layout that highlights the section it is
|
|
9
|
+
// in — this repository's own documentation site has two — would otherwise have
|
|
10
|
+
// to become a client component to find out where it is.
|
|
11
|
+
//
|
|
12
|
+
// So the Flight renderer runs each render inside a store holding the route it
|
|
13
|
+
// resolved, and the server half of the hooks reads it back. `AsyncLocalStorage`
|
|
14
|
+
// and not React's `cache`: `cache` finds its render through React's own request
|
|
15
|
+
// storage, which the build React ships for Deno does not have, so after the
|
|
16
|
+
// first `await` in an async server component it would stop finding the render
|
|
17
|
+
// at all. A store of this module's own follows every continuation of the
|
|
18
|
+
// render on every host `@uniflowed/server` already runs on, and it is one value
|
|
19
|
+
// per render, so two requests in flight cannot read each other's route.
|
|
20
|
+
//
|
|
21
|
+
// Server-only by construction: nothing in the browser's graph imports this.
|
|
22
|
+
|
|
23
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
24
|
+
|
|
25
|
+
import type { RouteState } from "./flight.js";
|
|
26
|
+
import { requireServerComponentsReact } from "./react-version.js";
|
|
27
|
+
|
|
28
|
+
const storage: AsyncLocalStorage<RouteState> = new AsyncLocalStorage();
|
|
29
|
+
|
|
30
|
+
/** Run `body` as a render of `route`. Everything it starts sees the route. */
|
|
31
|
+
export function withServerRoute<T>(route: RouteState, body: () => T): T {
|
|
32
|
+
return storage.run(route, body);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The route being rendered, or a refusal naming the caller.
|
|
37
|
+
*
|
|
38
|
+
* A refusal rather than `null`, because the only way to arrive here without a
|
|
39
|
+
* route is to call a router hook from a server module that is not being
|
|
40
|
+
* rendered by the router — a script, a route handler, a module evaluated at
|
|
41
|
+
* import time — and each of those is a mistake worth a sentence.
|
|
42
|
+
*
|
|
43
|
+
* The React version is checked first. On a React older than 19.3 the Flight
|
|
44
|
+
* renderer that would have put the hook inside a route refuses to start, so the
|
|
45
|
+
* sentence worth reading is that one, not "outside a route".
|
|
46
|
+
*/
|
|
47
|
+
export function serverRoute(caller: string): RouteState {
|
|
48
|
+
requireServerComponentsReact(`${caller}()`);
|
|
49
|
+
const route = storage.getStore();
|
|
50
|
+
if (route == null) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`@uniflowed/router: ${caller}() was called in a server module outside a route the router ` +
|
|
53
|
+
"is rendering. Server Components read the route they render in; a route handler reads " +
|
|
54
|
+
"the request it was given, and a module evaluated at import time has no route at all.",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return route;
|
|
58
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the documents an HTML renderer answers with
|
|
4
|
+
// that are not a route's own markup — uf's shell around that markup, and a
|
|
5
|
+
// redirect.
|
|
6
|
+
//
|
|
7
|
+
// `../server.js` renders a route from its modules, and `../rsc-ssr.js` renders
|
|
8
|
+
// one from the payload React Server Components wrote. They are two entries so
|
|
9
|
+
// that the second, which loads React's Flight client, stays out of every bundle
|
|
10
|
+
// that renders no Server Component (ubugeeei-prod/uf#992). Both write the same
|
|
11
|
+
// documents around what they render, which is why those documents live here and
|
|
12
|
+
// not in either entry.
|
|
13
|
+
|
|
14
|
+
import type { PrerenderResult, RenderAssets, RenderResult } from "../server.js";
|
|
15
|
+
import { addressOf } from "./base-path.js";
|
|
16
|
+
import { ROOT_ID } from "./document.js";
|
|
17
|
+
import type { RedirectError } from "./routing.js";
|
|
18
|
+
import { type DocumentShell, bodyOfText } from "./stream.js";
|
|
19
|
+
|
|
20
|
+
/** A redirect, as the finished document `prerender` answers with. */
|
|
21
|
+
export async function redirectResult(document: RenderResult): Promise<PrerenderResult> {
|
|
22
|
+
return {
|
|
23
|
+
status: document.status,
|
|
24
|
+
headers: document.headers,
|
|
25
|
+
html: await document.text(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function redirectDocument(error: RedirectError): RenderResult {
|
|
30
|
+
// Under `app.router.basePath`, and in the trailing-slash policy's spelling:
|
|
31
|
+
// `redirect("/sign-in")` names an application path, and a browser follows
|
|
32
|
+
// an address.
|
|
33
|
+
const address = addressOf(error.to);
|
|
34
|
+
const target = escapeAttribute(address);
|
|
35
|
+
// A document rather than an empty body, because a redirect is still an answer
|
|
36
|
+
// a browser may be shown; it goes through the same three methods as a
|
|
37
|
+
// rendered one so that a host has one shape to write, not two.
|
|
38
|
+
const body = bodyOfText(
|
|
39
|
+
`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=${target}"><title>Redirecting</title></head><body><a href="${target}">Redirecting…</a></body></html>\n`,
|
|
40
|
+
);
|
|
41
|
+
return {
|
|
42
|
+
status: error.permanent ? 308 : 307,
|
|
43
|
+
headers: { Location: address },
|
|
44
|
+
pipe: body.pipe,
|
|
45
|
+
stream: body.stream,
|
|
46
|
+
text: body.text,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The document uf writes around the app's markup.
|
|
52
|
+
*
|
|
53
|
+
* The same two shapes `assemble` chose between, decided from the same evidence
|
|
54
|
+
* — whether the markup opens with `<html>` — but stated up front instead of
|
|
55
|
+
* afterwards, because a stream has no "afterwards" in which to splice a head.
|
|
56
|
+
* An app whose root layout renders `<html>` owns the whole document and the
|
|
57
|
+
* client hydrates `document`, so uf contributes only the tags that go before
|
|
58
|
+
* `</head>`. An app that renders only content is wrapped in a minimal shell
|
|
59
|
+
* around `<div id="uf-root">`, which is what the client hydrates instead.
|
|
60
|
+
*
|
|
61
|
+
* `internal/stream.js` picks between them on the opening bytes React writes;
|
|
62
|
+
* everything either shape is made of is here, so what a uf document contains is
|
|
63
|
+
* still readable in one place.
|
|
64
|
+
*
|
|
65
|
+
* # Why the shell is three strings and not one
|
|
66
|
+
*
|
|
67
|
+
* Because uf's own `<head>` has to still be open when React's head tags arrive.
|
|
68
|
+
* React hoists a `<title>`, a `<meta>` and a `<link>` into the head it wrote
|
|
69
|
+
* itself, and here it wrote none — so with one string this shell closed its
|
|
70
|
+
* head before the app had rendered a byte, and every `og:` tag and the
|
|
71
|
+
* `<link rel="canonical">` landed in the body, where a crawler ignores them.
|
|
72
|
+
* `open` is uf's head up to that point, `body` is the rest of it and the
|
|
73
|
+
* wrapper, and what goes between them is whatever `assembled` lifts out of the
|
|
74
|
+
* app's own markup. See ubugeeei-prod/uf#547.
|
|
75
|
+
*
|
|
76
|
+
* That is also why no `<title>` is written here any more. It was, from
|
|
77
|
+
* `resolved.metadata.title` — the same string `Head` renders — so a document
|
|
78
|
+
* carried two of them, one in each place, and only one was where a browser
|
|
79
|
+
* looks. Hoisting the rendered one leaves the metadata with a single source.
|
|
80
|
+
*/
|
|
81
|
+
export function shellFor(assets: RenderAssets): DocumentShell {
|
|
82
|
+
const head = headTags(assets);
|
|
83
|
+
return {
|
|
84
|
+
head,
|
|
85
|
+
open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
86
|
+
body: `${head}</head><body><div id="${ROOT_ID}">`,
|
|
87
|
+
close: `</div></body></html>\n`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function headTags(assets: RenderAssets): string {
|
|
92
|
+
let tags = "";
|
|
93
|
+
for (const href of assets.styles) {
|
|
94
|
+
tags += `<link rel="stylesheet" href="${escapeAttribute(href)}">`;
|
|
95
|
+
}
|
|
96
|
+
for (const href of assets.preloads) {
|
|
97
|
+
tags += `<link rel="modulepreload" href="${escapeAttribute(href)}">`;
|
|
98
|
+
}
|
|
99
|
+
for (const src of assets.scripts) {
|
|
100
|
+
tags += `<script type="module" src="${escapeAttribute(src)}"></script>`;
|
|
101
|
+
}
|
|
102
|
+
return tags;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function escapeAttribute(value: string): string {
|
|
106
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
107
|
+
}
|
package/internal/stream.js
CHANGED
|
@@ -43,6 +43,7 @@ import * as React from "react";
|
|
|
43
43
|
import * as ReactDOMServer from "react-dom/server";
|
|
44
44
|
import * as ReactDOMStatic from "react-dom/static";
|
|
45
45
|
|
|
46
|
+
import { createChunkEncoder } from "./flight-chunks.js";
|
|
46
47
|
import { type StreamRecord, inspected } from "./inspector.js";
|
|
47
48
|
|
|
48
49
|
/**
|
|
@@ -656,6 +657,16 @@ export type RenderOptions = {|
|
|
|
656
657
|
* everything before it writes a byte, so there is no order to report.
|
|
657
658
|
*/
|
|
658
659
|
readonly onStream?: (record: StreamRecord) => void,
|
|
660
|
+
/**
|
|
661
|
+
* The Flight payload this document's tree was read from, to write into the
|
|
662
|
+
* document for the browser to hydrate from.
|
|
663
|
+
*
|
|
664
|
+
* Absent for a document rendered from the route's modules, which is what
|
|
665
|
+
* every document was before ubugeeei-prod/uf#519 and what `app.rsc: false`
|
|
666
|
+
* still asks for. Present, it is copied into the document as it arrives, by
|
|
667
|
+
* [`interleaved`], which says where it may and may not go.
|
|
668
|
+
*/
|
|
669
|
+
readonly payload?: ReadableStream<Uint8Array>,
|
|
659
670
|
|};
|
|
660
671
|
|
|
661
672
|
/**
|
|
@@ -692,7 +703,10 @@ export function renderDocument(node: React.Node, options: RenderOptions): Promis
|
|
|
692
703
|
resolve(
|
|
693
704
|
bodyOf(
|
|
694
705
|
outgoing(
|
|
695
|
-
|
|
706
|
+
withPayload(
|
|
707
|
+
assembled(queue.chunks(), options.shell, options.transformHead),
|
|
708
|
+
options.payload,
|
|
709
|
+
),
|
|
696
710
|
options.onStream,
|
|
697
711
|
),
|
|
698
712
|
() => abort(),
|
|
@@ -759,7 +773,10 @@ export function renderWithReadableStream(
|
|
|
759
773
|
(stream: ByteSource) =>
|
|
760
774
|
bodyOf(
|
|
761
775
|
outgoing(
|
|
762
|
-
|
|
776
|
+
withPayload(
|
|
777
|
+
assembled(decoded(stream), options.shell, options.transformHead),
|
|
778
|
+
options.payload,
|
|
779
|
+
),
|
|
763
780
|
options.onStream,
|
|
764
781
|
),
|
|
765
782
|
() => {
|
|
@@ -815,10 +832,206 @@ export async function prerenderDocument(node: React.Node, options: RenderOptions
|
|
|
815
832
|
? await ReactDOMStatic.prerenderToNodeStream(node, settings)
|
|
816
833
|
: await ReactDOMStatic.prerender(node, settings);
|
|
817
834
|
return bodyOf(
|
|
818
|
-
|
|
835
|
+
withPayload(
|
|
836
|
+
assembled(preludeChunks(result.prelude), options.shell, options.transformHead),
|
|
837
|
+
options.payload,
|
|
838
|
+
),
|
|
819
839
|
).text();
|
|
820
840
|
}
|
|
821
841
|
|
|
842
|
+
/** `chunks` unchanged when there is no payload, and [`interleaved`] with one. */
|
|
843
|
+
function withPayload(
|
|
844
|
+
chunks: AsyncGenerator<string, void, void>,
|
|
845
|
+
payload: ?ReadableStream<Uint8Array>,
|
|
846
|
+
): AsyncGenerator<string, void, void> {
|
|
847
|
+
return payload == null ? chunks : interleaved(chunks, payload);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* A document's chunks, with the Flight payload it was rendered from written
|
|
852
|
+
* into it as it arrives.
|
|
853
|
+
*
|
|
854
|
+
* Four rules, and each is the answer to a way the obvious version is wrong.
|
|
855
|
+
*
|
|
856
|
+
* **Nothing before the head.** The first chunk this is handed is the whole
|
|
857
|
+
* opening of the document — `assembled` does not let one go until the head is
|
|
858
|
+
* complete — and a payload element written in front of it would sit before
|
|
859
|
+
* `<head>`, where the parser would open a body for it and every tag after it
|
|
860
|
+
* would land in the wrong element.
|
|
861
|
+
*
|
|
862
|
+
* **Written as soon as it exists.** A payload row usually exists before the
|
|
863
|
+
* HTML rendered from it — React's client reads the row, then the boundary
|
|
864
|
+
* renders — so waiting for the next HTML chunk would put the browser's copy
|
|
865
|
+
* behind the markup it hydrates. Each HTML chunk that ends between elements is
|
|
866
|
+
* followed by whatever payload is waiting, and a payload that arrives while the
|
|
867
|
+
* HTML is idle there is written then, without a chunk of HTML to follow.
|
|
868
|
+
*
|
|
869
|
+
* **Only between elements.** React hands its HTML on through a fixed-size
|
|
870
|
+
* buffer, so a chunk can end anywhere: inside a tag, an attribute's value, a
|
|
871
|
+
* comment, a character reference or an inline `<script>`. A payload element
|
|
872
|
+
* written after such a chunk is not an element. It is part of the attribute,
|
|
873
|
+
* the comment or the text it landed in, the browser never reads it, and React's
|
|
874
|
+
* client closes the payload with rows missing, which is the "Connection closed"
|
|
875
|
+
* a page reports instead of hydrating. So a waiting payload goes out only where
|
|
876
|
+
* the HTML written so far ends between elements, which [`advanced`] follows
|
|
877
|
+
* from chunk to chunk, and otherwise waits for the HTML that finishes what is
|
|
878
|
+
* open.
|
|
879
|
+
*
|
|
880
|
+
* **`</body></html>` waits for the end of the payload.** The HTML can finish
|
|
881
|
+
* first — the last boundary's markup is rendered from rows that are already
|
|
882
|
+
* written — and a payload element after `</html>` is one the parser moves
|
|
883
|
+
* rather than one React expects. So the closing tags are held back, the rest of
|
|
884
|
+
* the payload and its end marker are written, and the tags go last. For a
|
|
885
|
+
* document uf wraps, the closing run is `</div></body></html>`, and only the
|
|
886
|
+
* part from `</body>` is held: the root element React hydrates must hold
|
|
887
|
+
* nothing React did not render.
|
|
888
|
+
*
|
|
889
|
+
* A consumer that stops early stops the payload too: the reader is cancelled
|
|
890
|
+
* in the `finally`, which is where `return()` on this generator lands.
|
|
891
|
+
*/
|
|
892
|
+
async function* interleaved(
|
|
893
|
+
chunks: AsyncGenerator<string, void, void>,
|
|
894
|
+
payload: ReadableStream<Uint8Array>,
|
|
895
|
+
): AsyncGenerator<string, void, void> {
|
|
896
|
+
const encoder = createChunkEncoder();
|
|
897
|
+
const reader = payload.getReader();
|
|
898
|
+
let written = "";
|
|
899
|
+
let ended = false;
|
|
900
|
+
let wake: ?() => void = null;
|
|
901
|
+
const ring = () => {
|
|
902
|
+
const resume = wake;
|
|
903
|
+
wake = null;
|
|
904
|
+
if (resume != null) {
|
|
905
|
+
resume();
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
const pumping = (async () => {
|
|
909
|
+
try {
|
|
910
|
+
while (true) {
|
|
911
|
+
const step = await reader.read();
|
|
912
|
+
if (step.done === true) {
|
|
913
|
+
break;
|
|
914
|
+
}
|
|
915
|
+
if (step.value != null) {
|
|
916
|
+
written += encoder.encode(step.value);
|
|
917
|
+
ring();
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
} catch {
|
|
921
|
+
// A payload that failed partway is still ended, with the end marker, so
|
|
922
|
+
// the browser's reader closes and React reports the rows it never got
|
|
923
|
+
// rather than waiting for them for as long as the page is open.
|
|
924
|
+
} finally {
|
|
925
|
+
written += encoder.end();
|
|
926
|
+
ended = true;
|
|
927
|
+
ring();
|
|
928
|
+
}
|
|
929
|
+
})();
|
|
930
|
+
|
|
931
|
+
// Nothing written yet, and nothing may go before the head.
|
|
932
|
+
let boundary: Boundary = NOTHING_WRITTEN;
|
|
933
|
+
let closing = "";
|
|
934
|
+
try {
|
|
935
|
+
let next = chunks.next();
|
|
936
|
+
while (true) {
|
|
937
|
+
if (boundary.safe && written !== "") {
|
|
938
|
+
const out = written;
|
|
939
|
+
written = "";
|
|
940
|
+
yield out;
|
|
941
|
+
}
|
|
942
|
+
const idle = new Promise<null>((resolve) => {
|
|
943
|
+
wake = () => resolve(null);
|
|
944
|
+
});
|
|
945
|
+
const outcome = await Promise.race([next, idle]);
|
|
946
|
+
if (outcome == null) {
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
if (outcome.done === true) {
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
let text = outcome.value;
|
|
953
|
+
const close = text.search(/<\/body>\s*<\/html>\s*$/i);
|
|
954
|
+
if (close !== -1) {
|
|
955
|
+
closing = text.slice(close);
|
|
956
|
+
text = text.slice(0, close);
|
|
957
|
+
}
|
|
958
|
+
if (text !== "") {
|
|
959
|
+
yield text;
|
|
960
|
+
boundary = advanced(boundary, text);
|
|
961
|
+
}
|
|
962
|
+
next = chunks.next();
|
|
963
|
+
}
|
|
964
|
+
while (!ended || written !== "") {
|
|
965
|
+
if (written !== "") {
|
|
966
|
+
const out = written;
|
|
967
|
+
written = "";
|
|
968
|
+
yield out;
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
await new Promise<void>((resolve) => {
|
|
972
|
+
wake = resolve;
|
|
973
|
+
if (ended || written !== "") {
|
|
974
|
+
ring();
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
await pumping;
|
|
979
|
+
yield closing;
|
|
980
|
+
} finally {
|
|
981
|
+
if (!ended) {
|
|
982
|
+
void reader.cancel();
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** Where the HTML written so far leaves the next payload element. */
|
|
988
|
+
type Boundary = {|
|
|
989
|
+
/** Whether a payload element may be written now. */
|
|
990
|
+
readonly safe: boolean,
|
|
991
|
+
/** The `script` or `style` element the HTML is inside, if it is inside one. */
|
|
992
|
+
readonly rawText: string | null,
|
|
993
|
+
/** A tag, or a comment, the HTML has started and not yet finished. */
|
|
994
|
+
readonly open: string,
|
|
995
|
+
|};
|
|
996
|
+
|
|
997
|
+
const NOTHING_WRITTEN: Boundary = { safe: false, rawText: null, open: "" };
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* `boundary`, once `html` has been written after it.
|
|
1001
|
+
*
|
|
1002
|
+
* A payload element may follow HTML that ends with a `>` outside a `<script>`
|
|
1003
|
+
* and a `<style>`. The test can be that short because React wrote the HTML: it
|
|
1004
|
+
* escapes `<` and `>` in text and in attribute values, so a `>` it wrote closes
|
|
1005
|
+
* a tag or a comment, and HTML that ends any other way ends inside one, or
|
|
1006
|
+
* inside a character reference or a run of text. What React does not escape is
|
|
1007
|
+
* the content of an inline script or stylesheet, where a `>` is code, so an
|
|
1008
|
+
* opening `<script>` or `<style>` is followed to its closing tag. A tag split
|
|
1009
|
+
* across two chunks is carried in `open` and read whole with the next one.
|
|
1010
|
+
*/
|
|
1011
|
+
function advanced(boundary: Boundary, html: string): Boundary {
|
|
1012
|
+
const text = boundary.open + html;
|
|
1013
|
+
let rawText = boundary.rawText;
|
|
1014
|
+
const tags = /<(\/?)(script|style)(?=[\s/>])[^>]*>/gi;
|
|
1015
|
+
let tag = tags.exec(text);
|
|
1016
|
+
while (tag != null) {
|
|
1017
|
+
const name = tag[2].toLowerCase();
|
|
1018
|
+
if (tag[1] === "/") {
|
|
1019
|
+
if (rawText === name) {
|
|
1020
|
+
rawText = null;
|
|
1021
|
+
}
|
|
1022
|
+
} else if (rawText == null) {
|
|
1023
|
+
rawText = name;
|
|
1024
|
+
}
|
|
1025
|
+
tag = tags.exec(text);
|
|
1026
|
+
}
|
|
1027
|
+
const start = text.lastIndexOf("<");
|
|
1028
|
+
return {
|
|
1029
|
+
safe: rawText == null && text.endsWith(">"),
|
|
1030
|
+
rawText,
|
|
1031
|
+
open: start > text.lastIndexOf(">") ? text.slice(start) : "",
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
822
1035
|
/**
|
|
823
1036
|
* The prelude of a static prerender, whichever stream this build produced.
|
|
824
1037
|
*
|
package/middleware.js
CHANGED
|
@@ -27,10 +27,38 @@
|
|
|
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.
|
|
34
62
|
//
|
|
35
63
|
// # The request it runs inside
|
|
36
64
|
//
|
|
@@ -68,6 +96,7 @@
|
|
|
68
96
|
// `routesModuleSource` keeps the middleware table in an export the client
|
|
69
97
|
// never imports, for the same reason it does that with route handlers.
|
|
70
98
|
|
|
99
|
+
import { documentPathOf, flightUrl } from "./internal/flight.js";
|
|
71
100
|
import { requireRequest } from "./internal/request.js";
|
|
72
101
|
import type { RouteParams } from "./internal/runtime.js";
|
|
73
102
|
|
|
@@ -79,11 +108,39 @@ export type MiddlewareContext = {|
|
|
|
79
108
|
readonly searchParams: URLSearchParams,
|
|
80
109
|
|};
|
|
81
110
|
|
|
111
|
+
/**
|
|
112
|
+
* What a middleware returns to serve another route at the requested address.
|
|
113
|
+
*
|
|
114
|
+
* Built by [`rewrite`] and read by the runner; a class so that the runner can
|
|
115
|
+
* tell it from a `Response` without trusting the shape of an object.
|
|
116
|
+
*/
|
|
117
|
+
export class Rewrite {
|
|
118
|
+
readonly destination: string;
|
|
119
|
+
|
|
120
|
+
constructor(destination: string) {
|
|
121
|
+
this.destination = destination;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Serve `destination` — a path of this application — in place of the path the
|
|
127
|
+
* request named.
|
|
128
|
+
*
|
|
129
|
+
* Relative to the request, so `"/beta/pricing"` and `"../pricing"` both work.
|
|
130
|
+
* A destination that names no query keeps the request's; one that names a
|
|
131
|
+
* query replaces it. Another origin is refused when the middleware returns it:
|
|
132
|
+
* sending a visitor elsewhere is `Response.redirect`, and proxying to another
|
|
133
|
+
* server is a route handler that fetches.
|
|
134
|
+
*/
|
|
135
|
+
export function rewrite(destination: string | URL): Rewrite {
|
|
136
|
+
return new Rewrite(typeof destination === "string" ? destination : destination.href);
|
|
137
|
+
}
|
|
138
|
+
|
|
82
139
|
/** One middleware function. */
|
|
83
140
|
export type Middleware = (
|
|
84
141
|
request: Request,
|
|
85
142
|
context: MiddlewareContext,
|
|
86
|
-
) => Response | void | Promise<Response | void>;
|
|
143
|
+
) => Response | Rewrite | void | Promise<Response | Rewrite | void>;
|
|
87
144
|
|
|
88
145
|
/** A middleware module, as the generated table loads it. */
|
|
89
146
|
export type MiddlewareModule = { readonly [name: string]: mixed };
|
|
@@ -100,7 +157,10 @@ export type MiddlewareRecord = {|
|
|
|
100
157
|
* Build the middleware runner for one application.
|
|
101
158
|
*
|
|
102
159
|
* Returns `null` when every middleware on the path declined, which is the
|
|
103
|
-
* caller's signal to carry on to the handler or the page.
|
|
160
|
+
* caller's signal to carry on to the handler or the page. Returns a `Request`
|
|
161
|
+
* when one of them rewrote: the same request at the destination, which the
|
|
162
|
+
* caller carries on with instead — and which has already been past the
|
|
163
|
+
* destination's middleware.
|
|
104
164
|
*
|
|
105
165
|
* The runner is called once per request, above both the dispatcher and the
|
|
106
166
|
* renderer, rather than from inside each of them. Putting the call inside
|
|
@@ -112,7 +172,7 @@ export type MiddlewareRecord = {|
|
|
|
112
172
|
*/
|
|
113
173
|
export function createMiddlewareRunner(options: {|
|
|
114
174
|
readonly middleware: $ReadOnlyArray<MiddlewareRecord>,
|
|
115
|
-
|}): (request: Request) => Promise<Response | null> {
|
|
175
|
+
|}): (request: Request) => Promise<Response | Request | null> {
|
|
116
176
|
// Root first, so an application-wide check runs before the one that guards a
|
|
117
177
|
// section of it. A shorter path is always an ancestor of a longer one that
|
|
118
178
|
// also matched, so segment count is the whole of the ordering.
|
|
@@ -120,7 +180,7 @@ export function createMiddlewareRunner(options: {|
|
|
|
120
180
|
(a, b) => segmentsOf(a.path).length - segmentsOf(b.path).length,
|
|
121
181
|
);
|
|
122
182
|
|
|
123
|
-
return async function runMiddleware(request: Request): Promise<Response | null> {
|
|
183
|
+
return async function runMiddleware(request: Request): Promise<Response | Request | null> {
|
|
124
184
|
// Checked rather than assumed, and checked before the table so that a host
|
|
125
185
|
// is caught on its first request whether or not this project happens to
|
|
126
186
|
// have a middleware. `createApplicationHandler` makes the same argument
|
|
@@ -138,13 +198,26 @@ export function createMiddlewareRunner(options: {|
|
|
|
138
198
|
return null;
|
|
139
199
|
}
|
|
140
200
|
|
|
141
|
-
const
|
|
201
|
+
const arrived = new URL(request.url);
|
|
202
|
+
const document = documentPathOf(arrived.pathname);
|
|
203
|
+
// The URL the chain is matched against and every middleware is handed: the
|
|
204
|
+
// document's, for a payload request. See "A payload request is its
|
|
205
|
+
// document" above.
|
|
206
|
+
let url = document == null ? arrived : withPathname(arrived, document);
|
|
207
|
+
let seen = document == null ? request : requestAt(request, url);
|
|
208
|
+
let rewritten = false;
|
|
209
|
+
const ran: Set<MiddlewareRecord> = new Set();
|
|
142
210
|
|
|
143
|
-
for (
|
|
211
|
+
for (let index = 0; index < table.length; index += 1) {
|
|
212
|
+
const record = table[index];
|
|
213
|
+
if (ran.has(record)) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
144
216
|
const params = matchPrefix(record.path, url.pathname);
|
|
145
217
|
if (params == null) {
|
|
146
218
|
continue;
|
|
147
219
|
}
|
|
220
|
+
ran.add(record);
|
|
148
221
|
|
|
149
222
|
const middleware = pick(await record.load(), record.file);
|
|
150
223
|
// In the host's context, not one of this module's own. Two middleware on
|
|
@@ -152,16 +225,71 @@ export function createMiddlewareRunner(options: {|
|
|
|
152
225
|
// underneath them: `draftMode().isEnabled` is one answer for the whole
|
|
153
226
|
// request, and every `after()` on the request lands in one ordered list
|
|
154
227
|
// that the host drains once, after the response has gone.
|
|
155
|
-
const result = await middleware(
|
|
156
|
-
if (result
|
|
157
|
-
|
|
228
|
+
const result = await middleware(seen, { params, searchParams: url.searchParams });
|
|
229
|
+
if (result == null) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (result instanceof Rewrite) {
|
|
233
|
+
url = destinationOf(result.destination, url, record.file);
|
|
234
|
+
seen = requestAt(seen, url);
|
|
235
|
+
rewritten = true;
|
|
236
|
+
// From the root again, over what has not run: the destination's guards
|
|
237
|
+
// are owed their say, and the ones that already had it are not asked
|
|
238
|
+
// twice.
|
|
239
|
+
index = -1;
|
|
240
|
+
continue;
|
|
158
241
|
}
|
|
242
|
+
return result;
|
|
159
243
|
}
|
|
160
244
|
|
|
161
|
-
|
|
245
|
+
if (!rewritten) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
return document == null
|
|
249
|
+
? seen
|
|
250
|
+
: requestAt(seen, new URL(flightUrl(url.pathname + url.search), url));
|
|
162
251
|
};
|
|
163
252
|
}
|
|
164
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Where a rewrite goes, resolved against the URL the middleware was handed.
|
|
256
|
+
*
|
|
257
|
+
* Refused by name when it leaves the origin, because the one thing a rewrite
|
|
258
|
+
* promises is that this application answers.
|
|
259
|
+
*/
|
|
260
|
+
function destinationOf(destination: string, base: URL, file: string): URL {
|
|
261
|
+
const next = new URL(destination, base);
|
|
262
|
+
if (next.origin !== base.origin) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`${file} rewrote ${base.pathname} to ${destination}, which is another origin. A rewrite ` +
|
|
265
|
+
"serves another route of this application: answer with `Response.redirect` to send the " +
|
|
266
|
+
"visitor elsewhere, or fetch the other server from a route handler.",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (!destination.includes("?")) {
|
|
270
|
+
next.search = base.search;
|
|
271
|
+
}
|
|
272
|
+
next.hash = "";
|
|
273
|
+
return next;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function withPathname(url: URL, pathname: string): URL {
|
|
277
|
+
const next = new URL(url.href);
|
|
278
|
+
next.pathname = pathname;
|
|
279
|
+
return next;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* `request` at another URL: same method, headers, signal and body.
|
|
284
|
+
*
|
|
285
|
+
* A `Request` is a valid `RequestInit`, so a streamed body is handed on rather
|
|
286
|
+
* than read.
|
|
287
|
+
*/
|
|
288
|
+
function requestAt(request: Request, url: URL): Request {
|
|
289
|
+
// $FlowFixMe[incompatible-call] - a `Request` is read as the `RequestInit` it satisfies.
|
|
290
|
+
return new Request(url.href, request);
|
|
291
|
+
}
|
|
292
|
+
|
|
165
293
|
/**
|
|
166
294
|
* The function a middleware module exports.
|
|
167
295
|
*
|
|
@@ -171,6 +299,8 @@ export function createMiddlewareRunner(options: {|
|
|
|
171
299
|
* ignored is the bug this whole module exists to stop happening.
|
|
172
300
|
*/
|
|
173
301
|
function pick(module: MiddlewareModule, file: string): Middleware {
|
|
302
|
+
// `rewrite` is an export a middleware module may well import, and is never
|
|
303
|
+
// the middleware itself.
|
|
174
304
|
const exported = typeof module.default === "function" ? module.default : module.middleware;
|
|
175
305
|
if (typeof exported !== "function") {
|
|
176
306
|
throw new Error(
|