@uniflowed/router 0.0.0-alpha.37 → 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 +5 -0
- package/internal/compose.js +12 -0
- package/internal/error-view.js +4 -0
- package/internal/flight-chunks.js +32 -8
- package/internal/navigation-cache.js +144 -0
- package/internal/runtime.js +119 -7
- package/internal/shell.js +12 -4
- package/internal/stream.js +44 -14
- package/package.json +3 -3
- package/rsc-client.js +3 -0
- package/rsc-ssr.js +9 -1
- package/rsc.js +5 -0
- package/server.js +11 -2
package/action.js
CHANGED
|
@@ -111,6 +111,7 @@ import {
|
|
|
111
111
|
decodeActionResult,
|
|
112
112
|
encodeActionArguments,
|
|
113
113
|
} from "./internal/action-wire.js";
|
|
114
|
+
import { clearNavigationCache } from "./internal/navigation-cache.js";
|
|
114
115
|
|
|
115
116
|
export type { ActionArgument, ActionValue } from "./internal/action-wire.js";
|
|
116
117
|
export {
|
|
@@ -264,6 +265,11 @@ export function createServerReference(id: string, name: string): ServerActionFun
|
|
|
264
265
|
headers,
|
|
265
266
|
body,
|
|
266
267
|
});
|
|
268
|
+
// A route a navigation kept may no longer show what this action wrote, and
|
|
269
|
+
// which routes is the server's to know, so every kept route is asked for
|
|
270
|
+
// again. Whatever the status: an action that failed part-way may have
|
|
271
|
+
// written before it failed. See `./internal/navigation-cache.js`.
|
|
272
|
+
clearNavigationCache();
|
|
267
273
|
if (!response.ok) {
|
|
268
274
|
throw new ServerActionError(name, response.status);
|
|
269
275
|
}
|
package/client.js
CHANGED
|
@@ -109,6 +109,7 @@ import {
|
|
|
109
109
|
installNavigation,
|
|
110
110
|
installRoutes,
|
|
111
111
|
installRouting,
|
|
112
|
+
installStaleTime,
|
|
112
113
|
matchRoute,
|
|
113
114
|
resolveFailure,
|
|
114
115
|
resolveMatch,
|
|
@@ -135,6 +136,7 @@ export async function hydrate(options: {|
|
|
|
135
136
|
readonly navigation?: Navigation,
|
|
136
137
|
readonly basePath?: string,
|
|
137
138
|
readonly trailingSlash?: TrailingSlash,
|
|
139
|
+
readonly staleTime?: number,
|
|
138
140
|
|}): Promise<void> {
|
|
139
141
|
const table: RouteTable = {
|
|
140
142
|
routes: options.routes,
|
|
@@ -148,6 +150,7 @@ export async function hydrate(options: {|
|
|
|
148
150
|
// means: the default is the behaviour, not the absence of one.
|
|
149
151
|
installNavigation(options.navigation ?? "client");
|
|
150
152
|
installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
|
|
153
|
+
installStaleTime(options.staleTime ?? 0);
|
|
151
154
|
// The route table has no base path in it, and the address bar does.
|
|
152
155
|
const applicationPath = applicationPathOf(window.location.pathname) ?? window.location.pathname;
|
|
153
156
|
|
|
@@ -270,6 +273,7 @@ export async function render(options: {|
|
|
|
270
273
|
readonly navigation?: Navigation,
|
|
271
274
|
readonly basePath?: string,
|
|
272
275
|
readonly trailingSlash?: TrailingSlash,
|
|
276
|
+
readonly staleTime?: number,
|
|
273
277
|
|}): Promise<void> {
|
|
274
278
|
const table: RouteTable = {
|
|
275
279
|
routes: options.routes,
|
|
@@ -279,6 +283,7 @@ export async function render(options: {|
|
|
|
279
283
|
installRoutes(table);
|
|
280
284
|
installNavigation(options.navigation ?? "client");
|
|
281
285
|
installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
|
|
286
|
+
installStaleTime(options.staleTime ?? 0);
|
|
282
287
|
|
|
283
288
|
const url =
|
|
284
289
|
(applicationPathOf(window.location.pathname) ?? window.location.pathname) +
|
package/internal/compose.js
CHANGED
|
@@ -412,6 +412,11 @@ component SlotView(slot: ResolvedSlot, pathname: string, searchParams: SearchPar
|
|
|
412
412
|
const at = slot.intercepted?.pathname ?? pathname;
|
|
413
413
|
const query = slot.intercepted?.searchParams ?? searchParams;
|
|
414
414
|
const Page = pageComponent(page);
|
|
415
|
+
// The slot module's own export, looked up by slot: `pageComponent` hands back
|
|
416
|
+
// its `default` or `Page` as it is, so this is the same component on every
|
|
417
|
+
// render of the same slot. The React Compiler cannot see through the lookup
|
|
418
|
+
// and reports a component created during render.
|
|
419
|
+
// uf-lint-disable-next-line react-compiler/static-components
|
|
415
420
|
let element: React.Node = <Page params={slot.params} searchParams={query} data={undefined} />;
|
|
416
421
|
const templateContext = {
|
|
417
422
|
pathname: at,
|
|
@@ -425,6 +430,10 @@ component SlotView(slot: ResolvedSlot, pathname: string, searchParams: SearchPar
|
|
|
425
430
|
continue;
|
|
426
431
|
}
|
|
427
432
|
const Fallback = loadingComponent(boundary.module);
|
|
433
|
+
// The same lookup as `Page` above: `loadingComponent` hands back the
|
|
434
|
+
// boundary module's own export as it is, so this is the same component
|
|
435
|
+
// on every render of that boundary.
|
|
436
|
+
// uf-lint-disable-next-line react-compiler/static-components
|
|
428
437
|
element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
|
|
429
438
|
}
|
|
430
439
|
const errorBoundary = slot.errorBoundary;
|
|
@@ -439,6 +448,9 @@ component SlotView(slot: ResolvedSlot, pathname: string, searchParams: SearchPar
|
|
|
439
448
|
if (depth > 0) {
|
|
440
449
|
const Layout = layoutComponent(slot.layouts[depth - 1]);
|
|
441
450
|
element = (
|
|
451
|
+
// The layout module's own export, looked up by depth, for the same
|
|
452
|
+
// reason as `Page` above.
|
|
453
|
+
// uf-lint-disable-next-line react-compiler/static-components
|
|
442
454
|
<Layout {...slotsAt(slot.slots, depth, { pathname, searchParams })} params={slot.params}>
|
|
443
455
|
{element}
|
|
444
456
|
</Layout>
|
package/internal/error-view.js
CHANGED
|
@@ -78,6 +78,10 @@ component RouteErrorView(module: ?ErrorModule, error: RouteError, reset: () => v
|
|
|
78
78
|
return <DefaultRouteError error={error} reset={reset} />;
|
|
79
79
|
}
|
|
80
80
|
const Boundary = errorComponent(module);
|
|
81
|
+
// The error module's own export, looked up by route, for the same reason as
|
|
82
|
+
// the page component in `compose.js`: the same component on every render of
|
|
83
|
+
// that route, through a lookup the compiler cannot see into.
|
|
84
|
+
// uf-lint-disable-next-line react-compiler/static-components
|
|
81
85
|
return <Boundary error={error} reset={reset} />;
|
|
82
86
|
}
|
|
83
87
|
|
|
@@ -55,9 +55,33 @@ function elementJson(value: FlightChunk): string {
|
|
|
55
55
|
.replace(/\u2029/g, "\\u2029");
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
/**
|
|
59
|
+
* The element for one chunk, or the end marker for `null`.
|
|
60
|
+
*
|
|
61
|
+
* `nonce` is this response's, and it is carried even though the element is
|
|
62
|
+
* `application/json` and therefore a data block the browser never executes.
|
|
63
|
+
* Two reasons, and neither is "in case the spec changes". A uf document's
|
|
64
|
+
* invariant is that every `<script>` in it carries the nonce — that is what
|
|
65
|
+
* `packages/router/streaming.test.js` pins, and an invariant with an exception
|
|
66
|
+
* in it is one a reader has to check every new script against. And these are
|
|
67
|
+
* written as text into a stream rather than rendered by React, so unlike the
|
|
68
|
+
* payload rows in `./runtime.js` there is no second render on the client to
|
|
69
|
+
* disagree with; the attribute costs a comparison nobody makes.
|
|
70
|
+
*/
|
|
71
|
+
export function flightChunkElement(chunk: FlightChunk, nonce?: string | null): string {
|
|
72
|
+
const carried = nonce == null ? "" : ` nonce="${escapeNonce(nonce)}"`;
|
|
73
|
+
return `<script type="application/json"${carried} ${FLIGHT_CHUNK_ATTRIBUTE}>${elementJson(chunk)}</script>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A nonce, as an attribute value.
|
|
78
|
+
*
|
|
79
|
+
* uf's own nonces are base64 and hold none of these, so this is about the one a
|
|
80
|
+
* project supplied: the value reaches a document that a browser parses as
|
|
81
|
+
* HTML, and a `"` in it would end the attribute and open whatever follows.
|
|
82
|
+
*/
|
|
83
|
+
function escapeNonce(value: string): string {
|
|
84
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
61
85
|
}
|
|
62
86
|
|
|
63
87
|
/**
|
|
@@ -74,7 +98,7 @@ export type ChunkEncoder = {|
|
|
|
74
98
|
readonly end: () => string,
|
|
75
99
|
|};
|
|
76
100
|
|
|
77
|
-
export function createChunkEncoder(): ChunkEncoder {
|
|
101
|
+
export function createChunkEncoder(nonce?: string | null): ChunkEncoder {
|
|
78
102
|
const strict = new TextDecoder("utf-8", { fatal: true });
|
|
79
103
|
let carry: Uint8Array = new Uint8Array(0);
|
|
80
104
|
|
|
@@ -87,17 +111,17 @@ export function createChunkEncoder(): ChunkEncoder {
|
|
|
87
111
|
}
|
|
88
112
|
const body = joined.subarray(0, complete);
|
|
89
113
|
try {
|
|
90
|
-
return flightChunkElement(strict.decode(body));
|
|
114
|
+
return flightChunkElement(strict.decode(body), nonce);
|
|
91
115
|
} catch {
|
|
92
|
-
return flightChunkElement({ bytes: base64(body) });
|
|
116
|
+
return flightChunkElement({ bytes: base64(body) }, nonce);
|
|
93
117
|
}
|
|
94
118
|
}
|
|
95
119
|
|
|
96
120
|
function end(): string {
|
|
97
121
|
const rest = carry;
|
|
98
122
|
carry = new Uint8Array(0);
|
|
99
|
-
const tail = rest.length === 0 ? "" : flightChunkElement({ bytes: base64(rest) });
|
|
100
|
-
return `${tail}${flightChunkElement(null)}`;
|
|
123
|
+
const tail = rest.length === 0 ? "" : flightChunkElement({ bytes: base64(rest) }, nonce);
|
|
124
|
+
return `${tail}${flightChunkElement(null, nonce)}`;
|
|
101
125
|
}
|
|
102
126
|
|
|
103
127
|
return { encode, end };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the routes a navigation already has.
|
|
4
|
+
//
|
|
5
|
+
// A navigation used to ask the server every time. A `Link` prefetched a route's
|
|
6
|
+
// payload on hover and handed it to exactly one click, and every other way back
|
|
7
|
+
// to a page the reader had just seen, a second visit or the back button, waited
|
|
8
|
+
// on the network again (ubugeeei-prod/uf#960). This module is what a navigation
|
|
9
|
+
// reads first instead: the route it fetched or prefetched, kept for
|
|
10
|
+
// `app.rendering.staleTime` seconds and asked for again after that.
|
|
11
|
+
//
|
|
12
|
+
// # Off until a project says a number
|
|
13
|
+
//
|
|
14
|
+
// uf's caches are opt-in, and this one is too. `staleTime` is `0` until a
|
|
15
|
+
// project writes one, and at `0` nothing is kept, so a navigation shows what the
|
|
16
|
+
// server answered for it just now, which is the guarantee every project had
|
|
17
|
+
// before the setting existed.
|
|
18
|
+
//
|
|
19
|
+
// # What is kept
|
|
20
|
+
//
|
|
21
|
+
// One promise per route, never a copy of what it resolved to:
|
|
22
|
+
//
|
|
23
|
+
// - an application React Server Components render keeps the payload fetch: the
|
|
24
|
+
// route's state and its tree, with the `$loading.js` fallbacks the tree
|
|
25
|
+
// carries, so going back to a page that was still streaming shows the loading
|
|
26
|
+
// shell the first visit did;
|
|
27
|
+
// - an application rendered from its modules keeps the resolved route: the
|
|
28
|
+
// loader's data, and the page, layouts and loading boundaries it loaded.
|
|
29
|
+
//
|
|
30
|
+
// A promise rather than its value, so a click on a link whose prefetch is still
|
|
31
|
+
// in flight waits on that request instead of making a second one. The router
|
|
32
|
+
// forgets an entry whose fetch failed or turned out not to be a route.
|
|
33
|
+
//
|
|
34
|
+
// # Keyed by the application path
|
|
35
|
+
//
|
|
36
|
+
// The path and query the route table is asked about, without
|
|
37
|
+
// `app.router.basePath`: `/docs/guide?tab=api` under `/docs` is kept as
|
|
38
|
+
// `/guide?tab=api`. A fragment is never part of a key, because a server never
|
|
39
|
+
// sees one.
|
|
40
|
+
//
|
|
41
|
+
// # Cleared, not revalidated in place
|
|
42
|
+
//
|
|
43
|
+
// `router.refresh()` and every server action clear the whole cache. An action
|
|
44
|
+
// is a write, and which pages it changed is the server's to know, so the next
|
|
45
|
+
// navigation to any of them asks again rather than showing what the write made
|
|
46
|
+
// untrue.
|
|
47
|
+
|
|
48
|
+
import { applicationPathOf } from "./base-path.js";
|
|
49
|
+
import type { FetchedFlight } from "./flight.js";
|
|
50
|
+
import type { ResolvedRoute } from "./resolve.js";
|
|
51
|
+
|
|
52
|
+
/** How many routes each cache keeps at once. Past it, the oldest is dropped. */
|
|
53
|
+
export const NAVIGATION_CACHE_LIMIT = 32;
|
|
54
|
+
|
|
55
|
+
let staleTimeMs: number = 0;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Say how long, in seconds, a route a navigation fetched is shown again without
|
|
59
|
+
* asking. `0`, the default, keeps nothing. Called once, by the entry that
|
|
60
|
+
* started the application.
|
|
61
|
+
*/
|
|
62
|
+
export function installStaleTime(seconds: number): void {
|
|
63
|
+
staleTimeMs = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0;
|
|
64
|
+
if (staleTimeMs === 0) {
|
|
65
|
+
clearNavigationCache();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether navigations keep what they fetch. */
|
|
70
|
+
export function keepsNavigations(): boolean {
|
|
71
|
+
return staleTimeMs > 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The key a URL's route is kept under: its application path, then its query. */
|
|
75
|
+
export function navigationKey(pathname: string, search: string): string {
|
|
76
|
+
return `${applicationPathOf(pathname) ?? pathname}${search}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One kind of kept route. */
|
|
80
|
+
export type NavigationCache<T> = {|
|
|
81
|
+
/** The route kept under `key`, while it is fresh. A stale one is dropped. */
|
|
82
|
+
readonly read: (key: string) => T | null,
|
|
83
|
+
/** Keep `value` under `key` from now. Nothing is kept while the stale time is `0`. */
|
|
84
|
+
readonly store: (key: string, value: T) => void,
|
|
85
|
+
/** Drop what `key` keeps, if it is still `value`. */
|
|
86
|
+
readonly forget: (key: string, value: T) => void,
|
|
87
|
+
readonly clear: () => void,
|
|
88
|
+
/** How many routes are kept, fresh or not. */
|
|
89
|
+
readonly size: () => number,
|
|
90
|
+
|};
|
|
91
|
+
|
|
92
|
+
function createNavigationCache<T>(): NavigationCache<T> {
|
|
93
|
+
const entries: Map<string, {| readonly value: T, readonly at: number |}> = new Map();
|
|
94
|
+
return {
|
|
95
|
+
read(key) {
|
|
96
|
+
const entry = entries.get(key);
|
|
97
|
+
if (entry == null) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
if (Date.now() - entry.at >= staleTimeMs) {
|
|
101
|
+
entries.delete(key);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return entry.value;
|
|
105
|
+
},
|
|
106
|
+
store(key, value) {
|
|
107
|
+
if (staleTimeMs === 0) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Deleted first, so a route kept again moves to the young end.
|
|
111
|
+
entries.delete(key);
|
|
112
|
+
if (entries.size >= NAVIGATION_CACHE_LIMIT) {
|
|
113
|
+
const oldest = entries.keys().next();
|
|
114
|
+
if (oldest.done !== true) {
|
|
115
|
+
entries.delete(oldest.value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
entries.set(key, { value, at: Date.now() });
|
|
119
|
+
},
|
|
120
|
+
forget(key, value) {
|
|
121
|
+
if (entries.get(key)?.value === value) {
|
|
122
|
+
entries.delete(key);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
clear() {
|
|
126
|
+
entries.clear();
|
|
127
|
+
},
|
|
128
|
+
size() {
|
|
129
|
+
return entries.size;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Payload fetches, for an application React Server Components render. */
|
|
135
|
+
export const flightNavigations: NavigationCache<Promise<FetchedFlight>> = createNavigationCache();
|
|
136
|
+
|
|
137
|
+
/** Resolved routes, for an application rendered from its modules. */
|
|
138
|
+
export const routeNavigations: NavigationCache<Promise<ResolvedRoute>> = createNavigationCache();
|
|
139
|
+
|
|
140
|
+
/** Forget every kept route. `router.refresh()` and each server action call this. */
|
|
141
|
+
export function clearNavigationCache(): void {
|
|
142
|
+
flightNavigations.clear();
|
|
143
|
+
routeNavigations.clear();
|
|
144
|
+
}
|
package/internal/runtime.js
CHANGED
|
@@ -77,6 +77,13 @@ import { composeRoute, pageComponent } from "./compose.js";
|
|
|
77
77
|
import { type FetchedFlight, type FlightRoot, type RouteState, routeState } from "./flight.js";
|
|
78
78
|
import { Head } from "./head.js";
|
|
79
79
|
import { addressOf, applicationPathOf, canonicalAddress } from "./base-path.js";
|
|
80
|
+
import {
|
|
81
|
+
clearNavigationCache,
|
|
82
|
+
flightNavigations,
|
|
83
|
+
keepsNavigations,
|
|
84
|
+
navigationKey,
|
|
85
|
+
routeNavigations,
|
|
86
|
+
} from "./navigation-cache.js";
|
|
80
87
|
import { hasClientPage, matchRoute, nearestBoundary } from "./routing.js";
|
|
81
88
|
import type { RouteParams, SearchParams } from "./routing.js";
|
|
82
89
|
import {
|
|
@@ -142,6 +149,10 @@ export { resolveFailure, resolveMatch } from "./resolve.js";
|
|
|
142
149
|
export type { RoutingSettings, TrailingSlash } from "./base-path.js";
|
|
143
150
|
export { basePath, installRouting } from "./base-path.js";
|
|
144
151
|
|
|
152
|
+
// `app.rendering.staleTime`, installed by the same entry; see
|
|
153
|
+
// `./navigation-cache.js`.
|
|
154
|
+
export { installStaleTime } from "./navigation-cache.js";
|
|
155
|
+
|
|
145
156
|
// ---------------------------------------------------------------------------
|
|
146
157
|
// View transitions
|
|
147
158
|
// ---------------------------------------------------------------------------
|
|
@@ -671,9 +682,13 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
671
682
|
}
|
|
672
683
|
setPending(true);
|
|
673
684
|
try {
|
|
685
|
+
// An interception depends on the page it starts from, so it is resolved
|
|
686
|
+
// every time; any other navigation reads what this page kept while it is
|
|
687
|
+
// fresh. See `./navigation-cache.js`.
|
|
688
|
+
const key = navigationKey(target.pathname, target.search);
|
|
674
689
|
const nextResolved =
|
|
675
690
|
(intercepting ? await resolveInterception(routeTable(), origin, next) : null) ??
|
|
676
|
-
(await resolveMatch(routeTable(), next));
|
|
691
|
+
(await (routeNavigations.read(key) ?? keepRoute(key, resolveMatch(routeTable(), next))));
|
|
677
692
|
// An intercepted entry remembers where it was intercepted from, so back
|
|
678
693
|
// and forward can put the page underneath under it again. Every other
|
|
679
694
|
// entry is written the way it always was.
|
|
@@ -781,7 +796,8 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
781
796
|
window.location.reload();
|
|
782
797
|
return;
|
|
783
798
|
}
|
|
784
|
-
|
|
799
|
+
const key = navigationKey(window.location.pathname, window.location.search);
|
|
800
|
+
(routeNavigations.read(key) ?? keepRoute(key, resolveMatch(routeTable(), next))).then(arrive);
|
|
785
801
|
};
|
|
786
802
|
window.addEventListener("popstate", onPopState);
|
|
787
803
|
return () => {
|
|
@@ -824,6 +840,17 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
824
840
|
if (matched == null || load == null) {
|
|
825
841
|
return;
|
|
826
842
|
}
|
|
843
|
+
// With `app.rendering.staleTime` set, the whole route: its loader runs
|
|
844
|
+
// now, and the click, a later visit and the back button read what it
|
|
845
|
+
// answered while it is fresh. Otherwise only the modules it will need.
|
|
846
|
+
if (keepsNavigations()) {
|
|
847
|
+
const key = navigationKey(target.pathname, target.search);
|
|
848
|
+
await (
|
|
849
|
+
routeNavigations.read(key) ??
|
|
850
|
+
keepRoute(key, resolveMatch(routeTable(), applicationPath + target.search))
|
|
851
|
+
);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
827
854
|
await Promise.all([
|
|
828
855
|
loadOnce(load),
|
|
829
856
|
...matched.route.layouts.map((layout) => loadOnce(layout)),
|
|
@@ -841,6 +868,9 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
841
868
|
window.location.reload();
|
|
842
869
|
return;
|
|
843
870
|
}
|
|
871
|
+
// Everything a navigation kept is older than what this asks for, so none
|
|
872
|
+
// of it is shown again; see `./navigation-cache.js`.
|
|
873
|
+
clearNavigationCache();
|
|
844
874
|
// A refresh of an intercepted page refreshes both of its halves: the
|
|
845
875
|
// page underneath, resolved again for its own URL, and the interception
|
|
846
876
|
// resolved again over it. Resolving only the address bar's URL would
|
|
@@ -935,7 +965,14 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
935
965
|
}
|
|
936
966
|
setPending(true);
|
|
937
967
|
try {
|
|
938
|
-
|
|
968
|
+
// The route this page already has while it is fresh, then a prefetch
|
|
969
|
+
// still in hand, then the network. See `./navigation-cache.js`.
|
|
970
|
+
const key = navigationKey(target.pathname, target.search);
|
|
971
|
+
const fetched = await (
|
|
972
|
+
flightNavigations.read(key) ??
|
|
973
|
+
takePrefetched(next) ??
|
|
974
|
+
keepFlight(key, fetchFlight(next))
|
|
975
|
+
);
|
|
939
976
|
// Not a payload: a redirect off this origin, or a host that has no payload
|
|
940
977
|
// for this URL. The browser loads it as a document, which is what the
|
|
941
978
|
// anchor would have done.
|
|
@@ -994,8 +1031,10 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
994
1031
|
const onPopState = () => {
|
|
995
1032
|
const next = window.location.pathname + window.location.search;
|
|
996
1033
|
// The history entry already moved; a payload that cannot be had for it is
|
|
997
|
-
// a document to load, and a reload is the browser's way to load it.
|
|
998
|
-
|
|
1034
|
+
// a document to load, and a reload is the browser's way to load it. While
|
|
1035
|
+
// this page keeps the route fresh, what it kept is what comes back.
|
|
1036
|
+
const key = navigationKey(window.location.pathname, window.location.search);
|
|
1037
|
+
(flightNavigations.read(key) ?? keepFlight(key, fetchFlight(next))).then(
|
|
999
1038
|
(fetched) => {
|
|
1000
1039
|
if (fetched.kind === "document") {
|
|
1001
1040
|
window.location.reload();
|
|
@@ -1037,7 +1076,15 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
1037
1076
|
if (target.origin !== window.location.origin) {
|
|
1038
1077
|
return;
|
|
1039
1078
|
}
|
|
1040
|
-
|
|
1079
|
+
const next = target.pathname + target.search;
|
|
1080
|
+
// Kept for every navigation to it while it is fresh, when a project set
|
|
1081
|
+
// `app.rendering.staleTime`; otherwise held for the one click after it.
|
|
1082
|
+
if (keepsNavigations()) {
|
|
1083
|
+
const key = navigationKey(target.pathname, target.search);
|
|
1084
|
+
await (flightNavigations.read(key) ?? keepFlight(key, fetchFlight(next)));
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
await prefetchFlight(next);
|
|
1041
1088
|
},
|
|
1042
1089
|
refresh: async () => {
|
|
1043
1090
|
if (!isBrowser()) {
|
|
@@ -1047,7 +1094,13 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
1047
1094
|
window.location.reload();
|
|
1048
1095
|
return;
|
|
1049
1096
|
}
|
|
1050
|
-
|
|
1097
|
+
// Everything a navigation kept is older than what this asks for, so none
|
|
1098
|
+
// of it is shown again; see `./navigation-cache.js`.
|
|
1099
|
+
clearNavigationCache();
|
|
1100
|
+
const fetched = await keepFlight(
|
|
1101
|
+
navigationKey(window.location.pathname, window.location.search),
|
|
1102
|
+
fetchFlight(window.location.pathname + window.location.search),
|
|
1103
|
+
);
|
|
1051
1104
|
if (fetched.kind === "document") {
|
|
1052
1105
|
window.location.reload();
|
|
1053
1106
|
return;
|
|
@@ -1098,6 +1151,57 @@ const prefetchedFlights: Map<
|
|
|
1098
1151
|
{| readonly fetched: Promise<FetchedFlight>, readonly at: number |},
|
|
1099
1152
|
> = new Map();
|
|
1100
1153
|
|
|
1154
|
+
/**
|
|
1155
|
+
* `fetched`, kept under `key` for as long as `app.rendering.staleTime` says,
|
|
1156
|
+
* and forgotten again if it turns out not to be a route to show twice: a
|
|
1157
|
+
* request that failed, an answer that was a document rather than a payload, or
|
|
1158
|
+
* a payload React could not read.
|
|
1159
|
+
*/
|
|
1160
|
+
function keepFlight(key: string, fetched: Promise<FetchedFlight>): Promise<FetchedFlight> {
|
|
1161
|
+
if (!keepsNavigations()) {
|
|
1162
|
+
return fetched;
|
|
1163
|
+
}
|
|
1164
|
+
flightNavigations.store(key, fetched);
|
|
1165
|
+
const forget = () => {
|
|
1166
|
+
flightNavigations.forget(key, fetched);
|
|
1167
|
+
};
|
|
1168
|
+
void fetched.then((answer) => {
|
|
1169
|
+
if (answer.kind === "document") {
|
|
1170
|
+
forget();
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
// A route that answered with its error or not-found boundary is shown this
|
|
1174
|
+
// once: asked again, it may have recovered.
|
|
1175
|
+
void answer.root.then((root) => {
|
|
1176
|
+
if (root.route.status !== 200) {
|
|
1177
|
+
forget();
|
|
1178
|
+
}
|
|
1179
|
+
}, forget);
|
|
1180
|
+
}, forget);
|
|
1181
|
+
return fetched;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* `resolved`, kept under `key` for as long as `app.rendering.staleTime` says,
|
|
1186
|
+
* and forgotten again if it did not resolve to a page: a loader that threw or
|
|
1187
|
+
* redirected, or a route that answered with its error or not-found boundary.
|
|
1188
|
+
*/
|
|
1189
|
+
function keepRoute(key: string, resolved: Promise<ResolvedRoute>): Promise<ResolvedRoute> {
|
|
1190
|
+
if (!keepsNavigations()) {
|
|
1191
|
+
return resolved;
|
|
1192
|
+
}
|
|
1193
|
+
routeNavigations.store(key, resolved);
|
|
1194
|
+
const forget = () => {
|
|
1195
|
+
routeNavigations.forget(key, resolved);
|
|
1196
|
+
};
|
|
1197
|
+
void resolved.then((route) => {
|
|
1198
|
+
if (route.status !== 200) {
|
|
1199
|
+
forget();
|
|
1200
|
+
}
|
|
1201
|
+
}, forget);
|
|
1202
|
+
return resolved;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1101
1205
|
function prefetchFlight(url: string): Promise<FetchedFlight> {
|
|
1102
1206
|
const existing = prefetchedFlights.get(url);
|
|
1103
1207
|
if (existing != null && Date.now() - existing.at < PREFETCH_LIFETIME_MS) {
|
|
@@ -1289,12 +1393,20 @@ component RenderedPage(data: mixed) {
|
|
|
1289
1393
|
}
|
|
1290
1394
|
const resolved = view.resolved;
|
|
1291
1395
|
const Page = pageComponent(resolved.page);
|
|
1396
|
+
// The route module's own export, looked up by route: `pageComponent` hands
|
|
1397
|
+
// back its `default` or `Page` as it is, so this is the same component on
|
|
1398
|
+
// every render of the same route. The React Compiler cannot see through the
|
|
1399
|
+
// lookup and reports a component created during render. The block form,
|
|
1400
|
+
// because the finding is on a JSX child and a `//` comment cannot stand
|
|
1401
|
+
// between JSX children without becoming text.
|
|
1402
|
+
// uf-lint-disable react-compiler/static-components
|
|
1292
1403
|
return (
|
|
1293
1404
|
<>
|
|
1294
1405
|
<Page params={resolved.params} searchParams={resolved.searchParams} data={data} />
|
|
1295
1406
|
{payloadElements(data)}
|
|
1296
1407
|
</>
|
|
1297
1408
|
);
|
|
1409
|
+
// uf-lint-enable react-compiler/static-components
|
|
1298
1410
|
}
|
|
1299
1411
|
|
|
1300
1412
|
/**
|
package/internal/shell.js
CHANGED
|
@@ -78,8 +78,8 @@ export function redirectDocument(error: RedirectError): RenderResult {
|
|
|
78
78
|
* carried two of them, one in each place, and only one was where a browser
|
|
79
79
|
* looks. Hoisting the rendered one leaves the metadata with a single source.
|
|
80
80
|
*/
|
|
81
|
-
export function shellFor(assets: RenderAssets): DocumentShell {
|
|
82
|
-
const head = headTags(assets);
|
|
81
|
+
export function shellFor(assets: RenderAssets, nonce?: string | null): DocumentShell {
|
|
82
|
+
const head = headTags(assets, nonce);
|
|
83
83
|
return {
|
|
84
84
|
head,
|
|
85
85
|
open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
@@ -88,7 +88,7 @@ export function shellFor(assets: RenderAssets): DocumentShell {
|
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
function headTags(assets: RenderAssets): string {
|
|
91
|
+
function headTags(assets: RenderAssets, nonce?: string | null): string {
|
|
92
92
|
let tags = "";
|
|
93
93
|
for (const href of assets.styles) {
|
|
94
94
|
tags += `<link rel="stylesheet" href="${escapeAttribute(href)}">`;
|
|
@@ -96,8 +96,16 @@ function headTags(assets: RenderAssets): string {
|
|
|
96
96
|
for (const href of assets.preloads) {
|
|
97
97
|
tags += `<link rel="modulepreload" href="${escapeAttribute(href)}">`;
|
|
98
98
|
}
|
|
99
|
+
// The nonce goes on the client entry even though it is `src` rather than
|
|
100
|
+
// inline, because a policy of `script-src 'nonce-…'` admits *no* script
|
|
101
|
+
// without one — a nonce policy is not an inline policy with an exception in
|
|
102
|
+
// it. A project whose policy also names `'self'` pays nothing for the
|
|
103
|
+
// attribute being here, and one whose policy is `'strict-dynamic'` needs it:
|
|
104
|
+
// that is the directive under which this script is the root of trust every
|
|
105
|
+
// chunk it imports inherits from.
|
|
106
|
+
const carried = nonce == null ? "" : ` nonce="${escapeAttribute(nonce)}"`;
|
|
99
107
|
for (const src of assets.scripts) {
|
|
100
|
-
tags += `<script type="module" src="${escapeAttribute(src)}"></script>`;
|
|
108
|
+
tags += `<script type="module" src="${escapeAttribute(src)}"${carried}></script>`;
|
|
101
109
|
}
|
|
102
110
|
return tags;
|
|
103
111
|
}
|
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,8 +871,9 @@ 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
|
/**
|
|
@@ -892,8 +921,9 @@ function withPayload(
|
|
|
892
921
|
async function* interleaved(
|
|
893
922
|
chunks: AsyncGenerator<string, void, void>,
|
|
894
923
|
payload: ReadableStream<Uint8Array>,
|
|
924
|
+
nonce?: string | null,
|
|
895
925
|
): AsyncGenerator<string, void, void> {
|
|
896
|
-
const encoder = createChunkEncoder();
|
|
926
|
+
const encoder = createChunkEncoder(nonce);
|
|
897
927
|
const reader = payload.getReader();
|
|
898
928
|
let written = "";
|
|
899
929
|
let ended = false;
|
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",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@uniflowed/hooks": "0.0.0-alpha.
|
|
61
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
60
|
+
"@uniflowed/hooks": "0.0.0-alpha.39",
|
|
61
|
+
"@uniflowed/server": "0.0.0-alpha.39"
|
|
62
62
|
}
|
|
63
63
|
}
|
package/rsc-client.js
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
installFlightFetch,
|
|
40
40
|
installNavigation,
|
|
41
41
|
installRouting,
|
|
42
|
+
installStaleTime,
|
|
42
43
|
} from "./internal/runtime.js";
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -72,10 +73,12 @@ export async function hydrateFlight(options: {|
|
|
|
72
73
|
readonly navigation?: Navigation,
|
|
73
74
|
readonly basePath?: string,
|
|
74
75
|
readonly trailingSlash?: TrailingSlash,
|
|
76
|
+
readonly staleTime?: number,
|
|
75
77
|
|}): Promise<void> {
|
|
76
78
|
requireServerComponentsReact("@uniflowed/router/rsc/client");
|
|
77
79
|
installNavigation(options.navigation ?? "client");
|
|
78
80
|
installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
|
|
81
|
+
installStaleTime(options.staleTime ?? 0);
|
|
79
82
|
installFlightFetch(fetchFlight);
|
|
80
83
|
installBrowserModules();
|
|
81
84
|
const flight = readDocumentPayload(document, domObserver(document));
|
package/rsc-ssr.js
CHANGED
|
@@ -32,6 +32,8 @@ import { type StreamRecord, streamReporter } from "./internal/inspector.js";
|
|
|
32
32
|
import { requireServerComponentsReact } from "./internal/react-version.js";
|
|
33
33
|
import { RedirectError } from "./internal/routing.js";
|
|
34
34
|
import type { AppProps } from "./internal/runtime.js";
|
|
35
|
+
import { currentNonce } from "@uniflowed/server/host";
|
|
36
|
+
|
|
35
37
|
import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
|
|
36
38
|
import { type DocumentBody, prerenderDocument, renderDocument } from "./internal/stream.js";
|
|
37
39
|
import type { FlightRenderer } from "./rsc.js";
|
|
@@ -101,13 +103,19 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
|
|
|
101
103
|
|},
|
|
102
104
|
): Promise<DocumentBody> {
|
|
103
105
|
const [forHtml, forBrowser] = stream.tee();
|
|
106
|
+
// Read rather than minted, for the reason `../server.js` gives: a project
|
|
107
|
+
// that has not asked for a nonce gets `null` and the document it has
|
|
108
|
+
// always had. Read here rather than passed in because both of this
|
|
109
|
+
// renderer's callers reach `documentOf`, and a render is one response.
|
|
110
|
+
const nonce = currentNonce();
|
|
104
111
|
try {
|
|
105
112
|
return await renderDocument(<App url={url} flight={readPayload(forHtml)} />, {
|
|
106
|
-
shell: shellFor(assets),
|
|
113
|
+
shell: shellFor(assets, nonce),
|
|
107
114
|
onError: settings.onError,
|
|
108
115
|
transformHead: settings.transformHead,
|
|
109
116
|
onStream: settings.onStream,
|
|
110
117
|
payload: forBrowser,
|
|
118
|
+
nonce,
|
|
111
119
|
});
|
|
112
120
|
} catch (error) {
|
|
113
121
|
void forBrowser.cancel();
|
package/rsc.js
CHANGED
|
@@ -238,6 +238,11 @@ component RoutePage(
|
|
|
238
238
|
data: mixed,
|
|
239
239
|
) {
|
|
240
240
|
const Page = pageComponent(page);
|
|
241
|
+
// The route module's own export, looked up by route: `pageComponent` hands
|
|
242
|
+
// back its `default` or `Page` as it is, so this is the same component on
|
|
243
|
+
// every render of the same route. The React Compiler cannot see through the
|
|
244
|
+
// lookup and reports a component created during render.
|
|
245
|
+
// uf-lint-disable-next-line react-compiler/static-components
|
|
241
246
|
return <Page params={params} searchParams={searchParams} data={data} />;
|
|
242
247
|
}
|
|
243
248
|
|
package/server.js
CHANGED
|
@@ -44,6 +44,8 @@ import {
|
|
|
44
44
|
} from "./internal/runtime.js";
|
|
45
45
|
|
|
46
46
|
import { type StreamDiagnostic, streamReporter } from "./internal/inspector.js";
|
|
47
|
+
import { currentNonce } from "@uniflowed/server/host";
|
|
48
|
+
|
|
47
49
|
import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
|
|
48
50
|
|
|
49
51
|
/** Asset URLs to reference from the document. */
|
|
@@ -357,6 +359,11 @@ export function createRenderer(options: {|
|
|
|
357
359
|
// browser was actually sent rather than the one that was abandoned.
|
|
358
360
|
const send = settings?.onStream;
|
|
359
361
|
const onStream = send == null ? undefined : streamReporter(url, send);
|
|
362
|
+
// Read rather than minted: a project that has not asked for a nonce gets
|
|
363
|
+
// `null` and the document it has always had. Read once for both renders
|
|
364
|
+
// below, so the error document a failed shell falls back to carries the
|
|
365
|
+
// same nonce as the policy already on the response.
|
|
366
|
+
const nonce = currentNonce();
|
|
360
367
|
|
|
361
368
|
// React reports an exception to `onError` *and*, if it was in the shell, to
|
|
362
369
|
// `onShellError` — so forwarding both would tell the host about one failure
|
|
@@ -377,10 +384,11 @@ export function createRenderer(options: {|
|
|
|
377
384
|
let body: DocumentBody;
|
|
378
385
|
try {
|
|
379
386
|
body = await renderDocument(<App url={url} initial={resolved} />, {
|
|
380
|
-
shell: shellFor(assets),
|
|
387
|
+
shell: shellFor(assets, nonce),
|
|
381
388
|
onError,
|
|
382
389
|
transformHead: settings?.transformHead,
|
|
383
390
|
onStream,
|
|
391
|
+
nonce,
|
|
384
392
|
});
|
|
385
393
|
streaming = true;
|
|
386
394
|
// Recovered before the shell was ready: a `<Suspense>` boundary whose
|
|
@@ -409,10 +417,11 @@ export function createRenderer(options: {|
|
|
|
409
417
|
// where somebody can fix it.
|
|
410
418
|
streaming = true;
|
|
411
419
|
body = await renderDocument(<App url={url} initial={resolved} />, {
|
|
412
|
-
shell: shellFor(assets),
|
|
420
|
+
shell: shellFor(assets, nonce),
|
|
413
421
|
onError,
|
|
414
422
|
transformHead: settings?.transformHead,
|
|
415
423
|
onStream,
|
|
424
|
+
nonce,
|
|
416
425
|
});
|
|
417
426
|
}
|
|
418
427
|
|