@uniflowed/router 0.0.0-alpha.9 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/action.js +324 -0
  2. package/client.js +261 -7
  3. package/handler.js +113 -99
  4. package/http-client.js +104 -0
  5. package/index.js +49 -6
  6. package/instrumentation.js +92 -0
  7. package/internal/action-endpoint.js +438 -0
  8. package/internal/action-wire.js +608 -0
  9. package/internal/base-path.js +175 -0
  10. package/internal/boundaries.js +481 -0
  11. package/internal/boundary-data.js +88 -0
  12. package/internal/compose.js +490 -0
  13. package/internal/deployment.js +160 -0
  14. package/internal/devtools.js +131 -0
  15. package/internal/diagnostics.js +169 -0
  16. package/internal/error-view.js +193 -0
  17. package/internal/flight-browser.js +242 -0
  18. package/internal/flight-chunks.js +205 -0
  19. package/internal/flight-rows.js +135 -0
  20. package/internal/flight-ssr.js +91 -0
  21. package/internal/flight.js +192 -0
  22. package/internal/head.js +219 -0
  23. package/internal/hydration.js +1085 -0
  24. package/internal/inspector.js +626 -0
  25. package/internal/native-links.js +67 -0
  26. package/internal/native-tree.js +89 -0
  27. package/internal/navigation-cache.js +181 -0
  28. package/internal/payload-rows.js +270 -0
  29. package/internal/payload.js +685 -0
  30. package/internal/prepare-document.js +54 -0
  31. package/internal/react-version.js +77 -0
  32. package/internal/resolve.js +1617 -0
  33. package/internal/resolved-summary.js +199 -0
  34. package/internal/routing.js +478 -0
  35. package/internal/runtime.js +1593 -1341
  36. package/internal/server-instrumentation.js +12 -0
  37. package/internal/server-route.js +58 -0
  38. package/internal/shell.js +125 -0
  39. package/internal/stream.js +754 -21
  40. package/middleware.js +161 -22
  41. package/native-navigation.js +217 -0
  42. package/native.js +416 -0
  43. package/package.json +48 -7
  44. package/routing.js +51 -0
  45. package/rsc-client.js +120 -0
  46. package/rsc-ssr.js +637 -0
  47. package/rsc.js +402 -0
  48. package/server-components.js +159 -0
  49. package/server.js +254 -106
@@ -0,0 +1,89 @@
1
+ // @flow
2
+ import type { RouteModule, RouteTable } from "./routing.js";
3
+
4
+ export type NativeLayout = {
5
+ readonly segment: string,
6
+ readonly file: string,
7
+ readonly module: RouteModule<>,
8
+ };
9
+ export type NativeNode = {
10
+ name: string,
11
+ module: ?RouteModule<>,
12
+ layout: boolean,
13
+ children: Array<NativeNode>,
14
+ };
15
+ export type NativeTree = { root: NativeNode, paths: Map<string, $ReadOnlyArray<string>> };
16
+
17
+ /** Module identity comes from the generated table; group names never become URLs. */
18
+ export function nativeTree(table: RouteTable<>, layouts: $ReadOnlyArray<NativeLayout>): NativeTree {
19
+ const rootLayout = layouts.find((layout) => layout.segment === "/");
20
+ const root: NativeNode = { name: "root", module: rootLayout?.module, layout: true, children: [] };
21
+ const metadata = new Map(layouts.map((layout) => [layout.module, layout]));
22
+ const paths = new Map<string, $ReadOnlyArray<string>>();
23
+ for (const route of table.routes) {
24
+ if (route.page == null) continue;
25
+ let parent = root;
26
+ const path = [];
27
+ for (const load of route.layouts) {
28
+ if (load === rootLayout?.module) continue;
29
+ const layout = metadata.get(load);
30
+ if (layout == null)
31
+ throw new Error(`Native navigation: missing layout metadata for ${route.path}`);
32
+ const name = `layout:${layout.segment}`;
33
+ let child = parent.children.find((node) => node.name === name);
34
+ if (child == null) {
35
+ child = { name, module: load, layout: true, children: [] };
36
+ parent.children.push(child);
37
+ }
38
+ parent = child;
39
+ path.push(name);
40
+ }
41
+ parent.children.push({ name: route.path, module: route.page, layout: false, children: [] });
42
+ path.push(route.path);
43
+ paths.set(route.path, path);
44
+ }
45
+ return { root, paths };
46
+ }
47
+
48
+ export type NavigationPayload = {
49
+ readonly ufHref: string,
50
+ readonly ufParams: { readonly [string]: string | $ReadOnlyArray<string> },
51
+ };
52
+ export type NavigationState = {
53
+ readonly key?: string,
54
+ readonly type?: string,
55
+ readonly index?: number,
56
+ readonly routes: $ReadOnlyArray<{
57
+ readonly name: string,
58
+ readonly params?: NavigationPayload,
59
+ readonly state?: NavigationState,
60
+ }>,
61
+ };
62
+
63
+ export function stateForPath(
64
+ names: $ReadOnlyArray<string>,
65
+ payload: NavigationPayload,
66
+ ): NavigationState {
67
+ const [name, ...rest] = names;
68
+ if (name == null) throw new Error("Native navigation: an empty screen path cannot be opened");
69
+ return {
70
+ routes: [
71
+ {
72
+ name,
73
+ ...(rest.length === 0 ? { params: payload } : { state: stateForPath(rest, payload) }),
74
+ },
75
+ ],
76
+ };
77
+ }
78
+
79
+ export function hrefFromState(state: NavigationState): string | null {
80
+ const route = state.routes[state.index ?? state.routes.length - 1];
81
+ if (route == null) return null;
82
+ return route.state == null ? (route.params?.ufHref ?? null) : hrefFromState(route.state);
83
+ }
84
+
85
+ /** React Navigation's nested navigate payload, separate from user route params. */
86
+ export function paramsForPath(names: $ReadOnlyArray<string>, payload: NavigationPayload): mixed {
87
+ const [screen, ...rest] = names;
88
+ return screen == null ? payload : { screen, params: paramsForPath(rest, payload) };
89
+ }
@@ -0,0 +1,181 @@
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 (import.meta.hot != null) {
65
+ Object.defineProperty((globalThis: $FlowFixMe), "__UF_NAVIGATION_CACHE__", {
66
+ configurable: true,
67
+ value: inspectNavigationCache,
68
+ });
69
+ }
70
+ if (staleTimeMs === 0) {
71
+ clearNavigationCache();
72
+ }
73
+ }
74
+
75
+ /** Whether navigations keep what they fetch. */
76
+ export function keepsNavigations(): boolean {
77
+ return staleTimeMs > 0;
78
+ }
79
+
80
+ /** The key a URL's route is kept under: its application path, then its query. */
81
+ export function navigationKey(pathname: string, search: string): string {
82
+ return `${applicationPathOf(pathname) ?? pathname}${search}`;
83
+ }
84
+
85
+ /** One kind of kept route. */
86
+ export type NavigationCache<T> = {|
87
+ /** The route kept under `key`, while it is fresh. A stale one is dropped. */
88
+ readonly read: (key: string) => T | null,
89
+ /** Keep `value` under `key` from now. Nothing is kept while the stale time is `0`. */
90
+ readonly store: (key: string, value: T) => void,
91
+ /** Drop what `key` keeps, if it is still `value`. */
92
+ readonly forget: (key: string, value: T) => void,
93
+ readonly clear: () => void,
94
+ /** How many routes are kept, fresh or not. */
95
+ readonly size: () => number,
96
+ readonly inspect: () => $ReadOnlyArray<NavigationCacheEntry>,
97
+ |};
98
+
99
+ /** Metadata only: inspecting the cache never exposes loader or Flight data. */
100
+ export type NavigationCacheEntry = {|
101
+ readonly key: string,
102
+ readonly cachedAt: number,
103
+ readonly staleAt: number,
104
+ readonly fresh: boolean,
105
+ |};
106
+
107
+ function createNavigationCache<T>(): NavigationCache<T> {
108
+ const entries: Map<string, {| readonly value: T, readonly at: number |}> = new Map();
109
+ return {
110
+ read(key) {
111
+ const entry = entries.get(key);
112
+ if (entry == null) {
113
+ return null;
114
+ }
115
+ if (Date.now() - entry.at >= staleTimeMs) {
116
+ entries.delete(key);
117
+ return null;
118
+ }
119
+ return entry.value;
120
+ },
121
+ store(key, value) {
122
+ if (staleTimeMs === 0) {
123
+ return;
124
+ }
125
+ // Deleted first, so a route kept again moves to the young end.
126
+ entries.delete(key);
127
+ if (entries.size >= NAVIGATION_CACHE_LIMIT) {
128
+ const oldest = entries.keys().next();
129
+ if (oldest.done !== true) {
130
+ entries.delete(oldest.value);
131
+ }
132
+ }
133
+ entries.set(key, { value, at: Date.now() });
134
+ },
135
+ forget(key, value) {
136
+ if (entries.get(key)?.value === value) {
137
+ entries.delete(key);
138
+ }
139
+ },
140
+ clear() {
141
+ entries.clear();
142
+ },
143
+ size() {
144
+ return entries.size;
145
+ },
146
+ inspect() {
147
+ const now = Date.now();
148
+ return Array.from(entries, ([key, entry]) => ({
149
+ key,
150
+ cachedAt: entry.at,
151
+ staleAt: entry.at + staleTimeMs,
152
+ fresh: now - entry.at < staleTimeMs,
153
+ }));
154
+ },
155
+ };
156
+ }
157
+
158
+ /** Payload fetches, for an application React Server Components render. */
159
+ export const flightNavigations: NavigationCache<Promise<FetchedFlight>> = createNavigationCache();
160
+
161
+ /** Resolved routes, for an application rendered from its modules. */
162
+ export const routeNavigations: NavigationCache<Promise<ResolvedRoute>> = createNavigationCache();
163
+
164
+ /** A fresh snapshot for the development console, without reading an entry. */
165
+ export function inspectNavigationCache(): {|
166
+ readonly staleTime: number,
167
+ readonly flight: $ReadOnlyArray<NavigationCacheEntry>,
168
+ readonly routes: $ReadOnlyArray<NavigationCacheEntry>,
169
+ |} {
170
+ return {
171
+ staleTime: staleTimeMs / 1000,
172
+ flight: flightNavigations.inspect(),
173
+ routes: routeNavigations.inspect(),
174
+ };
175
+ }
176
+
177
+ /** Forget every kept route. `router.refresh()` and each server action call this. */
178
+ export function clearNavigationCache(): void {
179
+ flightNavigations.clear();
180
+ routeNavigations.clear();
181
+ }
@@ -0,0 +1,270 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: reading the payload's rows out of a
4
+ // streaming document.
5
+ //
6
+ // `./payload.js` is the format and knows nothing about a browser. This is the
7
+ // other half: row 0 arrives in the document's `<script id="__uf_data">` and
8
+ // every later row arrives in a `<script data-uf-row="n">` React inserts when
9
+ // the value it holds resolves — which is *after* the parser reached the end of
10
+ // what had been sent, and usually after `hydrateRoot` has already been called.
11
+ // So a reader that only looked once would see the rows that happened to be
12
+ // early and wait forever for the rest.
13
+ //
14
+ // # A `MutationObserver`, and not an executable script
15
+ //
16
+ // The obvious mechanism is React's own: an inline `<script>` that pushes into
17
+ // a global. `internal/runtime.js` explains at length why uf's data element is
18
+ // `application/json` instead — "a script that is executed is a script a
19
+ // content security policy has to allow" — and the payload inherits the whole
20
+ // of that argument, several times over: there is now one element per deferred
21
+ // value rather than one per document, and each one carries application data
22
+ // that a `script` element with no type would hand to the JavaScript parser.
23
+ //
24
+ // The cost of keeping that property is that nothing calls uf when a row lands,
25
+ // so uf has to watch. A `MutationObserver` on the document, `childList` and
26
+ // `subtree`, is the whole of it. It is installed before `hydrateRoot` and
27
+ // disconnects itself the moment the last row the model named has arrived, so a
28
+ // page with no deferred values never has one and a page with three has one for
29
+ // as long as its slowest value takes.
30
+ //
31
+ // # Why the observer beats React to the row
32
+ //
33
+ // It has to, or a boundary that the server completed would be hydrated while
34
+ // this side still thought the value was pending. It does, for two reasons that
35
+ // are each sufficient. React writes a completed boundary's content into a
36
+ // hidden `<div>` *before* the inline script that moves it into place, so the
37
+ // row element is in the document one mutation earlier than React's own
38
+ // completion path; and a `MutationObserver` callback is a microtask, while
39
+ // React's hydration of a completed boundary is scheduled work in a later task.
40
+ //
41
+ // If it ever lost that race the failure is still bounded rather than wrong:
42
+ // `use` on a pending promise suspends, React keeps the boundary's fallback for
43
+ // one more microtask, and the row resolves it. What must not happen — and
44
+ // cannot, since both sides render the row element from the same value through
45
+ // the same `payloadJson` — is the two sides writing different bytes into it.
46
+ //
47
+ // # Rows nobody asked for
48
+ //
49
+ // Ignored. The ids that matter are the ones row 0 referred to, `resolve` is
50
+ // what records them, and an element carrying any other id is a document that
51
+ // says more than its model does. Refusing the page over it would be a
52
+ // hydration that fails because of something no component rendered; dropping it
53
+ // is the reading this module can defend.
54
+
55
+ import {
56
+ type PayloadRowMessage,
57
+ PAYLOAD_ROW_ATTRIBUTE,
58
+ PayloadRowError,
59
+ PayloadValueError,
60
+ payloadRowId,
61
+ parseRowMessage,
62
+ } from "./payload.js";
63
+
64
+ /** The document half of a payload: the promises, and the watch that fills them. */
65
+ export type PayloadReader = {|
66
+ /**
67
+ * The value of row `id`, as a promise.
68
+ *
69
+ * The `RowResolver` `decodePayload` is handed. Calling it is what tells this
70
+ * reader that the id is one the page is waiting for.
71
+ */
72
+ readonly resolve: (id: number) => Promise<mixed>,
73
+ /**
74
+ * Start reading. Applies every row already in the document, then watches for
75
+ * the rest; returns without waiting for any of them.
76
+ */
77
+ readonly watch: () => void,
78
+ /** Stop watching, whether or not every row arrived. */
79
+ readonly stop: () => void,
80
+ |};
81
+
82
+ /** The parts of a `Document` this module uses, so it needs no DOM lib. */
83
+ type DocumentLike = interface {
84
+ readonly querySelectorAll: (selector: string) => Iterable<ElementLike>,
85
+ readonly documentElement: mixed,
86
+ };
87
+
88
+ /** The parts of an `Element` this module uses. */
89
+ type ElementLike = interface {
90
+ readonly getAttribute: (name: string) => string | null,
91
+ readonly textContent: string | null,
92
+ };
93
+
94
+ /** One row the page is waiting for. */
95
+ type Slot = {|
96
+ readonly promise: Promise<mixed>,
97
+ readonly settle: (message: PayloadRowMessage) => void,
98
+ arrived: boolean,
99
+ |};
100
+
101
+ /**
102
+ * A reader over one document.
103
+ *
104
+ * `observe` is passed in rather than reached for so that this module needs no
105
+ * `MutationObserver` global to be *loaded* — the tests drive it with a
106
+ * document they mutate by hand, and a runtime without the constructor gets a
107
+ * reader that reads what is already there and never watches, which is exactly
108
+ * what a prerendered document needs.
109
+ */
110
+ export function createPayloadReader(
111
+ document: DocumentLike,
112
+ observe?: ?(callback: () => void) => (() => void) | null,
113
+ ): PayloadReader {
114
+ const slots: Map<number, Slot> = new Map();
115
+ let disconnect: (() => void) | null = null;
116
+ let reading = false;
117
+
118
+ function slotFor(id: number): Slot {
119
+ const existing = slots.get(id);
120
+ if (existing != null) {
121
+ return existing;
122
+ }
123
+ let settle: (message: PayloadRowMessage) => void = () => {};
124
+ const promise = new Promise<mixed>((fulfil, reject) => {
125
+ settle = (message) => {
126
+ if (message.error !== undefined) {
127
+ // A `PayloadRowError` rather than a plain one, carrying the row's
128
+ // exact text: the page's error boundary sees an `Error` either way,
129
+ // and `internal/runtime.js` needs the text back verbatim when it
130
+ // re-renders this row's element. See `PayloadRowError`.
131
+ reject(new PayloadRowError(message.error));
132
+ return;
133
+ }
134
+ fulfil(message.value);
135
+ };
136
+ });
137
+ // Marked handled the moment it exists. A row that says the value failed
138
+ // rejects this promise, and whether anything is listening by then depends
139
+ // on whether React has rendered the row's element yet — so without this an
140
+ // ordinary loader failure would arrive as an unhandled rejection, which
141
+ // recent Node turns into an exit.
142
+ promise.catch(() => {});
143
+ const slot: Slot = { promise, settle, arrived: false };
144
+ slots.set(id, slot);
145
+ return slot;
146
+ }
147
+
148
+ function apply(element: ElementLike): void {
149
+ const attribute = element.getAttribute(PAYLOAD_ROW_ATTRIBUTE);
150
+ if (attribute == null) {
151
+ return;
152
+ }
153
+ const id = payloadRowId(attribute);
154
+ if (id == null) {
155
+ return;
156
+ }
157
+ const slot = slots.get(id);
158
+ // A row for an id the model never referred to, or one already applied. See
159
+ // the header: neither is this reader's to complain about.
160
+ if (slot == null || slot.arrived) {
161
+ return;
162
+ }
163
+ slot.arrived = true;
164
+ try {
165
+ slot.settle(parseRowMessage(element.textContent ?? "", id));
166
+ } catch (error) {
167
+ slot.settle({
168
+ error:
169
+ error instanceof PayloadValueError
170
+ ? error.message
171
+ : `@uniflowed/router: row ${String(id)} could not be read.`,
172
+ });
173
+ }
174
+ if (finished()) {
175
+ stop();
176
+ }
177
+ }
178
+
179
+ function sweep(): void {
180
+ for (const element of document.querySelectorAll(`script[${PAYLOAD_ROW_ATTRIBUTE}]`)) {
181
+ apply(element);
182
+ }
183
+ }
184
+
185
+ function finished(): boolean {
186
+ for (const slot of slots.values()) {
187
+ if (!slot.arrived) {
188
+ return false;
189
+ }
190
+ }
191
+ return true;
192
+ }
193
+
194
+ function stop(): void {
195
+ reading = false;
196
+ const off = disconnect;
197
+ disconnect = null;
198
+ if (off != null) {
199
+ off();
200
+ }
201
+ }
202
+
203
+ function observeRest(): void {
204
+ if (!reading || disconnect != null || finished() || observe == null) {
205
+ return;
206
+ }
207
+ disconnect = observe(sweep);
208
+ if (disconnect == null) {
209
+ reading = false;
210
+ }
211
+ }
212
+
213
+ return {
214
+ resolve(id: number): Promise<mixed> {
215
+ const slot = slotFor(id);
216
+ // A reference discovered after the watch started — a client navigation
217
+ // decoding a payload of its own — still gets whatever is already in the
218
+ // document, so the order the two calls happen in does not matter.
219
+ if (reading) {
220
+ sweep();
221
+ observeRest();
222
+ }
223
+ return slot.promise;
224
+ },
225
+ watch(): void {
226
+ if (reading) {
227
+ return;
228
+ }
229
+ reading = true;
230
+ sweep();
231
+ if (slots.size > 0 && finished()) {
232
+ reading = false;
233
+ return;
234
+ }
235
+ observeRest();
236
+ },
237
+ stop,
238
+ };
239
+ }
240
+
241
+ /**
242
+ * The `observe` a browser has.
243
+ *
244
+ * Separate from [`createPayloadReader`] so the reader itself stays testable
245
+ * without one, and so the `MutationObserver` global is touched in exactly one
246
+ * place — a runtime that has no such constructor gets `null` and a reader that
247
+ * reads the document once, which is the right answer for a prerendered file
248
+ * where every row is already in it.
249
+ */
250
+ export function domObserver(
251
+ document: DocumentLike,
252
+ ): ((callback: () => void) => (() => void) | null) | null {
253
+ if (typeof MutationObserver === "undefined") {
254
+ return null;
255
+ }
256
+ const root = document.documentElement;
257
+ if (root == null) {
258
+ return null;
259
+ }
260
+ return (callback) => {
261
+ const observer = new MutationObserver(() => {
262
+ callback();
263
+ });
264
+ // $FlowFixMe[incompatible-call] `documentElement` is a `Node`; the interface above says only what is read.
265
+ observer.observe(root, { childList: true, subtree: true });
266
+ return () => {
267
+ observer.disconnect();
268
+ };
269
+ };
270
+ }