netjes 0.0.0-stage → 0.0.2

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 (222) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -2
  3. package/bin/netjes.mjs +13 -0
  4. package/cli/agents.mjs +66 -0
  5. package/cli/commands.mjs +467 -0
  6. package/cli/help.mjs +89 -0
  7. package/cli/info.mjs +454 -0
  8. package/cli/main.mjs +167 -0
  9. package/cli/packages.mjs +19 -0
  10. package/cli/process-io.mjs +163 -0
  11. package/cli/routes.mjs +31 -0
  12. package/cli/scaffold.mjs +193 -0
  13. package/cli/suggest.mjs +50 -0
  14. package/cli/terminal.mjs +40 -0
  15. package/dist/actions.d.ts +98 -0
  16. package/dist/actions.d.ts.map +1 -0
  17. package/dist/actions.js +176 -0
  18. package/dist/actions.js.map +1 -0
  19. package/dist/client-entry.d.ts +2 -0
  20. package/dist/client-entry.d.ts.map +1 -0
  21. package/dist/client-entry.js +126 -0
  22. package/dist/client-entry.js.map +1 -0
  23. package/dist/deferred.d.ts +39 -0
  24. package/dist/deferred.d.ts.map +1 -0
  25. package/dist/deferred.js +123 -0
  26. package/dist/deferred.js.map +1 -0
  27. package/dist/define-page.d.ts +15 -0
  28. package/dist/define-page.d.ts.map +1 -0
  29. package/dist/define-page.js +27 -0
  30. package/dist/define-page.js.map +1 -0
  31. package/dist/document.d.ts +19 -0
  32. package/dist/document.d.ts.map +1 -0
  33. package/dist/document.js +17 -0
  34. package/dist/document.js.map +1 -0
  35. package/dist/draft-mode.d.ts +5 -0
  36. package/dist/draft-mode.d.ts.map +1 -0
  37. package/dist/draft-mode.js +6 -0
  38. package/dist/draft-mode.js.map +1 -0
  39. package/dist/dynamic.d.ts +20 -0
  40. package/dist/dynamic.d.ts.map +1 -0
  41. package/dist/dynamic.js +37 -0
  42. package/dist/dynamic.js.map +1 -0
  43. package/dist/form-data.d.ts +10 -0
  44. package/dist/form-data.d.ts.map +1 -0
  45. package/dist/form-data.js +10 -0
  46. package/dist/form-data.js.map +1 -0
  47. package/dist/form.d.ts +35 -0
  48. package/dist/form.d.ts.map +1 -0
  49. package/dist/form.js +80 -0
  50. package/dist/form.js.map +1 -0
  51. package/dist/href.d.ts +25 -0
  52. package/dist/href.d.ts.map +1 -0
  53. package/dist/href.js +71 -0
  54. package/dist/href.js.map +1 -0
  55. package/dist/image.d.ts +36 -0
  56. package/dist/image.d.ts.map +1 -0
  57. package/dist/image.js +60 -0
  58. package/dist/image.js.map +1 -0
  59. package/dist/index.d.ts +18 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +14 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/link.d.ts +17 -0
  64. package/dist/link.d.ts.map +1 -0
  65. package/dist/link.js +46 -0
  66. package/dist/link.js.map +1 -0
  67. package/dist/locale.d.ts +3 -0
  68. package/dist/locale.d.ts.map +1 -0
  69. package/dist/locale.js +6 -0
  70. package/dist/locale.js.map +1 -0
  71. package/dist/metadata.d.ts +83 -0
  72. package/dist/metadata.d.ts.map +1 -0
  73. package/dist/metadata.js +100 -0
  74. package/dist/metadata.js.map +1 -0
  75. package/dist/modules.d.ts +74 -0
  76. package/dist/modules.d.ts.map +1 -0
  77. package/dist/modules.js +85 -0
  78. package/dist/modules.js.map +1 -0
  79. package/dist/nonce.d.ts +8 -0
  80. package/dist/nonce.d.ts.map +1 -0
  81. package/dist/nonce.js +12 -0
  82. package/dist/nonce.js.map +1 -0
  83. package/dist/payload.d.ts +171 -0
  84. package/dist/payload.d.ts.map +1 -0
  85. package/dist/payload.js +273 -0
  86. package/dist/payload.js.map +1 -0
  87. package/dist/public-config.d.ts +11 -0
  88. package/dist/public-config.d.ts.map +1 -0
  89. package/dist/public-config.js +10 -0
  90. package/dist/public-config.js.map +1 -0
  91. package/dist/query.d.ts +22 -0
  92. package/dist/query.d.ts.map +1 -0
  93. package/dist/query.js +68 -0
  94. package/dist/query.js.map +1 -0
  95. package/dist/render-cleanup.d.ts +6 -0
  96. package/dist/render-cleanup.d.ts.map +1 -0
  97. package/dist/render-cleanup.js +7 -0
  98. package/dist/render-cleanup.js.map +1 -0
  99. package/dist/root.d.ts +12 -0
  100. package/dist/root.d.ts.map +1 -0
  101. package/dist/root.js +26 -0
  102. package/dist/root.js.map +1 -0
  103. package/dist/route-tree.d.ts +56 -0
  104. package/dist/route-tree.d.ts.map +1 -0
  105. package/dist/route-tree.js +86 -0
  106. package/dist/route-tree.js.map +1 -0
  107. package/dist/router.d.ts +118 -0
  108. package/dist/router.d.ts.map +1 -0
  109. package/dist/router.js +938 -0
  110. package/dist/router.js.map +1 -0
  111. package/dist/schema.d.ts +35 -0
  112. package/dist/schema.d.ts.map +1 -0
  113. package/dist/schema.js +74 -0
  114. package/dist/schema.js.map +1 -0
  115. package/dist/script.d.ts +23 -0
  116. package/dist/script.d.ts.map +1 -0
  117. package/dist/script.js +89 -0
  118. package/dist/script.js.map +1 -0
  119. package/dist/server-entry.d.ts +2 -0
  120. package/dist/server-entry.d.ts.map +1 -0
  121. package/dist/server-entry.js +4 -0
  122. package/dist/server-entry.js.map +1 -0
  123. package/dist/server-render.d.ts +19 -0
  124. package/dist/server-render.d.ts.map +1 -0
  125. package/dist/server-render.js +193 -0
  126. package/dist/server-render.js.map +1 -0
  127. package/dist/testing-setup.d.ts +2 -0
  128. package/dist/testing-setup.d.ts.map +1 -0
  129. package/dist/testing-setup.js +8 -0
  130. package/dist/testing-setup.js.map +1 -0
  131. package/dist/testing.d.ts +76 -0
  132. package/dist/testing.d.ts.map +1 -0
  133. package/dist/testing.js +106 -0
  134. package/dist/testing.js.map +1 -0
  135. package/dist/vitals-config.d.ts +4 -0
  136. package/dist/vitals-config.d.ts.map +1 -0
  137. package/dist/vitals-config.js +5 -0
  138. package/dist/vitals-config.js.map +1 -0
  139. package/dist/vitals.d.ts +13 -0
  140. package/dist/vitals.d.ts.map +1 -0
  141. package/dist/vitals.js +60 -0
  142. package/dist/vitals.js.map +1 -0
  143. package/dist/zod.d.ts +19 -0
  144. package/dist/zod.d.ts.map +1 -0
  145. package/dist/zod.js +65 -0
  146. package/dist/zod.js.map +1 -0
  147. package/package.json +147 -4
  148. package/sidecar/server.mjs +325 -0
  149. package/skills/netjes/SKILL.md +93 -0
  150. package/skills/netjes/references/caching-and-streaming.md +68 -0
  151. package/skills/netjes/references/data-and-actions.md +98 -0
  152. package/skills/netjes/references/deployment.md +57 -0
  153. package/skills/netjes/references/routing.md +65 -0
  154. package/skills/netjes/references/testing.md +57 -0
  155. package/src/actions.tsx +241 -0
  156. package/src/client-entry.tsx +126 -0
  157. package/src/deferred.ts +134 -0
  158. package/src/define-page.tsx +41 -0
  159. package/src/document.tsx +49 -0
  160. package/src/draft-mode.ts +6 -0
  161. package/src/dynamic.tsx +64 -0
  162. package/src/form-data.ts +10 -0
  163. package/src/form.tsx +122 -0
  164. package/src/href.ts +79 -0
  165. package/src/image.tsx +91 -0
  166. package/src/index.ts +40 -0
  167. package/src/link.tsx +73 -0
  168. package/src/locale.ts +6 -0
  169. package/src/metadata.tsx +181 -0
  170. package/src/modules.ts +133 -0
  171. package/src/nonce.tsx +14 -0
  172. package/src/payload.ts +405 -0
  173. package/src/public-config.tsx +19 -0
  174. package/src/query.tsx +82 -0
  175. package/src/render-cleanup.ts +7 -0
  176. package/src/root.tsx +39 -0
  177. package/src/route-tree.tsx +139 -0
  178. package/src/router.tsx +1188 -0
  179. package/src/schema.ts +98 -0
  180. package/src/script.tsx +103 -0
  181. package/src/server-entry.tsx +4 -0
  182. package/src/server-render.tsx +223 -0
  183. package/src/testing-setup.ts +8 -0
  184. package/src/testing.tsx +177 -0
  185. package/src/virtual.d.ts +12 -0
  186. package/src/vitals-config.ts +6 -0
  187. package/src/vitals.tsx +68 -0
  188. package/src/zod.ts +72 -0
  189. package/template/.dockerignore +11 -0
  190. package/template/Dockerfile +58 -0
  191. package/template/Features/Home/HomePage.cs +32 -0
  192. package/template/Program.cs +28 -0
  193. package/template/Properties/launchSettings.json +13 -0
  194. package/template/README.md +24 -0
  195. package/template/SiteProps.cs +4 -0
  196. package/template/_AGENTS.md +64 -0
  197. package/template/_CLAUDE.md +1 -0
  198. package/template/__NAME__.csproj +11 -0
  199. package/template/_gitattributes +2 -0
  200. package/template/_gitignore +11 -0
  201. package/template/app/about/page.tsx +17 -0
  202. package/template/app/error.tsx +15 -0
  203. package/template/app/layout.tsx +33 -0
  204. package/template/app/not-found.tsx +14 -0
  205. package/template/app/page.tsx +14 -0
  206. package/template/appsettings.Development.json +3 -0
  207. package/template/appsettings.json +9 -0
  208. package/template/components/Greeting.test.tsx +25 -0
  209. package/template/components/Greeting.tsx +26 -0
  210. package/template/components/HelloForm.test.tsx +27 -0
  211. package/template/components/HelloForm.tsx +34 -0
  212. package/template/docker-compose.override.yml +15 -0
  213. package/template/docker-compose.prod.yml +26 -0
  214. package/template/docker-compose.yml +16 -0
  215. package/template/generated/netjes.ts +73 -0
  216. package/template/package.json +36 -0
  217. package/template/public/favicon.svg +6 -0
  218. package/template/styles/global.css +7 -0
  219. package/template/tsconfig.json +19 -0
  220. package/template/vite.config.ts +7 -0
  221. package/vite/plugin.d.ts +29 -0
  222. package/vite/plugin.mjs +291 -0
package/src/router.tsx ADDED
@@ -0,0 +1,1188 @@
1
+ import {
2
+ type ComponentType,
3
+ type ReactNode,
4
+ Suspense,
5
+ createContext,
6
+ startTransition,
7
+ use,
8
+ useContext,
9
+ useEffect,
10
+ useLayoutEffect,
11
+ useMemo,
12
+ useState,
13
+ } from 'react';
14
+ import { actionRequest, expectActionClient, fetchAction, setActionClient } from './actions.tsx';
15
+ import { deferredRegistry, revivePayload } from './deferred.ts';
16
+ import type { EncodedForm } from './form-data.ts';
17
+ import type { Href } from './href.ts';
18
+ import { MissingModuleError, type ModuleLoader, type RouteModules } from './modules.ts';
19
+ import {
20
+ type ActionResponse,
21
+ type AntiforgeryToken,
22
+ COOKIES_HEADER,
23
+ DATA_HEADER,
24
+ type NavigationPayload,
25
+ type PagePayload,
26
+ type Params,
27
+ SEGMENTS_HEADER,
28
+ SERVER_TIME_HEADER,
29
+ type SegmentRef,
30
+ type SpecialPageRef,
31
+ parseAntiforgery,
32
+ parseNavigationPayload,
33
+ } from './payload.ts';
34
+ import { ErrorBoundary, LayoutDepthContext, RouteTree } from './route-tree.tsx';
35
+ import { type StandardSchemaV1, parseLenient, parseWith } from './schema.ts';
36
+
37
+ /** A form post that did not produce a page: a non-page error response (`status`), or no response at all (`null`). */
38
+ export type SubmitError = { status: number | null; action: string };
39
+
40
+ /** What the router is doing: idle, or on its way to `location` for a navigation (`loading`) or a form post (`submitting`). */
41
+ export type NavigationState =
42
+ | { state: 'idle'; error?: SubmitError }
43
+ | { state: 'loading' | 'submitting'; location: string };
44
+
45
+ /** The client router that `useRouter()` returns. */
46
+ export type Router = {
47
+ push(href: Href, options?: { scroll?: boolean }): Promise<void>;
48
+ replace(href: Href, options?: { scroll?: boolean }): Promise<void>;
49
+ /** Fetches the page payload and modules ahead of a navigation. Links do this on hover and focus. */
50
+ prefetch(href: Href): void;
51
+ /** Re-runs the current page's loader and re-renders with the new props. */
52
+ refresh(): Promise<void>;
53
+ back(): void;
54
+ forward(): void;
55
+ };
56
+
57
+ export type SubmitBody = EncodedForm;
58
+
59
+ /** Called with every server action response, e.g. by the query shell to apply query updates. */
60
+ export type ActionListener = (response: ActionResponse) => void;
61
+
62
+ export type InternalRouter = Router & {
63
+ submit(action: URL, body: SubmitBody): Promise<void>;
64
+ onActionResponse(listener: ActionListener): () => void;
65
+ /**
66
+ * Called as soon as a server action call or form post says it set or deleted a cookie (signed someone in or out),
67
+ * e.g. by the query shell to drop the previous user's queries.
68
+ */
69
+ onCookiesChanged(listener: () => void): () => void;
70
+ };
71
+
72
+ /**
73
+ * A page to render. An intercepted navigation keeps the page it came from (`base`, rendered under its own key) and
74
+ * shows the new URL's intercepting page in a slot.
75
+ */
76
+ type Loaded = { payload: PagePayload; modules: RouteModules; url: URL; pageKey?: string; base?: Loaded };
77
+ /**
78
+ * A page to render, or a URL to load the old-fashioned way. A form post can also get back an HTML document to show,
79
+ * a file to download, or a failure, none of which may be re-requested (that would repeat or turn it into a GET).
80
+ */
81
+ type LoadResult =
82
+ | Loaded
83
+ | { hard: URL }
84
+ | { document: string; url: string }
85
+ | { file: Blob; name: string }
86
+ | { failed: number };
87
+ type PageRequest = { method: 'GET' } | ({ method: 'POST' } & SubmitBody);
88
+
89
+ function isLoaded(result: LoadResult): result is Loaded {
90
+ return 'payload' in result;
91
+ }
92
+ type ScrollIntent = { kind: 'none' } | { kind: 'top' } | { kind: 'hash'; hash: string } | { kind: 'restore'; x: number; y: number };
93
+ type HistoryMode = 'push' | 'replace' | 'none' | 'auto';
94
+
95
+ const IDLE: NavigationState = { state: 'idle' };
96
+ const ANTIFORGERY_URL = '/_netjes/antiforgery';
97
+ const NONE: ScrollIntent = { kind: 'none' };
98
+ const TOP: ScrollIntent = { kind: 'top' };
99
+ const STATE_KEY = '__netjes';
100
+ const PREFETCH_TTL_MS = 30_000;
101
+ const MAX_REDIRECTS = 10;
102
+ const MAX_REMEMBERED_ENTRIES = 50;
103
+ /** What a tab posts to the app's other tabs when one of its calls or posts changed cookies. */
104
+ const COOKIES_CHANGED_MESSAGE = 'cookies-changed';
105
+
106
+ /**
107
+ * The channel the tabs of one app share: same origin (BroadcastChannel's own scope) and same base path, as cookie
108
+ * authentication scopes its cookie to the base path by default.
109
+ */
110
+ const channelName = (basePath: string) => `netjes:${basePath || '/'}`;
111
+
112
+ let keyCounter = 0;
113
+ const createKey = () => `${Date.now().toString(36)}-${(keyCounter++).toString(36)}`;
114
+
115
+ function readKey(state: unknown): string | undefined {
116
+ if (typeof state !== 'object' || state === null || !(STATE_KEY in state)) return undefined;
117
+ const value = (state as Record<string, unknown>)[STATE_KEY];
118
+ return typeof value === 'string' ? value : undefined;
119
+ }
120
+
121
+ /**
122
+ * Records how far the browser clock is from the server's, given a server time for "now": the `X-Netjes-Now` header
123
+ * of a data response, or for the initial document the payload's `renderedAt` (so the browser judges seeded data
124
+ * as fresh as the server did when it rendered, and hydration matches).
125
+ */
126
+ export function withClockOffset<T extends PagePayload>(payload: T, serverNow: number | undefined, now = Date.now()): T {
127
+ return serverNow === undefined || !Number.isFinite(serverNow) ? payload : { ...payload, clockOffset: now - serverNow };
128
+ }
129
+
130
+ /**
131
+ * Root-relative hrefs (`/about`, not `//host` or `https://…`) are written as if the app were mounted at the root.
132
+ * One that already starts with the base path (built from it on purpose) is left as it is.
133
+ */
134
+ export function withBasePath(href: string, basePath: string): string {
135
+ if (!basePath || !href.startsWith('/') || href.startsWith('//') || href.startsWith('/\\')) return href;
136
+ const alreadyThere = href === basePath || href.startsWith(`${basePath}/`) || href.startsWith(`${basePath}?`) || href.startsWith(`${basePath}#`);
137
+ return alreadyThere ? href : basePath + href;
138
+ }
139
+
140
+ function withoutBasePath(pathname: string, basePath: string): string {
141
+ if (!basePath || !pathname.startsWith(basePath)) return pathname;
142
+ const rest = pathname.slice(basePath.length);
143
+ return rest === '' ? '/' : rest.startsWith('/') ? rest : pathname;
144
+ }
145
+
146
+ const samePage = (a: URL, b: URL) => a.pathname === b.pathname && a.search === b.search;
147
+
148
+ /** Only http(s) URLs are ever navigated to; `javascript:` and friends would run script in the page. */
149
+ const isWebUrl = (url: URL) => url.protocol === 'http:' || url.protocol === 'https:';
150
+
151
+ /**
152
+ * The URL of a page the server sent (`payload.url`, the request's path and query), on the base's origin. That path
153
+ * can start with `//` (a request for `https://app//evil.example/x`), which `new URL(path, base)` reads as a host.
154
+ */
155
+ function pageUrl(path: string, base: string | URL): URL {
156
+ return path.startsWith('/') ? new URL(new URL(base).origin + path) : new URL(path, base);
157
+ }
158
+
159
+ /**
160
+ * The first page's URL. Its url is the path the browser requested, so one on another origin is not where the browser
161
+ * is: the router then goes by the browser's location, which refresh() reloads.
162
+ */
163
+ function initialUrl(path: string): URL {
164
+ if (typeof window === 'undefined') return pageUrl(path, 'http://localhost');
165
+ const url = pageUrl(path, window.location.href);
166
+ return url.origin === window.location.origin ? url : withHash(new URL(window.location.href), '');
167
+ }
168
+
169
+ function withHash(url: URL, hash: string): URL {
170
+ const copy = new URL(url);
171
+ copy.hash = hash;
172
+ return copy;
173
+ }
174
+
175
+ type FetchResult = NavigationPayload | { html: string; url: string } | { file: Blob; name: string } | { failed: number } | null;
176
+
177
+ function attachmentName(disposition: string): string {
178
+ const encoded = /filename\*=UTF-8''([^;]+)/i.exec(disposition)?.[1];
179
+ if (encoded) {
180
+ try {
181
+ return decodeURIComponent(encoded);
182
+ } catch {
183
+ // Fall through to the plain filename.
184
+ }
185
+ }
186
+ return /filename="?([^";]+)"?/i.exec(disposition)?.[1] ?? 'download';
187
+ }
188
+
189
+ async function fetchPayload(
190
+ url: URL,
191
+ request: PageRequest,
192
+ signal: AbortSignal,
193
+ segmentKeys: readonly string[] = [],
194
+ token?: AntiforgeryToken,
195
+ onCookiesChanged?: () => void,
196
+ ): Promise<FetchResult> {
197
+ const headers: Record<string, string> = { [DATA_HEADER]: '1', Accept: 'application/json' };
198
+ if (request.method === 'POST' && token?.headerName && token.requestToken) headers[token.headerName] = token.requestToken;
199
+ if (request.method === 'GET' && segmentKeys.length > 0) headers[SEGMENTS_HEADER] = segmentKeys.join(',');
200
+ if (request.method === 'POST' && request.contentType) headers['Content-Type'] = request.contentType;
201
+ const init: RequestInit = { method: request.method, headers, credentials: 'same-origin', signal };
202
+ if (request.method === 'POST' && request.body !== undefined) init.body = request.body;
203
+ const response = await fetch(url.href, init);
204
+ // A form post that signed someone in or out; script cannot read Set-Cookie itself.
205
+ if (request.method === 'POST' && response.headers.get(COOKIES_HEADER) === 'changed') onCookiesChanged?.();
206
+ const type = response.headers.get('content-type') ?? '';
207
+ if (type.includes('application/x-ndjson') && response.body) {
208
+ // Page.Defer: the payload first, then a line per deferred value as the server completes it.
209
+ const registry = deferredRegistry();
210
+ const lines = readLines(response.body, signal);
211
+ const first = await lines.next();
212
+ if (first.done) throw new Error('netjes: the server sent an empty page stream');
213
+ const payload = parseNavigationPayload(JSON.parse(first.value));
214
+ void (async () => {
215
+ try {
216
+ for await (const line of { [Symbol.asyncIterator]: () => lines }) {
217
+ const item = JSON.parse(line) as { id: string; value?: unknown; error?: string };
218
+ if (typeof item.error === 'string') registry.reject(item.id, item.error);
219
+ else registry.resolve(item.id, item.value);
220
+ }
221
+ } catch (error) {
222
+ if (!signal.aborted) console.error('netjes: reading deferred values failed', error);
223
+ } finally {
224
+ // Whatever the stream did not deliver will not come: fail it, so its Suspense boundary does not wait forever.
225
+ registry.rejectPending((id) => `netjes: the page stream ended before deferred value ${id} arrived`);
226
+ }
227
+ })();
228
+ if (payload.kind !== 'page') return payload;
229
+ const serverNow = Number(response.headers.get(SERVER_TIME_HEADER) ?? Number.NaN);
230
+ return withClockOffset(revivePayload(payload, registry.source), Number.isFinite(serverNow) ? serverNow : undefined);
231
+ }
232
+ if (type.includes('application/json')) {
233
+ const payload = parseNavigationPayload(await response.json());
234
+ if (payload.kind !== 'page') return payload;
235
+ // Without the header (a cache in front of UseNetjes) there is no fresh server time: take the timestamps as they
236
+ // are. Falling back to renderedAt would make a cached payload look brand new.
237
+ const serverNow = Number(response.headers.get(SERVER_TIME_HEADER) ?? Number.NaN);
238
+ return withClockOffset(payload, Number.isFinite(serverNow) ? serverNow : undefined);
239
+ }
240
+ // Something other than a Netjes page answered. A GET can simply be loaded by the browser instead.
241
+ if (request.method === 'GET') return null;
242
+ // A POST must not be sent again. Show what a native submission would have shown, where that is safe.
243
+ if (!response.ok) return { failed: response.status };
244
+ if (type.includes('text/html')) return { html: await response.text(), url: response.url || url.href };
245
+ const disposition = response.headers.get('content-disposition') ?? '';
246
+ if (/^\s*attachment/i.test(disposition)) return { file: await response.blob(), name: attachmentName(disposition) };
247
+ return { failed: response.status };
248
+ }
249
+
250
+ /** The lines of a streamed body, as they arrive. */
251
+ async function* readLines(body: ReadableStream<Uint8Array>, signal: AbortSignal): AsyncGenerator<string, void, undefined> {
252
+ const reader = body.getReader();
253
+ const decoder = new TextDecoder();
254
+ let buffered = '';
255
+ try {
256
+ while (!signal.aborted) {
257
+ const { value, done } = await reader.read();
258
+ if (done) break;
259
+ buffered += decoder.decode(value, { stream: true });
260
+ let newline = buffered.indexOf('\n');
261
+ while (newline >= 0) {
262
+ const line = buffered.slice(0, newline).trim();
263
+ buffered = buffered.slice(newline + 1);
264
+ if (line) yield line;
265
+ newline = buffered.indexOf('\n');
266
+ }
267
+ }
268
+ const rest = (buffered + decoder.decode()).trim();
269
+ if (rest) yield rest;
270
+ } finally {
271
+ reader.releaseLock();
272
+ }
273
+ }
274
+
275
+ type NavigatorDeps = {
276
+ loader: ModuleLoader;
277
+ version: string;
278
+ /** `UsePathBase`'s base path ('' at the root): added to the root-relative hrefs the app navigates to. */
279
+ basePath: string;
280
+ hardNavigate(href: string): void;
281
+ showDocument(html: string, url: string): void;
282
+ download(file: Blob, name: string): void;
283
+ setPending(state: NavigationState): void;
284
+ commit(loaded: Loaded, scroll: ScrollIntent): void;
285
+ };
286
+
287
+ type GoOptions = {
288
+ history: HistoryMode;
289
+ scroll: ScrollIntent | 'auto';
290
+ pending: 'loading' | 'submitting';
291
+ key?: string;
292
+ usePrefetch: boolean;
293
+ /** Let the server skip loaders of layout segments this page already has. Off for refreshes and form posts. */
294
+ reuseSegments: boolean;
295
+ /** Refreshing an intercepted page: load this page under it again too, and show the result in its slot again. */
296
+ interceptOver?: URL;
297
+ };
298
+
299
+ /**
300
+ * A navigation on its way. `again` starts it over (GETs only: a form post is never sent twice), and `restarted` is that
301
+ * new attempt, which the navigation finishes with. `stale` says another response changed cookies while it ran, so what
302
+ * it loads shows whoever was signed in before; `changedCookies` says its own response did.
303
+ */
304
+ type ActiveNavigation = {
305
+ controller: AbortController;
306
+ again: (() => void) | null;
307
+ restarted?: Promise<void>;
308
+ stale: boolean;
309
+ changedCookies: boolean;
310
+ };
311
+
312
+ type Prefetch = { at: number; result: Promise<LoadResult>; controller: AbortController; loading: boolean };
313
+
314
+ /** Client-side navigation: fetches page payloads from the server, keeps history in sync, restores scroll. */
315
+ class Navigator {
316
+ private active: ActiveNavigation | null = null;
317
+ private readonly prefetched = new Map<string, Prefetch>();
318
+ private readonly entries = new Map<string, Loaded>();
319
+ private readonly scrollPositions = new Map<string, { x: number; y: number }>();
320
+ private currentKey = createKey();
321
+ /** The page on screen is from before cookies changed: kept for no history entry until one comes from the server. */
322
+ private currentIsStale = false;
323
+ /** Shared with the app's other tabs, where the browser has BroadcastChannel; null until started, and once stopped. */
324
+ private channel: BroadcastChannel | null = null;
325
+
326
+ private readonly deps: NavigatorDeps;
327
+ private current: Loaded;
328
+
329
+ constructor(deps: NavigatorDeps, current: Loaded) {
330
+ this.deps = deps;
331
+ this.current = current;
332
+ }
333
+
334
+ start(): () => void {
335
+ window.history.scrollRestoration = 'manual';
336
+ const existing = readKey(window.history.state);
337
+ if (existing) this.currentKey = existing;
338
+ else window.history.replaceState({ ...(window.history.state ?? {}), [STATE_KEY]: this.currentKey }, '');
339
+ this.remember(this.currentKey, this.current);
340
+
341
+ let frame = 0;
342
+ const onScroll = () => {
343
+ if (frame) return;
344
+ frame = requestAnimationFrame(() => {
345
+ frame = 0;
346
+ this.rememberScroll();
347
+ });
348
+ };
349
+ const onPopState = (event: PopStateEvent) => this.onPopState(event);
350
+ window.addEventListener('popstate', onPopState);
351
+ window.addEventListener('scroll', onScroll, { passive: true });
352
+ const channel = typeof BroadcastChannel === 'function' ? new BroadcastChannel(channelName(this.deps.basePath)) : null;
353
+ if (channel) {
354
+ channel.onmessage = (event: MessageEvent<unknown>) => {
355
+ if (event.data === COOKIES_CHANGED_MESSAGE) this.cookiesChangedInAnotherTab();
356
+ };
357
+ }
358
+ this.channel = channel;
359
+ return () => {
360
+ window.removeEventListener('popstate', onPopState);
361
+ window.removeEventListener('scroll', onScroll);
362
+ cancelAnimationFrame(frame);
363
+ this.stopListeningToOtherTabs();
364
+ };
365
+ }
366
+
367
+ private stopListeningToOtherTabs() {
368
+ this.channel?.close();
369
+ this.channel = null;
370
+ }
371
+
372
+ async navigate(href: string, history: 'push' | 'replace', scroll: boolean): Promise<void> {
373
+ const url = new URL(withBasePath(href, this.deps.basePath), window.location.href);
374
+ if (!isWebUrl(url)) {
375
+ console.error(`netjes: refusing to navigate to ${url.protocol} URL`);
376
+ return;
377
+ }
378
+ if (url.origin !== window.location.origin) {
379
+ this.hard(url);
380
+ return;
381
+ }
382
+
383
+ if (url.hash && samePage(url, this.current.url)) {
384
+ // Same document, different fragment: no fetch, just history and scroll. It supersedes any pending navigation.
385
+ this.cancelActive();
386
+ this.rememberScroll();
387
+ const key = createKey();
388
+ window.history[history === 'push' ? 'pushState' : 'replaceState']({ [STATE_KEY]: key }, '', url.href);
389
+ this.setCurrent(key, { ...this.current, url });
390
+ this.deps.commit(this.current, scroll ? { kind: 'hash', hash: url.hash } : NONE);
391
+ return;
392
+ }
393
+
394
+ await this.go(url, { method: 'GET' }, {
395
+ history,
396
+ scroll: !scroll ? NONE : url.hash ? { kind: 'hash', hash: url.hash } : TOP,
397
+ pending: 'loading',
398
+ usePrefetch: true,
399
+ reuseSegments: true,
400
+ });
401
+ }
402
+
403
+ submit(action: URL, body: SubmitBody): Promise<void> {
404
+ return this.go(action, { method: 'POST', ...body }, { history: 'auto', scroll: 'auto', pending: 'submitting', usePrefetch: false, reuseSegments: false });
405
+ }
406
+
407
+ /**
408
+ * Loads the page on screen again, in place: same history entry, fragment and scroll position. `keepPrefetched` after
409
+ * cookies changed: the prefetched pages left are all from after the change.
410
+ */
411
+ refresh({ keepPrefetched = false } = {}): Promise<void> {
412
+ if (!keepPrefetched) this.prefetched.clear();
413
+ // In a modal (an intercepted page), as in Next.js: the modal stays open over the page it opened on.
414
+ const base = this.current.base;
415
+ return this.go(this.current.url, { method: 'GET' }, {
416
+ history: 'replace',
417
+ scroll: NONE,
418
+ pending: 'loading',
419
+ usePrefetch: false,
420
+ reuseSegments: false,
421
+ ...(base ? { interceptOver: withHash(base.url, '') } : {}),
422
+ });
423
+ }
424
+
425
+ /** The current page: actions send its token and URL, and refresh it when they revalidate. */
426
+ get page(): Loaded {
427
+ return this.current;
428
+ }
429
+
430
+ private tokenRequest: Promise<AntiforgeryToken | undefined> | null = null;
431
+
432
+ /**
433
+ * A token for pages that carry none (output-cached pages leave it out so they can be shared): fetched once from
434
+ * the server, which also sets the antiforgery cookie, then reused.
435
+ */
436
+ fetchedToken(): Promise<AntiforgeryToken | undefined> {
437
+ this.tokenRequest ??= fetch(withBasePath(ANTIFORGERY_URL, this.deps.basePath), { credentials: 'same-origin', headers: { Accept: 'application/json' } })
438
+ .then(async (response) => (response.ok ? (parseAntiforgery(await response.json()) as AntiforgeryToken) : undefined))
439
+ .catch(() => {
440
+ this.tokenRequest = null;
441
+ return undefined;
442
+ });
443
+ return this.tokenRequest;
444
+ }
445
+
446
+ /** Whether a navigation is on its way (fetching or about to commit). */
447
+ get navigating(): boolean {
448
+ return this.active !== null;
449
+ }
450
+
451
+ /** Drops the fetched token; the next action or post fetches a new one (an action may have signed the user in or out). */
452
+ forgetToken() {
453
+ this.tokenRequest = null;
454
+ }
455
+
456
+ /** After a server action: the data behind any page may have changed, so prefetched pages are dropped. */
457
+ forgetPrefetched() {
458
+ this.prefetched.clear();
459
+ }
460
+
461
+ /** The page on screen is from before cookies last changed, and no page has come from the server since. */
462
+ get fromBeforeCookieChange(): boolean {
463
+ return this.currentIsStale;
464
+ }
465
+
466
+ /**
467
+ * An action or post of this tab changed cookies (signed someone in or out). The app's other tabs share the cookies,
468
+ * so they are told, and forget the previous user's pages too.
469
+ */
470
+ cookiesChanged(from?: ActiveNavigation) {
471
+ this.forgetPagesBeforeCookieChange(from);
472
+ this.channel?.postMessage(COOKIES_CHANGED_MESSAGE);
473
+ }
474
+
475
+ /**
476
+ * Another tab of the app changed cookies: this tab forgets the same as that one did, and loads the page on screen
477
+ * from the server, as that tab does after its call, unless a navigation on its way (started over) shows a page
478
+ * soon. It posts nothing back, so tabs never answer each other's messages.
479
+ */
480
+ private cookiesChangedInAnotherTab() {
481
+ this.forgetToken();
482
+ this.forgetPagesBeforeCookieChange();
483
+ if (!this.navigating) void this.refresh({ keepPrefetched: true });
484
+ }
485
+
486
+ /**
487
+ * After cookies changed (signing in or out, like Next.js's rule for cookies set by an action): the pages kept for
488
+ * back and forward, and prefetched ones, show the app as it was before, so they are loaded from the server again.
489
+ * Requests still on their way went out with the old cookies: a navigation and the prefetches in flight start over.
490
+ * A form post (`from`, when its own response said so) is never sent twice. The page on screen stays until a page
491
+ * comes from the server, and is kept for no history entry, not even one a fragment link on it adds.
492
+ */
493
+ private forgetPagesBeforeCookieChange(from?: ActiveNavigation) {
494
+ this.entries.clear();
495
+ const loading = [...this.prefetched].filter(([, prefetch]) => prefetch.loading);
496
+ this.prefetched.clear();
497
+ for (const [href, prefetch] of loading) {
498
+ prefetch.controller.abort();
499
+ this.startPrefetch(new URL(href));
500
+ }
501
+ this.currentIsStale = true;
502
+ const active = this.active;
503
+ if (active === from) {
504
+ if (from) from.changedCookies = true;
505
+ } else if (active?.again) {
506
+ active.again();
507
+ } else if (active) {
508
+ active.stale = true;
509
+ }
510
+ for (const listener of this.cookieListeners) listener();
511
+ }
512
+
513
+ private readonly cookieListeners = new Set<() => void>();
514
+
515
+ onCookiesChanged(listener: () => void): () => void {
516
+ this.cookieListeners.add(listener);
517
+ return () => this.cookieListeners.delete(listener);
518
+ }
519
+
520
+ prefetch(href: string): void {
521
+ const url = new URL(withBasePath(href, this.deps.basePath), window.location.href);
522
+ if (url.origin !== window.location.origin) return;
523
+ url.hash = '';
524
+ const now = Date.now();
525
+ for (const [key, entry] of this.prefetched) if (now - entry.at >= PREFETCH_TTL_MS) this.prefetched.delete(key);
526
+ if (this.prefetched.has(url.href)) return;
527
+ this.startPrefetch(url);
528
+ }
529
+
530
+ private startPrefetch(url: URL) {
531
+ const controller = new AbortController();
532
+ const prefetch: Prefetch = { at: Date.now(), result: this.load(url, { method: 'GET' }, controller.signal, true), controller, loading: true };
533
+ prefetch.result.then(
534
+ () => {
535
+ prefetch.loading = false;
536
+ },
537
+ () => {
538
+ prefetch.loading = false;
539
+ // A prefetch started over has taken its place.
540
+ if (this.prefetched.get(url.href) === prefetch) this.prefetched.delete(url.href);
541
+ },
542
+ );
543
+ this.prefetched.set(url.href, prefetch);
544
+ }
545
+
546
+ private onPopState(event: PopStateEvent) {
547
+ const url = new URL(window.location.href);
548
+ const key = readKey(event.state) ?? createKey();
549
+ const saved = this.scrollPositions.get(key);
550
+ const scroll: ScrollIntent = saved ? { kind: 'restore', ...saved } : url.hash ? { kind: 'hash', hash: url.hash } : TOP;
551
+ const cached = this.entries.get(key);
552
+ const known = cached && samePage(cached.url, url) ? cached : samePage(url, this.current.url) ? this.current : null;
553
+ if (known) {
554
+ this.active?.controller.abort();
555
+ this.setCurrent(key, { ...known, url });
556
+ this.deps.commit(this.current, scroll);
557
+ return;
558
+ }
559
+ void this.go(url, { method: 'GET' }, { history: 'none', scroll, pending: 'loading', key, usePrefetch: false, reuseSegments: true });
560
+ }
561
+
562
+ private async go(url: URL, request: PageRequest, options: GoOptions): Promise<void> {
563
+ if (url.origin !== window.location.origin) {
564
+ // navigate() and Form hand other origins to the browser, and every page URL the router keeps is on this one.
565
+ // Refuse one that gets here all the same: a failed load would send the browser to it.
566
+ console.error(`netjes: refusing to load ${url.origin} in the client router`);
567
+ return;
568
+ }
569
+ this.active?.controller.abort();
570
+ const controller = new AbortController();
571
+ const navigation: ActiveNavigation = {
572
+ controller,
573
+ again:
574
+ request.method === 'GET'
575
+ ? () => {
576
+ navigation.restarted = this.go(url, request, options);
577
+ }
578
+ : null,
579
+ stale: false,
580
+ changedCookies: false,
581
+ };
582
+ this.active = navigation;
583
+ this.deps.setPending({ state: options.pending, location: withoutBasePath(url.pathname, this.deps.basePath) + url.search });
584
+ let committed = false;
585
+ let failure: SubmitError | undefined;
586
+ try {
587
+ const prefetched = options.usePrefetch ? this.takePrefetched(url, controller.signal) : undefined;
588
+ const under = options.interceptOver ? this.load(options.interceptOver, { method: 'GET' }, controller.signal, false) : undefined;
589
+ under?.catch(() => {});
590
+ const result = await (prefetched ?? this.load(url, request, controller.signal, options.reuseSegments, () => this.cookiesChanged(navigation)));
591
+ // Superseded by another navigation, or started over because cookies changed: then it ends with the new attempt.
592
+ if (controller.signal.aborted) return navigation.restarted;
593
+ // A post may have changed what other pages show; do not serve them from before it. One that changed cookies has
594
+ // dropped them already, and started those in flight over.
595
+ if (request.method === 'POST' && !navigation.changedCookies) this.prefetched.clear();
596
+ if ('hard' in result) {
597
+ this.hard(result.hard);
598
+ return;
599
+ }
600
+ if ('document' in result) {
601
+ // The tab belongs to that document: another tab's sign-in or sign-out must not load a page into it.
602
+ this.stopListeningToOtherTabs();
603
+ this.deps.showDocument(result.document, result.url);
604
+ return;
605
+ }
606
+ if ('file' in result) {
607
+ this.deps.download(result.file, result.name);
608
+ return;
609
+ }
610
+ if ('failed' in result) {
611
+ // Keep the page, and what the user typed, as it is; useNavigation() tells the page.
612
+ console.error(`netjes: the form submission failed with status ${result.failed}`);
613
+ failure = { status: result.failed, action: withoutBasePath(url.pathname, this.deps.basePath) + url.search };
614
+ return;
615
+ }
616
+
617
+ const base = under ? await under.catch(() => null) : null;
618
+ const shown =
619
+ options.reuseSegments && request.method === 'GET'
620
+ ? await this.intercepted(result)
621
+ : base && isLoaded(base)
622
+ ? await this.intercepted(result, base)
623
+ : result;
624
+ if (controller.signal.aborted) return navigation.restarted;
625
+ const previous = this.current.url;
626
+ const history = options.history === 'auto' ? (samePage(shown.url, previous) ? 'replace' : 'push') : options.history;
627
+ let key = options.key ?? createKey();
628
+ if (history === 'none') {
629
+ if (!options.key) window.history.replaceState({ [STATE_KEY]: key }, '');
630
+ } else {
631
+ this.rememberScroll();
632
+ key = createKey();
633
+ window.history[history === 'push' ? 'pushState' : 'replaceState']({ [STATE_KEY]: key }, '', shown.url.href);
634
+ }
635
+
636
+ this.currentIsStale = navigation.stale;
637
+ this.setCurrent(key, shown);
638
+ this.forgetToken();
639
+ committed = true;
640
+ const scroll = shown.base ? NONE : options.scroll === 'auto' ? (shown.url.pathname === previous.pathname ? NONE : TOP) : options.scroll;
641
+ this.deps.commit(shown, scroll);
642
+ } catch (error) {
643
+ if (controller.signal.aborted) return navigation.restarted;
644
+ if (request.method === 'POST') {
645
+ // Never turn a failed submission into a GET: keep the page, and what the user typed, as it is.
646
+ console.error('netjes: the form could not be submitted', error);
647
+ failure = { status: null, action: withoutBasePath(url.pathname, this.deps.basePath) + url.search };
648
+ return;
649
+ }
650
+ console.error('netjes: navigation failed, loading the page from the server instead', error);
651
+ this.hard(url);
652
+ } finally {
653
+ if (this.active === navigation) {
654
+ this.active = null;
655
+ if (!committed) this.deps.setPending(failure ? { state: 'idle', error: failure } : IDLE);
656
+ }
657
+ }
658
+ }
659
+
660
+ private async load(
661
+ url: URL,
662
+ request: PageRequest,
663
+ signal: AbortSignal,
664
+ reuseSegments: boolean,
665
+ onCookiesChanged = () => this.cookiesChanged(),
666
+ ): Promise<LoadResult> {
667
+ let target = url;
668
+ let current = request;
669
+ // After a form post everything is loaded again: the post may have changed what any layout shows.
670
+ let keys = reuseSegments && request.method === 'GET' ? this.heldSegmentKeys() : [];
671
+ // Output-cached pages carry no token; a post from one needs a fetched one (as a header).
672
+ const token = request.method === 'POST' && !this.current.payload.antiforgery ? await this.fetchedToken() : undefined;
673
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
674
+ let payload = await fetchPayload(target, current, signal, keys, current.method === 'POST' ? token : undefined, onCookiesChanged);
675
+ if (payload !== null && 'kind' in payload && payload.kind === 'page' && keys.length > 0) {
676
+ const resolved = this.withReusedSegments(payload);
677
+ // The server reused a segment this page no longer has (it changed meanwhile): load it in full.
678
+ payload = resolved ?? (await fetchPayload(target, current, signal));
679
+ }
680
+ if (payload === null) return { hard: target };
681
+ if ('html' in payload) return { document: payload.html, url: payload.url };
682
+ if ('file' in payload || 'failed' in payload) return payload;
683
+ if (payload.kind === 'redirect') {
684
+ target = new URL(payload.location, target);
685
+ if (!isWebUrl(target)) throw new Error(`netjes: refusing to follow a redirect to a ${target.protocol} URL`);
686
+ if (target.origin !== window.location.origin) return { hard: target };
687
+ if (current.method !== 'GET') keys = [];
688
+ current = { method: 'GET' };
689
+ continue;
690
+ }
691
+
692
+ const finalUrl = pageUrl(payload.url, target);
693
+ if (finalUrl.origin !== window.location.origin) throw new Error(`netjes: the page payload names another origin (${finalUrl.origin})`);
694
+ if (hop === 0) finalUrl.hash = url.hash;
695
+ if (payload.version !== this.deps.version) return { hard: finalUrl };
696
+ try {
697
+ return { payload, modules: await this.deps.loader.loadRoute(payload), url: finalUrl };
698
+ } catch (error) {
699
+ if (error instanceof MissingModuleError) return { hard: finalUrl };
700
+ throw error;
701
+ }
702
+ }
703
+ throw new Error('netjes: too many redirects');
704
+ }
705
+
706
+ /**
707
+ * Intercepting routes: when the new page can be shown in a slot of a segment the current page is inside (a photo
708
+ * opening in a modal over the feed), keep the current page and render the intercepting page in that slot.
709
+ */
710
+ private async intercepted(loaded: Loaded, base: Loaded = this.current.base ?? this.current): Promise<Loaded> {
711
+ const intercepts = loaded.payload.intercepts;
712
+ if (!intercepts?.length) return loaded;
713
+ const intercept = intercepts.find((i) => base.payload.segments.some((s) => s.id === i.segment));
714
+ if (!intercept) return loaded;
715
+ const Page = (await this.deps.loader.load(intercept.page)) as ComponentType<Record<string, unknown>>;
716
+ const { props } = loaded.payload;
717
+ // The intercepting page's own route values: its parameters can be named differently from the page's.
718
+ const params = intercept.params ?? loaded.payload.params;
719
+ function InterceptedSlot() {
720
+ return <Page {...props} params={params} />;
721
+ }
722
+ const segments = base.modules.segments.map((segment) =>
723
+ segment.id === intercept.segment ? { ...segment, slots: { ...segment.slots, [intercept.slot]: InterceptedSlot } } : segment,
724
+ );
725
+ return {
726
+ payload: { ...base.payload, url: loaded.payload.url },
727
+ modules: { ...base.modules, segments },
728
+ url: loaded.url,
729
+ pageKey: base.pageKey ?? base.url.pathname,
730
+ base,
731
+ };
732
+ }
733
+
734
+ /** Keys of the current page's layout segments whose props the client holds. */
735
+ private heldSegmentKeys(): string[] {
736
+ return this.current.payload.segments.flatMap((segment) => (segment.key && segment.props && !segment.reused ? [segment.key] : []));
737
+ }
738
+
739
+ /** Fills in the props of segments the server marked reused from the current page, or null if one is missing. */
740
+ private withReusedSegments(payload: PagePayload): PagePayload | null {
741
+ if (!payload.segments.some((segment) => segment.reused)) return payload;
742
+ const held = this.current.payload.segments;
743
+ const segments: SegmentRef[] = [];
744
+ for (const segment of payload.segments) {
745
+ if (!segment.reused) {
746
+ segments.push(segment);
747
+ continue;
748
+ }
749
+ const known = held.find((h) => h.id === segment.id && h.key === segment.key && h.props);
750
+ if (!known) return null;
751
+ segments.push({ ...segment, props: known.props, reused: undefined });
752
+ }
753
+ return { ...payload, segments };
754
+ }
755
+
756
+ private hard(url: URL) {
757
+ if (isWebUrl(url)) this.deps.hardNavigate(url.href);
758
+ else console.error(`netjes: refusing to navigate to ${url.protocol} URL`);
759
+ }
760
+
761
+ private cancelActive() {
762
+ if (!this.active) return;
763
+ this.active.controller.abort();
764
+ this.active = null;
765
+ this.deps.setPending(IDLE);
766
+ }
767
+
768
+ private takePrefetched(url: URL, signal: AbortSignal): Promise<LoadResult> | undefined {
769
+ const key = withHash(url, '').href;
770
+ const hit = this.prefetched.get(key);
771
+ if (!hit) return undefined;
772
+ this.prefetched.delete(key);
773
+ if (Date.now() - hit.at >= PREFETCH_TTL_MS) return undefined;
774
+ return hit.result.then(
775
+ (result) =>
776
+ 'payload' in result ? { ...result, url: samePage(result.url, url) ? withHash(result.url, url.hash) : result.url } : result,
777
+ () => this.load(url, { method: 'GET' }, signal, true),
778
+ );
779
+ }
780
+
781
+ private setCurrent(key: string, loaded: Loaded) {
782
+ this.currentKey = key;
783
+ this.current = loaded;
784
+ if (!this.currentIsStale) this.remember(key, loaded);
785
+ }
786
+
787
+ private remember(key: string, loaded: Loaded) {
788
+ this.entries.delete(key);
789
+ this.entries.set(key, loaded);
790
+ while (this.entries.size > MAX_REMEMBERED_ENTRIES) {
791
+ const oldest = this.entries.keys().next().value;
792
+ if (oldest === undefined) break;
793
+ this.entries.delete(oldest);
794
+ }
795
+ }
796
+
797
+ private rememberScroll() {
798
+ this.scrollPositions.set(this.currentKey, { x: window.scrollX, y: window.scrollY });
799
+ }
800
+ }
801
+
802
+ function elementForFragment(fragment: string): HTMLElement | null {
803
+ let decoded = fragment;
804
+ try {
805
+ decoded = decodeURIComponent(fragment);
806
+ } catch {
807
+ // Not valid percent-encoding (e.g. "#100%"): look the fragment up as written.
808
+ }
809
+ return document.getElementById(decoded) ?? document.getElementById(fragment);
810
+ }
811
+
812
+ function applyScroll(intent: ScrollIntent) {
813
+ switch (intent.kind) {
814
+ case 'none':
815
+ return;
816
+ case 'top':
817
+ window.scrollTo(0, 0);
818
+ return;
819
+ case 'restore':
820
+ window.scrollTo(intent.x, intent.y);
821
+ return;
822
+ case 'hash': {
823
+ const target = elementForFragment(intent.hash.slice(1));
824
+ if (target) target.scrollIntoView();
825
+ else window.scrollTo(0, 0);
826
+ return;
827
+ }
828
+ default: {
829
+ const exhaustive: never = intent;
830
+ return exhaustive;
831
+ }
832
+ }
833
+ }
834
+
835
+ type View = { loaded: Loaded; id: number; scroll: ScrollIntent };
836
+ type ViewValue = { payload: PagePayload; url: URL };
837
+
838
+ const RouterContext = createContext<InternalRouter | null>(null);
839
+ const ViewContext = createContext<ViewValue | null>(null);
840
+ const NavigationContext = createContext<NavigationState>(IDLE);
841
+
842
+ export type RouterProviderProps = {
843
+ initial: PagePayload;
844
+ initialModules: RouteModules;
845
+ loader: ModuleLoader;
846
+ errorPage: SpecialPageRef | null;
847
+ hardNavigate?: (href: string) => void;
848
+ showDocument?: (html: string, url: string) => void;
849
+ download?: (file: Blob, name: string) => void;
850
+ /** Wraps every page and the error page, inside the router: where app-wide providers such as the QueryClient go. */
851
+ shell?: ComponentType<{ children: ReactNode }> | undefined;
852
+ };
853
+
854
+ function PassThrough({ children }: { children: ReactNode }) {
855
+ return children;
856
+ }
857
+
858
+ const defaultHardNavigate = (href: string) => window.location.assign(href);
859
+
860
+ /** Shows an HTML response to a form post the way the browser would have after a native submission. */
861
+ function defaultShowDocument(html: string, url: string) {
862
+ window.history.pushState(null, '', url);
863
+ document.open();
864
+ document.write(html);
865
+ document.close();
866
+ // document.open() removed the router's listeners; let the browser load whatever back/forward lands on.
867
+ window.addEventListener('popstate', () => window.location.reload());
868
+ }
869
+
870
+ function defaultDownload(file: Blob, name: string) {
871
+ const href = URL.createObjectURL(file);
872
+ const link = document.createElement('a');
873
+ link.href = href;
874
+ link.download = name;
875
+ document.body.append(link);
876
+ link.click();
877
+ link.remove();
878
+ setTimeout(() => URL.revokeObjectURL(href), 10_000);
879
+ }
880
+
881
+ export function RouterProvider({
882
+ initial,
883
+ initialModules,
884
+ loader,
885
+ errorPage,
886
+ hardNavigate = defaultHardNavigate,
887
+ showDocument = defaultShowDocument,
888
+ download = defaultDownload,
889
+ shell: Shell = PassThrough,
890
+ }: RouterProviderProps) {
891
+ const [view, setView] = useState<View>(() => ({
892
+ loaded: {
893
+ payload: initial,
894
+ modules: initialModules,
895
+ url: initialUrl(initial.url),
896
+ },
897
+ id: 0,
898
+ scroll: NONE,
899
+ }));
900
+ const [navigation, setNavigation] = useState<NavigationState>(IDLE);
901
+ const [navigator] = useState(() => {
902
+ // Server actions called before the effect below registers this router wait for it (the server runs no effects).
903
+ if (typeof document !== 'undefined') expectActionClient();
904
+ return new Navigator(
905
+ {
906
+ loader,
907
+ version: initial.version,
908
+ basePath: initial.basePath ?? '',
909
+ hardNavigate,
910
+ showDocument,
911
+ download,
912
+ setPending: setNavigation,
913
+ commit: (loaded, scroll) =>
914
+ startTransition(() => {
915
+ setView((previous) => ({ loaded, id: previous.id + 1, scroll }));
916
+ setNavigation(IDLE);
917
+ }),
918
+ },
919
+ view.loaded,
920
+ );
921
+ });
922
+
923
+ // In the commit that shows the page, before the browser dispatches the clicks that came in while React hydrated it:
924
+ // a fragment link followed then must find this history entry keyed, to come back to.
925
+ useLayoutEffect(() => navigator.start(), [navigator]);
926
+ const [actionListeners] = useState(() => new Set<ActionListener>());
927
+ useEffect(
928
+ () =>
929
+ setActionClient({
930
+ tokenField: () => navigator.page.payload.antiforgery?.formFieldName ?? '__RequestVerificationToken',
931
+ async invoke(name, url, input) {
932
+ const page = navigator.page;
933
+ const returnUrl = page.url.pathname + page.url.search;
934
+ const token = page.payload.antiforgery ?? (await navigator.fetchedToken());
935
+ const actionUrl = withBasePath(url, page.payload.basePath ?? '');
936
+ let cookiesChanged = false;
937
+ const onCookiesChanged = () => {
938
+ cookiesChanged = true;
939
+ navigator.cookiesChanged();
940
+ };
941
+ let call = await fetchAction(name, actionUrl, actionRequest(input, token, returnUrl), onCookiesChanged);
942
+ navigator.forgetToken();
943
+ // Tokens belong to the signed-in user: after signing in or out (on any tab) the page's one is stale. Only a
944
+ // call the server marks as refused for its token did not run, so only that one is sent again, once, with a
945
+ // fresh token. Any other 400 may come from an action that changed data first.
946
+ if (call.tokenRefused) {
947
+ const fresh = await navigator.fetchedToken();
948
+ if (fresh?.requestToken && fresh.requestToken !== token?.requestToken) {
949
+ call = await fetchAction(name, actionUrl, actionRequest(input, fresh, returnUrl), onCookiesChanged);
950
+ }
951
+ navigator.forgetToken();
952
+ }
953
+ const { response } = call;
954
+ // Changed cookies dropped the prefetched pages already, and started those in flight over.
955
+ if (!cookiesChanged) navigator.forgetPrefetched();
956
+ for (const listener of actionListeners) listener(response);
957
+ // The user may have moved on while the action ran: then neither pull them back (redirect) nor reload the
958
+ // page they left (refresh, which would also cancel their navigation).
959
+ const movedOn = navigator.page !== page || navigator.navigating;
960
+ if (!movedOn && response.redirect) await navigator.navigate(response.redirect, 'push', true);
961
+ else if (!movedOn && response.revalidate) await navigator.refresh();
962
+ // After signing in or out (an action that set or deleted a cookie), the page on screen shows whoever was
963
+ // signed in before until a page comes from the server: refresh it, like Next.js. That is the action's page
964
+ // when it neither redirected nor revalidated, or redirected to a fragment of itself (which loads nothing),
965
+ // or one the user moved on to that loaded with the old cookies while the action ran. A navigation still on
966
+ // its way has started over already. A redirect that left the page as it was went to another site (to sign
967
+ // in there, say), which the browser loads instead, or ended in a download: neither refreshes it.
968
+ const handedOver = !movedOn && Boolean(response.redirect) && navigator.page === page;
969
+ if (navigator.fromBeforeCookieChange && !navigator.navigating && !handedOver) {
970
+ await navigator.refresh({ keepPrefetched: cookiesChanged });
971
+ }
972
+ return response;
973
+ },
974
+ }),
975
+ [navigator, actionListeners],
976
+ );
977
+ useLayoutEffect(() => {
978
+ if (view.id > 0) applyScroll(view.scroll);
979
+ }, [view]);
980
+
981
+ const router = useMemo<InternalRouter>(
982
+ () => ({
983
+ push: (href, options) => navigator.navigate(href, 'push', options?.scroll ?? true),
984
+ replace: (href, options) => navigator.navigate(href, 'replace', options?.scroll ?? true),
985
+ prefetch: (href) => navigator.prefetch(href),
986
+ refresh: () => navigator.refresh(),
987
+ back: () => window.history.back(),
988
+ forward: () => window.history.forward(),
989
+ submit: (action, body) => navigator.submit(action, body),
990
+ onActionResponse: (listener) => {
991
+ actionListeners.add(listener);
992
+ return () => actionListeners.delete(listener);
993
+ },
994
+ onCookiesChanged: (listener) => navigator.onCookiesChanged(listener),
995
+ }),
996
+ [navigator, actionListeners],
997
+ );
998
+ const { payload, modules, url } = view.loaded;
999
+ const viewValue = useMemo<ViewValue>(() => ({ payload, url }), [payload, url]);
1000
+
1001
+ return (
1002
+ <RouterContext value={router}>
1003
+ <ViewContext value={viewValue}>
1004
+ <NavigationContext value={navigation}>
1005
+ <Shell>
1006
+ <ErrorBoundary
1007
+ resetKey={view.id}
1008
+ fallback={(error) => <ErrorFallback error={error} errorPage={errorPage} loader={loader} payload={payload} />}
1009
+ >
1010
+ <RouteTree
1011
+ modules={modules}
1012
+ segments={payload.segments}
1013
+ props={payload.props}
1014
+ params={payload.params}
1015
+ status={payload.status}
1016
+ pageKind={payload.pageKind}
1017
+ pageKey={view.loaded.pageKey ?? url.pathname}
1018
+ retry={() => void router.refresh()}
1019
+ metadata={payload.metadata}
1020
+ />
1021
+ </ErrorBoundary>
1022
+ </Shell>
1023
+ </NavigationContext>
1024
+ </ViewContext>
1025
+ </RouterContext>
1026
+ );
1027
+ }
1028
+
1029
+ const errorPageModules = new WeakMap<ModuleLoader, Promise<RouteModules>>();
1030
+
1031
+ function ErrorFallback({ error, errorPage, loader, payload }: { error: unknown; errorPage: SpecialPageRef | null; loader: ModuleLoader; payload: PagePayload }) {
1032
+ const props = { status: 500, message: error instanceof Error ? error.message : 'Something went wrong' };
1033
+ const builtIn = <RouteTree modules={{ page: null, segments: [] }} props={props} params={payload.params} status={500} pageKey="error" />;
1034
+ if (!errorPage) return builtIn;
1035
+ let modules = errorPageModules.get(loader);
1036
+ if (!modules) {
1037
+ modules = loader.loadRoute(errorPage);
1038
+ errorPageModules.set(loader, modules);
1039
+ }
1040
+ return (
1041
+ // If the error page (or a layout around it) throws as well, fall back to the built-in page.
1042
+ <ErrorBoundary resetKey={0} fallback={() => builtIn}>
1043
+ <Suspense fallback={null}>
1044
+ <LoadedErrorPage modules={modules} props={props} payload={payload} />
1045
+ </Suspense>
1046
+ </ErrorBoundary>
1047
+ );
1048
+ }
1049
+
1050
+ function LoadedErrorPage({ modules, props, payload }: { modules: Promise<RouteModules>; props: Record<string, unknown>; payload: PagePayload }) {
1051
+ return <RouteTree modules={use(modules)} props={props} params={payload.params} status={500} pageKind="error" pageKey="error" />;
1052
+ }
1053
+
1054
+ /** The router's contexts without the router: for the test harness (netjes/testing), which fakes navigation. */
1055
+ export function RouterContexts({
1056
+ router,
1057
+ payload,
1058
+ url,
1059
+ navigation,
1060
+ children,
1061
+ }: {
1062
+ router: InternalRouter;
1063
+ payload: PagePayload;
1064
+ url: URL;
1065
+ navigation: NavigationState;
1066
+ children: ReactNode;
1067
+ }) {
1068
+ const view = useMemo<ViewValue>(() => ({ payload, url }), [payload, url]);
1069
+ return (
1070
+ <RouterContext value={router}>
1071
+ <ViewContext value={view}>
1072
+ <NavigationContext value={navigation}>{children}</NavigationContext>
1073
+ </ViewContext>
1074
+ </RouterContext>
1075
+ );
1076
+ }
1077
+
1078
+ function useView(): ViewValue {
1079
+ const view = useContext(ViewContext);
1080
+ if (!view) throw new Error('netjes: this hook must be used inside a Netjes page');
1081
+ return view;
1082
+ }
1083
+
1084
+ export function useInternalRouter(): InternalRouter {
1085
+ const router = useContext(RouterContext);
1086
+ if (!router) throw new Error('netjes: this hook must be used inside a Netjes page');
1087
+ return router;
1088
+ }
1089
+
1090
+ /** The client router: `push`, `replace`, `prefetch`, `refresh`, `back` and `forward`. Throws outside a Netjes page. */
1091
+ export function useRouter(): Router {
1092
+ return useInternalRouter();
1093
+ }
1094
+
1095
+ /** The path without the base path, as the app's routes see it (like Next.js's `usePathname` with `basePath`). */
1096
+ export function usePathname(): string {
1097
+ const { url, payload } = useView();
1098
+ return withoutBasePath(url.pathname, payload.basePath ?? '');
1099
+ }
1100
+
1101
+ /** An href for a plain `<a>` or attribute, under the base path: what `<Link>` does for its own. */
1102
+ export function useHref(href: string): string {
1103
+ return withBasePath(href, useBasePath());
1104
+ }
1105
+
1106
+ /** `UsePathBase`'s base path for this page, or '' when the app is mounted at the root. */
1107
+ export function useBasePath(): string {
1108
+ return useView().payload.basePath ?? '';
1109
+ }
1110
+
1111
+ /** Search values as Next.js passes them: a string, or an array of strings when the key repeats. */
1112
+ function searchObject(search: URLSearchParams): Record<string, string | string[]> {
1113
+ const result: Record<string, string | string[]> = {};
1114
+ for (const key of new Set(search.keys())) {
1115
+ const values = search.getAll(key);
1116
+ result[key] = values.length === 1 ? values[0]! : values;
1117
+ }
1118
+ return result;
1119
+ }
1120
+
1121
+ /**
1122
+ * The query string; with a schema, parsed from `{ key: string | string[] }` (repeated keys become arrays).
1123
+ * Visitors control the query string, so values the schema rejects are dropped (defaults then apply) rather than
1124
+ * failing the page. Define the schema at module level: a new schema object each render parses again.
1125
+ */
1126
+ export function useSearchParams(): URLSearchParams;
1127
+ export function useSearchParams<Schema extends StandardSchemaV1>(schema: Schema): StandardSchemaV1.InferOutput<Schema>;
1128
+ export function useSearchParams(schema?: StandardSchemaV1): unknown {
1129
+ const { search } = useView().url;
1130
+ return useMemo(() => {
1131
+ const params = new URLSearchParams(search);
1132
+ return schema ? parseLenient(schema, searchObject(params), 'search params') : params;
1133
+ }, [search, schema]);
1134
+ }
1135
+
1136
+ /**
1137
+ * The route parameters (catch-all parameters are arrays); with a schema, parsed by it. A rejected value throws to
1138
+ * the error boundary, so prefer C# route constraints (or a loader returning NotFound) for URLs visitors can mistype.
1139
+ */
1140
+ export function useParams(): Params;
1141
+ export function useParams<Schema extends StandardSchemaV1>(schema: Schema): StandardSchemaV1.InferOutput<Schema>;
1142
+ export function useParams(schema?: StandardSchemaV1): unknown {
1143
+ const { params } = useView().payload;
1144
+ return useMemo(() => (schema ? parseWith(schema, params, 'route params') : params), [params, schema]);
1145
+ }
1146
+
1147
+ /**
1148
+ * The props configured with `NetjesOptions.SharedProps` on the server, available to every page and layout.
1149
+ * Pass a schema to get them parsed and typed; without one they are `unknown`.
1150
+ */
1151
+ export function useSharedProps(): unknown;
1152
+ export function useSharedProps<Schema extends StandardSchemaV1>(schema: Schema): StandardSchemaV1.InferOutput<Schema>;
1153
+ export function useSharedProps(schema?: StandardSchemaV1): unknown {
1154
+ const { shared } = useView().payload;
1155
+ return useMemo(() => (schema ? parseWith(schema, shared, 'shared props') : shared), [shared, schema]);
1156
+ }
1157
+
1158
+ function decodeSegment(segment: string): string {
1159
+ try {
1160
+ return decodeURIComponent(segment);
1161
+ } catch {
1162
+ return segment;
1163
+ }
1164
+ }
1165
+
1166
+ /**
1167
+ * In a layout: the URL segments below it, decoded (route groups do not appear in URLs). Next.js
1168
+ * `useSelectedLayoutSegments`. Outside layouts, the whole path.
1169
+ */
1170
+ export function useSelectedLayoutSegments(): string[] {
1171
+ const depth = useContext(LayoutDepthContext);
1172
+ const pathname = usePathname();
1173
+ return useMemo(() => pathname.split('/').filter(Boolean).slice(depth).map(decodeSegment), [pathname, depth]);
1174
+ }
1175
+
1176
+ /** In a layout: the active segment one level below it (e.g. the selected tab), or null at the layout's own page. */
1177
+ export function useSelectedLayoutSegment(): string | null {
1178
+ return useSelectedLayoutSegments()[0] ?? null;
1179
+ }
1180
+
1181
+ export function usePagePayload(): PagePayload {
1182
+ return useView().payload;
1183
+ }
1184
+
1185
+ /** The router's state: `loading` during a navigation, `submitting` during a form post, else `idle` (with `error` after a post that produced no page). */
1186
+ export function useNavigation(): NavigationState {
1187
+ return useContext(NavigationContext);
1188
+ }