@uniflowed/router 0.0.0-alpha.35 → 0.0.0-alpha.39
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 +6 -0
- package/client.js +29 -105
- package/index.js +1 -0
- package/internal/base-path.js +175 -0
- package/internal/compose.js +12 -0
- package/internal/error-view.js +4 -0
- package/internal/flight-browser.js +17 -19
- package/internal/flight-chunks.js +32 -8
- package/internal/flight-ssr.js +6 -0
- package/internal/flight.js +26 -0
- package/internal/navigation-cache.js +144 -0
- package/internal/prepare-document.js +49 -0
- package/internal/react-version.js +77 -0
- package/internal/runtime.js +208 -26
- package/internal/server-route.js +6 -0
- package/internal/shell.js +115 -0
- package/internal/stream.js +111 -21
- package/middleware.js +144 -14
- package/package.json +12 -5
- package/rsc-client.js +120 -0
- package/rsc-ssr.js +440 -0
- package/rsc.js +11 -0
- package/server-components.js +4 -0
- package/server.js +20 -484
package/internal/stream.js
CHANGED
|
@@ -667,6 +667,23 @@ export type RenderOptions = {|
|
|
|
667
667
|
* [`interleaved`], which says where it may and may not go.
|
|
668
668
|
*/
|
|
669
669
|
readonly payload?: ReadableStream<Uint8Array>,
|
|
670
|
+
/**
|
|
671
|
+
* This response's Content-Security-Policy nonce, or absent for none.
|
|
672
|
+
*
|
|
673
|
+
* Absent is what every render had before nonces existed and what every
|
|
674
|
+
* render still has in a project that has not asked for one, so a document
|
|
675
|
+
* written without it is byte-for-byte the document uf has always written.
|
|
676
|
+
*
|
|
677
|
+
* Present, it reaches three places, and it has to reach all three or the
|
|
678
|
+
* page is broken rather than merely unprotected: React's own option, which
|
|
679
|
+
* nonces every inline script React emits — the runtime that reveals a
|
|
680
|
+
* `<Suspense>` boundary, and the bootstrap; `shell.open`/`shell.body`, which
|
|
681
|
+
* carry the client entry; and the payload chunk elements, which
|
|
682
|
+
* [`interleaved`] writes as text.
|
|
683
|
+
*
|
|
684
|
+
* `prerenderDocument` is deliberately never given one. See its own paragraph.
|
|
685
|
+
*/
|
|
686
|
+
readonly nonce?: string | null,
|
|
670
687
|
|};
|
|
671
688
|
|
|
672
689
|
/**
|
|
@@ -706,6 +723,7 @@ export function renderDocument(node: React.Node, options: RenderOptions): Promis
|
|
|
706
723
|
withPayload(
|
|
707
724
|
assembled(queue.chunks(), options.shell, options.transformHead),
|
|
708
725
|
options.payload,
|
|
726
|
+
options.nonce,
|
|
709
727
|
),
|
|
710
728
|
options.onStream,
|
|
711
729
|
),
|
|
@@ -717,6 +735,11 @@ export function renderDocument(node: React.Node, options: RenderOptions): Promis
|
|
|
717
735
|
reject(error);
|
|
718
736
|
},
|
|
719
737
|
onError: options.onError,
|
|
738
|
+
// Every inline script React writes for this document, from one option:
|
|
739
|
+
// the streaming runtime that reveals a boundary and patches a segment,
|
|
740
|
+
// and the bootstrap. uf nonces the scripts it writes itself; these are
|
|
741
|
+
// React's, and there is no other way to reach them.
|
|
742
|
+
nonce: options.nonce ?? undefined,
|
|
720
743
|
});
|
|
721
744
|
return;
|
|
722
745
|
}
|
|
@@ -744,6 +767,7 @@ type ReadableStreamRenderer = (
|
|
|
744
767
|
settings: {|
|
|
745
768
|
readonly onError: (error: mixed) => void,
|
|
746
769
|
readonly signal: AbortSignal,
|
|
770
|
+
readonly nonce?: string,
|
|
747
771
|
|},
|
|
748
772
|
) => Promise<ByteSource>;
|
|
749
773
|
|
|
@@ -769,20 +793,24 @@ export function renderWithReadableStream(
|
|
|
769
793
|
options: RenderOptions,
|
|
770
794
|
): Promise<DocumentBody> {
|
|
771
795
|
const controller = new AbortController();
|
|
772
|
-
return render(node, {
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
options.
|
|
796
|
+
return render(node, {
|
|
797
|
+
onError: options.onError,
|
|
798
|
+
signal: controller.signal,
|
|
799
|
+
nonce: options.nonce ?? undefined,
|
|
800
|
+
}).then((stream: ByteSource) =>
|
|
801
|
+
bodyOf(
|
|
802
|
+
outgoing(
|
|
803
|
+
withPayload(
|
|
804
|
+
assembled(decoded(stream), options.shell, options.transformHead),
|
|
805
|
+
options.payload,
|
|
806
|
+
options.nonce,
|
|
781
807
|
),
|
|
782
|
-
|
|
783
|
-
controller.abort();
|
|
784
|
-
},
|
|
808
|
+
options.onStream,
|
|
785
809
|
),
|
|
810
|
+
() => {
|
|
811
|
+
controller.abort();
|
|
812
|
+
},
|
|
813
|
+
),
|
|
786
814
|
);
|
|
787
815
|
}
|
|
788
816
|
|
|
@@ -843,15 +871,16 @@ export async function prerenderDocument(node: React.Node, options: RenderOptions
|
|
|
843
871
|
function withPayload(
|
|
844
872
|
chunks: AsyncGenerator<string, void, void>,
|
|
845
873
|
payload: ?ReadableStream<Uint8Array>,
|
|
874
|
+
nonce?: string | null,
|
|
846
875
|
): AsyncGenerator<string, void, void> {
|
|
847
|
-
return payload == null ? chunks : interleaved(chunks, payload);
|
|
876
|
+
return payload == null ? chunks : interleaved(chunks, payload, nonce);
|
|
848
877
|
}
|
|
849
878
|
|
|
850
879
|
/**
|
|
851
880
|
* A document's chunks, with the Flight payload it was rendered from written
|
|
852
881
|
* into it as it arrives.
|
|
853
882
|
*
|
|
854
|
-
*
|
|
883
|
+
* Four rules, and each is the answer to a way the obvious version is wrong.
|
|
855
884
|
*
|
|
856
885
|
* **Nothing before the head.** The first chunk this is handed is the whole
|
|
857
886
|
* opening of the document — `assembled` does not let one go until the head is
|
|
@@ -862,9 +891,20 @@ function withPayload(
|
|
|
862
891
|
* **Written as soon as it exists.** A payload row usually exists before the
|
|
863
892
|
* HTML rendered from it — React's client reads the row, then the boundary
|
|
864
893
|
* renders — so waiting for the next HTML chunk would put the browser's copy
|
|
865
|
-
* behind the markup it hydrates. Each HTML chunk
|
|
866
|
-
* payload is waiting, and a payload that arrives while the
|
|
867
|
-
* written then, without a chunk of HTML to follow.
|
|
894
|
+
* behind the markup it hydrates. Each HTML chunk that ends between elements is
|
|
895
|
+
* followed by whatever payload is waiting, and a payload that arrives while the
|
|
896
|
+
* HTML is idle there is written then, without a chunk of HTML to follow.
|
|
897
|
+
*
|
|
898
|
+
* **Only between elements.** React hands its HTML on through a fixed-size
|
|
899
|
+
* buffer, so a chunk can end anywhere: inside a tag, an attribute's value, a
|
|
900
|
+
* comment, a character reference or an inline `<script>`. A payload element
|
|
901
|
+
* written after such a chunk is not an element. It is part of the attribute,
|
|
902
|
+
* the comment or the text it landed in, the browser never reads it, and React's
|
|
903
|
+
* client closes the payload with rows missing, which is the "Connection closed"
|
|
904
|
+
* a page reports instead of hydrating. So a waiting payload goes out only where
|
|
905
|
+
* the HTML written so far ends between elements, which [`advanced`] follows
|
|
906
|
+
* from chunk to chunk, and otherwise waits for the HTML that finishes what is
|
|
907
|
+
* open.
|
|
868
908
|
*
|
|
869
909
|
* **`</body></html>` waits for the end of the payload.** The HTML can finish
|
|
870
910
|
* first — the last boundary's markup is rendered from rows that are already
|
|
@@ -881,8 +921,9 @@ function withPayload(
|
|
|
881
921
|
async function* interleaved(
|
|
882
922
|
chunks: AsyncGenerator<string, void, void>,
|
|
883
923
|
payload: ReadableStream<Uint8Array>,
|
|
924
|
+
nonce?: string | null,
|
|
884
925
|
): AsyncGenerator<string, void, void> {
|
|
885
|
-
const encoder = createChunkEncoder();
|
|
926
|
+
const encoder = createChunkEncoder(nonce);
|
|
886
927
|
const reader = payload.getReader();
|
|
887
928
|
let written = "";
|
|
888
929
|
let ended = false;
|
|
@@ -917,12 +958,13 @@ async function* interleaved(
|
|
|
917
958
|
}
|
|
918
959
|
})();
|
|
919
960
|
|
|
920
|
-
|
|
961
|
+
// Nothing written yet, and nothing may go before the head.
|
|
962
|
+
let boundary: Boundary = NOTHING_WRITTEN;
|
|
921
963
|
let closing = "";
|
|
922
964
|
try {
|
|
923
965
|
let next = chunks.next();
|
|
924
966
|
while (true) {
|
|
925
|
-
if (
|
|
967
|
+
if (boundary.safe && written !== "") {
|
|
926
968
|
const out = written;
|
|
927
969
|
written = "";
|
|
928
970
|
yield out;
|
|
@@ -945,8 +987,8 @@ async function* interleaved(
|
|
|
945
987
|
}
|
|
946
988
|
if (text !== "") {
|
|
947
989
|
yield text;
|
|
990
|
+
boundary = advanced(boundary, text);
|
|
948
991
|
}
|
|
949
|
-
opened = true;
|
|
950
992
|
next = chunks.next();
|
|
951
993
|
}
|
|
952
994
|
while (!ended || written !== "") {
|
|
@@ -972,6 +1014,54 @@ async function* interleaved(
|
|
|
972
1014
|
}
|
|
973
1015
|
}
|
|
974
1016
|
|
|
1017
|
+
/** Where the HTML written so far leaves the next payload element. */
|
|
1018
|
+
type Boundary = {|
|
|
1019
|
+
/** Whether a payload element may be written now. */
|
|
1020
|
+
readonly safe: boolean,
|
|
1021
|
+
/** The `script` or `style` element the HTML is inside, if it is inside one. */
|
|
1022
|
+
readonly rawText: string | null,
|
|
1023
|
+
/** A tag, or a comment, the HTML has started and not yet finished. */
|
|
1024
|
+
readonly open: string,
|
|
1025
|
+
|};
|
|
1026
|
+
|
|
1027
|
+
const NOTHING_WRITTEN: Boundary = { safe: false, rawText: null, open: "" };
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* `boundary`, once `html` has been written after it.
|
|
1031
|
+
*
|
|
1032
|
+
* A payload element may follow HTML that ends with a `>` outside a `<script>`
|
|
1033
|
+
* and a `<style>`. The test can be that short because React wrote the HTML: it
|
|
1034
|
+
* escapes `<` and `>` in text and in attribute values, so a `>` it wrote closes
|
|
1035
|
+
* a tag or a comment, and HTML that ends any other way ends inside one, or
|
|
1036
|
+
* inside a character reference or a run of text. What React does not escape is
|
|
1037
|
+
* the content of an inline script or stylesheet, where a `>` is code, so an
|
|
1038
|
+
* opening `<script>` or `<style>` is followed to its closing tag. A tag split
|
|
1039
|
+
* across two chunks is carried in `open` and read whole with the next one.
|
|
1040
|
+
*/
|
|
1041
|
+
function advanced(boundary: Boundary, html: string): Boundary {
|
|
1042
|
+
const text = boundary.open + html;
|
|
1043
|
+
let rawText = boundary.rawText;
|
|
1044
|
+
const tags = /<(\/?)(script|style)(?=[\s/>])[^>]*>/gi;
|
|
1045
|
+
let tag = tags.exec(text);
|
|
1046
|
+
while (tag != null) {
|
|
1047
|
+
const name = tag[2].toLowerCase();
|
|
1048
|
+
if (tag[1] === "/") {
|
|
1049
|
+
if (rawText === name) {
|
|
1050
|
+
rawText = null;
|
|
1051
|
+
}
|
|
1052
|
+
} else if (rawText == null) {
|
|
1053
|
+
rawText = name;
|
|
1054
|
+
}
|
|
1055
|
+
tag = tags.exec(text);
|
|
1056
|
+
}
|
|
1057
|
+
const start = text.lastIndexOf("<");
|
|
1058
|
+
return {
|
|
1059
|
+
safe: rawText == null && text.endsWith(">"),
|
|
1060
|
+
rawText,
|
|
1061
|
+
open: start > text.lastIndexOf(">") ? text.slice(start) : "",
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
975
1065
|
/**
|
|
976
1066
|
* The prelude of a static prerender, whichever stream this build produced.
|
|
977
1067
|
*
|
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(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/router",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.39",
|
|
4
4
|
"description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"./client": "./client.js",
|
|
20
20
|
"./native": "./native.js",
|
|
21
21
|
"./rsc": "./rsc.js",
|
|
22
|
+
"./rsc/client": "./rsc-client.js",
|
|
23
|
+
"./rsc/ssr": "./rsc-ssr.js",
|
|
22
24
|
"./server": "./server.js",
|
|
23
25
|
"./routing": "./routing.js",
|
|
24
26
|
"./package.json": "./package.json",
|
|
@@ -34,23 +36,28 @@
|
|
|
34
36
|
"middleware.js",
|
|
35
37
|
"native.js",
|
|
36
38
|
"routing.js",
|
|
39
|
+
"rsc-client.js",
|
|
40
|
+
"rsc-ssr.js",
|
|
37
41
|
"rsc.js",
|
|
38
42
|
"server-components.js",
|
|
39
43
|
"server.js",
|
|
40
44
|
"!*.test.js"
|
|
41
45
|
],
|
|
42
46
|
"peerDependencies": {
|
|
43
|
-
"react": ">=19.3
|
|
44
|
-
"react-dom": ">=19.3
|
|
47
|
+
"react": ">=19.2.3",
|
|
48
|
+
"react-dom": ">=19.2.3",
|
|
45
49
|
"react-server-dom-parcel": ">=19.3.0"
|
|
46
50
|
},
|
|
47
51
|
"peerDependenciesMeta": {
|
|
48
52
|
"react-dom": {
|
|
49
53
|
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"react-server-dom-parcel": {
|
|
56
|
+
"optional": true
|
|
50
57
|
}
|
|
51
58
|
},
|
|
52
59
|
"dependencies": {
|
|
53
|
-
"@uniflowed/hooks": "0.0.0-alpha.
|
|
54
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
60
|
+
"@uniflowed/hooks": "0.0.0-alpha.39",
|
|
61
|
+
"@uniflowed/server": "0.0.0-alpha.39"
|
|
55
62
|
}
|
|
56
63
|
}
|
package/rsc-client.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/router/rsc/client`: starting, in the browser, an application that
|
|
4
|
+
// React Server Components rendered.
|
|
5
|
+
//
|
|
6
|
+
// `virtual:uf/client` imports this entry when routes render as Server
|
|
7
|
+
// Components, which is the default. It imports `@uniflowed/router/client` when
|
|
8
|
+
// they render from their modules (`app.rsc: false`) or into an empty shell
|
|
9
|
+
// (`app.rendering.modes: ["csr"]`). This is an entry of its own because it loads
|
|
10
|
+
// React's Flight client, `react-server-dom-parcel`. That package is an optional
|
|
11
|
+
// peer and needs React 19.3, while the rest of the router runs on the React
|
|
12
|
+
// 19.2.3 that Expo SDK 57 and React Native 0.87 ship (ubugeeei-prod/uf#992). A
|
|
13
|
+
// bundler resolves every import in the graph it is given, whether or not
|
|
14
|
+
// anything calls it, so a browser bundle that renders no Server Component
|
|
15
|
+
// leaves the package out only if nothing it imports names it.
|
|
16
|
+
// `crates/uf_lib/tests/package_surface.rs` holds the router to that.
|
|
17
|
+
//
|
|
18
|
+
// The same reason keeps the payload fetch out of `./internal/runtime.js`.
|
|
19
|
+
// Navigation there serves every application, so `hydrateFlight` hands it the
|
|
20
|
+
// fetch before the first render.
|
|
21
|
+
|
|
22
|
+
import * as React from "react";
|
|
23
|
+
import { StrictMode, startTransition } from "react";
|
|
24
|
+
import { hydrateRoot } from "react-dom/client";
|
|
25
|
+
|
|
26
|
+
import { type TrailingSlash, applicationPathOf } from "./internal/base-path.js";
|
|
27
|
+
import { ROOT_ID } from "./internal/document.js";
|
|
28
|
+
import {
|
|
29
|
+
fetchFlight,
|
|
30
|
+
installBrowserModules,
|
|
31
|
+
readDocumentPayload,
|
|
32
|
+
} from "./internal/flight-browser.js";
|
|
33
|
+
import { domObserver } from "./internal/payload-rows.js";
|
|
34
|
+
import { prepareDocumentForHydration } from "./internal/prepare-document.js";
|
|
35
|
+
import { requireServerComponentsReact } from "./internal/react-version.js";
|
|
36
|
+
import {
|
|
37
|
+
type AppProps,
|
|
38
|
+
type Navigation,
|
|
39
|
+
installFlightFetch,
|
|
40
|
+
installNavigation,
|
|
41
|
+
installRouting,
|
|
42
|
+
installStaleTime,
|
|
43
|
+
} from "./internal/runtime.js";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Hydrate a document React Server Components rendered.
|
|
47
|
+
*
|
|
48
|
+
* `hydrate` in `./client.js` resolves the route from its modules and renders it
|
|
49
|
+
* again over the server's markup. This one resolves nothing and imports no route
|
|
50
|
+
* module: the document carries the Flight payload its tree was rendered from,
|
|
51
|
+
* React's own client reads it, and the tree the browser hydrates is the tree the
|
|
52
|
+
* server rendered — a Server Component is markup and a reference, and a client
|
|
53
|
+
* component is the one kind of module this page loads. See
|
|
54
|
+
* ubugeeei-prod/uf#519.
|
|
55
|
+
*
|
|
56
|
+
* The payload is read while the document is still arriving. Row 0 is in the
|
|
57
|
+
* shell, so hydration starts as soon as the module script runs, and every row
|
|
58
|
+
* after it lands in a later chunk that the reader picks up as it is parsed — so
|
|
59
|
+
* a boundary the server completes after hydration began resolves then, with no
|
|
60
|
+
* second request.
|
|
61
|
+
*
|
|
62
|
+
* Everything else is `hydrate`'s, for the reasons written there: the navigation
|
|
63
|
+
* mode is installed before the first render, the development hydration report
|
|
64
|
+
* captures the server's markup before React repairs it, and Strict Mode wraps
|
|
65
|
+
* the root.
|
|
66
|
+
*
|
|
67
|
+
* On a React older than 19.3 it refuses before it touches the page, naming the
|
|
68
|
+
* version it found; see `./internal/react-version.js`.
|
|
69
|
+
*/
|
|
70
|
+
export async function hydrateFlight(options: {|
|
|
71
|
+
readonly App: React.ComponentType<AppProps>,
|
|
72
|
+
readonly strictMode?: boolean,
|
|
73
|
+
readonly navigation?: Navigation,
|
|
74
|
+
readonly basePath?: string,
|
|
75
|
+
readonly trailingSlash?: TrailingSlash,
|
|
76
|
+
readonly staleTime?: number,
|
|
77
|
+
|}): Promise<void> {
|
|
78
|
+
requireServerComponentsReact("@uniflowed/router/rsc/client");
|
|
79
|
+
installNavigation(options.navigation ?? "client");
|
|
80
|
+
installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
|
|
81
|
+
installStaleTime(options.staleTime ?? 0);
|
|
82
|
+
installFlightFetch(fetchFlight);
|
|
83
|
+
installBrowserModules();
|
|
84
|
+
const flight = readDocumentPayload(document, domObserver(document));
|
|
85
|
+
|
|
86
|
+
// The route table has no base path in it, and the address bar does.
|
|
87
|
+
const url =
|
|
88
|
+
(applicationPathOf(window.location.pathname) ?? window.location.pathname) +
|
|
89
|
+
window.location.search;
|
|
90
|
+
const { App } = options;
|
|
91
|
+
const container = document.getElementById(ROOT_ID) ?? document;
|
|
92
|
+
prepareDocumentForHydration(document);
|
|
93
|
+
|
|
94
|
+
let recovery = null;
|
|
95
|
+
let restoreDevHead = null;
|
|
96
|
+
if (import.meta.hot != null) {
|
|
97
|
+
const { captureServerMarkup, hydrationErrorHandler, prepareDevHeadForHydration } =
|
|
98
|
+
await import("./internal/hydration.js");
|
|
99
|
+
restoreDevHead = prepareDevHeadForHydration(document);
|
|
100
|
+
recovery = hydrationErrorHandler(container, captureServerMarkup(container), document);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const tree = <App url={url} flight={flight} />;
|
|
104
|
+
|
|
105
|
+
startTransition(() => {
|
|
106
|
+
hydrateRoot(
|
|
107
|
+
container,
|
|
108
|
+
options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
|
|
109
|
+
recovery == null ? undefined : { onRecoverableError: recovery },
|
|
110
|
+
);
|
|
111
|
+
if (restoreDevHead != null) {
|
|
112
|
+
setTimeout(restoreDevHead, 250);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (import.meta.hot != null) {
|
|
117
|
+
const { reportDevtools } = await import("./internal/devtools.js");
|
|
118
|
+
reportDevtools(window);
|
|
119
|
+
}
|
|
120
|
+
}
|