netjes 0.0.2 → 0.0.4

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 (160) hide show
  1. package/README.md +13 -11
  2. package/cli/agents.mjs +25 -2
  3. package/cli/commands.mjs +56 -6
  4. package/cli/help.mjs +7 -2
  5. package/cli/info.mjs +275 -33
  6. package/cli/routes.mjs +35 -8
  7. package/cli/scaffold.mjs +31 -4
  8. package/dist/actions.d.ts +7 -2
  9. package/dist/actions.d.ts.map +1 -1
  10. package/dist/actions.js +9 -3
  11. package/dist/actions.js.map +1 -1
  12. package/dist/client-entry.d.ts +1 -0
  13. package/dist/client-entry.d.ts.map +1 -1
  14. package/dist/client-entry.js +19 -2
  15. package/dist/client-entry.js.map +1 -1
  16. package/dist/deferred.d.ts +11 -0
  17. package/dist/deferred.d.ts.map +1 -1
  18. package/dist/deferred.js +15 -1
  19. package/dist/deferred.js.map +1 -1
  20. package/dist/document.d.ts.map +1 -1
  21. package/dist/document.js +10 -1
  22. package/dist/document.js.map +1 -1
  23. package/dist/errors.d.ts +7 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +11 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/form.d.ts +5 -5
  28. package/dist/form.d.ts.map +1 -1
  29. package/dist/form.js +5 -2
  30. package/dist/form.js.map +1 -1
  31. package/dist/href.d.ts +31 -3
  32. package/dist/href.d.ts.map +1 -1
  33. package/dist/href.js +131 -10
  34. package/dist/href.js.map +1 -1
  35. package/dist/image.d.ts +3 -3
  36. package/dist/image.d.ts.map +1 -1
  37. package/dist/image.js.map +1 -1
  38. package/dist/index.d.ts +3 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +3 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/link.d.ts +15 -7
  43. package/dist/link.d.ts.map +1 -1
  44. package/dist/link.js +38 -7
  45. package/dist/link.js.map +1 -1
  46. package/dist/metadata.d.ts +8 -2
  47. package/dist/metadata.d.ts.map +1 -1
  48. package/dist/metadata.js +14 -5
  49. package/dist/metadata.js.map +1 -1
  50. package/dist/modules.d.ts +15 -1
  51. package/dist/modules.d.ts.map +1 -1
  52. package/dist/modules.js.map +1 -1
  53. package/dist/mounted.d.ts +55 -0
  54. package/dist/mounted.d.ts.map +1 -0
  55. package/dist/mounted.js +110 -0
  56. package/dist/mounted.js.map +1 -0
  57. package/dist/navigation-focus.d.ts +38 -0
  58. package/dist/navigation-focus.d.ts.map +1 -0
  59. package/dist/navigation-focus.js +71 -0
  60. package/dist/navigation-focus.js.map +1 -0
  61. package/dist/payload.d.ts +8 -0
  62. package/dist/payload.d.ts.map +1 -1
  63. package/dist/payload.js +28 -0
  64. package/dist/payload.js.map +1 -1
  65. package/dist/query.d.ts +5 -2
  66. package/dist/query.d.ts.map +1 -1
  67. package/dist/query.js +11 -8
  68. package/dist/query.js.map +1 -1
  69. package/dist/render-document.d.ts +74 -0
  70. package/dist/render-document.d.ts.map +1 -0
  71. package/dist/render-document.js +393 -0
  72. package/dist/render-document.js.map +1 -0
  73. package/dist/route-announcer.d.ts +18 -0
  74. package/dist/route-announcer.d.ts.map +1 -0
  75. package/dist/route-announcer.js +58 -0
  76. package/dist/route-announcer.js.map +1 -0
  77. package/dist/route-tree.d.ts +6 -2
  78. package/dist/route-tree.d.ts.map +1 -1
  79. package/dist/route-tree.js +17 -7
  80. package/dist/route-tree.js.map +1 -1
  81. package/dist/router.d.ts +28 -5
  82. package/dist/router.d.ts.map +1 -1
  83. package/dist/router.js +71 -26
  84. package/dist/router.js.map +1 -1
  85. package/dist/script.d.ts +28 -5
  86. package/dist/script.d.ts.map +1 -1
  87. package/dist/script.js +192 -21
  88. package/dist/script.js.map +1 -1
  89. package/dist/server-render.d.ts.map +1 -1
  90. package/dist/server-render.js +4 -1
  91. package/dist/server-render.js.map +1 -1
  92. package/dist/testing-vitest.d.ts +8 -0
  93. package/dist/testing-vitest.d.ts.map +1 -0
  94. package/dist/testing-vitest.js +26 -0
  95. package/dist/testing-vitest.js.map +1 -0
  96. package/dist/testing.d.ts +17 -1
  97. package/dist/testing.d.ts.map +1 -1
  98. package/dist/testing.js +18 -3
  99. package/dist/testing.js.map +1 -1
  100. package/dist/zod-jitless.d.ts +2 -0
  101. package/dist/zod-jitless.d.ts.map +1 -0
  102. package/dist/zod-jitless.js +4 -0
  103. package/dist/zod-jitless.js.map +1 -0
  104. package/package.json +1 -1
  105. package/sidecar/server.mjs +109 -15
  106. package/skills/netjes/SKILL.md +15 -7
  107. package/skills/netjes/references/caching-and-streaming.md +37 -4
  108. package/skills/netjes/references/data-and-actions.md +15 -7
  109. package/skills/netjes/references/deployment.md +7 -3
  110. package/skills/netjes/references/routing.md +32 -9
  111. package/skills/netjes/references/testing.md +23 -7
  112. package/src/actions.tsx +9 -3
  113. package/src/client-entry.tsx +20 -2
  114. package/src/deferred.ts +14 -1
  115. package/src/document.tsx +22 -3
  116. package/src/errors.ts +12 -0
  117. package/src/form.tsx +10 -7
  118. package/src/href.ts +137 -11
  119. package/src/image.tsx +3 -3
  120. package/src/index.ts +19 -1
  121. package/src/link.tsx +47 -13
  122. package/src/metadata.tsx +19 -6
  123. package/src/modules.ts +15 -2
  124. package/src/mounted.tsx +160 -0
  125. package/src/navigation-focus.ts +75 -0
  126. package/src/payload.ts +32 -0
  127. package/src/query.tsx +14 -8
  128. package/src/render-document.tsx +483 -0
  129. package/src/route-announcer.tsx +69 -0
  130. package/src/route-tree.tsx +17 -6
  131. package/src/router.tsx +116 -31
  132. package/src/script.tsx +214 -23
  133. package/src/server-render.tsx +4 -1
  134. package/src/testing-vitest.ts +37 -0
  135. package/src/testing.tsx +38 -5
  136. package/src/virtual.d.ts +8 -1
  137. package/src/zod-jitless.ts +15 -0
  138. package/template/.dockerignore +3 -0
  139. package/template/Dockerfile +11 -1
  140. package/template/README.md +11 -7
  141. package/template/_AGENTS.md +38 -9
  142. package/template/__NAME__.csproj +2 -0
  143. package/template/app/document.test.ts +29 -0
  144. package/template/app/error.tsx +3 -2
  145. package/template/app/layout.tsx +10 -1
  146. package/template/docker-compose.override.yml +4 -3
  147. package/template/docker-compose.prod.yml +2 -1
  148. package/template/e2e/home.spec.ts +32 -0
  149. package/template/generated/netjes.ts +43 -14
  150. package/template/package.json +4 -1
  151. package/template/playwright.config.ts +36 -0
  152. package/template/tests/__NAME__.Tests/AppFactory.cs +31 -0
  153. package/template/tests/__NAME__.Tests/HomePageTests.cs +55 -0
  154. package/template/tests/__NAME__.Tests/__NAME__.Tests.csproj +30 -0
  155. package/template/tsconfig.app.json +20 -0
  156. package/template/tsconfig.json +2 -17
  157. package/template/tsconfig.node.json +8 -0
  158. package/vite/packages.mjs +345 -0
  159. package/vite/plugin.d.ts +16 -1
  160. package/vite/plugin.mjs +224 -34
@@ -19,6 +19,7 @@ app.MapPageLoader("/projects/[id]", async (string id, ClaimsPrincipal user, AppD
19
19
  - It binds like a minimal API: route values by name, services, `ClaimsPrincipal`, `HttpContext`, `CancellationToken`, `[FromQuery]`.
20
20
  - It reads no request body, so a complex type that is not a registered service needs `[AsParameters]` or a static `BindAsync`. A loader or action whose parameters minimal APIs cannot bind stops the app at startup, saying which and why. So does a loader parameter that reads the body (`[FromBody]`, `[FromForm]`, `IFormFile`): posted data belongs in a page action or a server action. A page loader stops the app too for a type that adds accepts metadata (`IAcceptsMetadata`) for itself, and must not get `.Accepts<T>()`: routing would match the page only for requests with that Content-Type, and navigations would get the not-found page. Repeated query values bind to an array (`int[] ids`); `List<int>` and `IEnumerable<int>` stop the app at startup, the latter because minimal APIs would bind it from services.
21
21
  - `Page.Props(record)` names the props type for `pages['/projects/[id]']`. `Page.NotFound()`, `Page.Forbidden()`, `Page.Unauthorized()` render the nearest boundary file; `Page.Redirect(url)` and `Page.LocalRedirect(returnUrl)` (for URLs from the request) redirect, and the client router follows them. `.WithStatus(422)` changes the status.
22
+ - A link to one of the app's pages is a `Href` in the props record (`public sealed record NavLink(Href Href, string Label)`, `new Href($"/projects/{Uri.EscapeDataString(id)}")`, with each value encoded), root-relative, in the page file's spelling (the props parse compares literal segments with their case, so `/About` fails for `app/about/page.tsx`) and without the base path. It is generated as the `Href` schema, so the props parse checks it against the pages and the component passes it to `<Link href>` as it is. `new Href(url)` throws `ArgumentException` unless the URL starts with `/` (and not `//` or `/\`), `?`, `#`, `http:`, `https:`, `mailto:` or `tel:`, and also for a control character anywhere in it or a space at either end; `Href.TryCreate` returns false instead. C# checks only that shape: a root-relative `Href` that no page serves fails the whole props parse and the page shows its error boundary (a 500 from the server only when no Suspense boundary, such as a `loading.tsx`, is above the component that parses; otherwise the server sends that boundary's fallback with the loader's status), so a link to an endpoint or a file, such as `/llms.txt`, stays a `string`, rendered with `useHref`, and so does a link from a CMS, checked with `isHref` where it is rendered. No query or route value binds to an `Href`: a `returnUrl` stays a `string` for `Page.LocalRedirect`, and a `[FromForm]` input takes a `string`, since form binding reads no `Href` from the field of its name. `LinkGenerator`'s `HttpContext` overloads add the base path, so pass `pathBase: PathString.Empty`.
22
23
  - It returns a `RouteHandlerBuilder`: `.RequireAuthorization()`, `.CachePage(...)` and filters apply.
23
24
  - A page's loader runs before its layouts' loaders, so a page under a layout that checks access checks it too.
24
25
 
@@ -67,14 +68,18 @@ const nameError = state?.ok === false ? state.errors.name?.[0] : undefined;
67
68
 
68
69
  - Results: `ServerAction.Ok(result)` or `Ok()`, `Invalid(field, message)` or `Invalid(errors)` (422), `Redirect(url)`, `Forbidden()`, `NotFound()`. Chain `.Revalidate(tags)` and `.RevalidatePath(paths)` (evict output-cached pages and refresh the page), `.Refresh()`, `.Invalidate(queryKey)` and `.WithQuery(queryKey, data)` (TanStack Query on the client).
69
70
  - `[FromForm]` inputs make the form work without JavaScript: the browser posts, a success redirects back, a failure renders the page again with the errors and the typed values. Keep that working.
70
- - An input without `[FromForm]` binds JSON: `await serverActions['posts.like']({ slug })` resolves to `{ ok: true, result }` or `{ ok: false, errors }`; server failures throw to the nearest error boundary.
71
+ - An input without `[FromForm]` binds JSON: `await serverActions['posts.like']({ slug })` resolves to `{ ok: true, result }` or `{ ok: false, errors }`; server failures throw a `ServerActionError` (`status`, and `withheld` when the server sent no message, as for a 5xx with error details off) to the nearest error boundary.
71
72
  - Validation: DataAnnotations (`[Required]`, `[StringLength]`, `[Range]`, `[EmailAddress]`) and `IValidatableObject` on an action's input are checked before the action runs (for a polymorphic JSON input, those of the `[JsonDerivedType]` the call sends). Failures come back as field errors (422, `state.errors`), also without JavaScript, and the action is not called. This needs no setup: `builder.Services.AddValidation()` is for your other minimal API endpoints, not for `MapAction` or `MapPageAction`. An attribute that cannot be read, such as a `[Display]` whose resource type has no such name, stops the app at startup, also on a derived type a JSON input lists with `[JsonDerivedType]`. A `[FromForm]` input is never `null` (unless declared nullable): a post with none of its fields, such as a form of unchecked checkboxes, gets an empty input, checked like any other. Give form inputs `init` properties, as above, not constructor parameters: a field the post leaves out (an unchecked checkbox, a list with nothing chosen) keeps its default, while form binding refuses such a post (400) for a positional record. A form input, and each type its properties and lists hold, needs exactly one public constructor: form binding never builds a type with several or none, or an abstract class or an interface, so that stops the app at startup. Bind a checkbox group or a list as a property of the input type (`public string[] Tags { get; init; } = [];`), not as a top-level `[FromForm]` parameter: a top-level list or dictionary reads fields named `[0]` or `[key]`, not its own name, and is `null` when none is posted.
72
73
  - Optimistic UI: `useAction(action, { onSubmit(formData) { setOptimistic(...) } })` with React's `useOptimistic`.
73
74
  - Every action needs `app.UseAntiforgery()` (after authentication) and checks the origin; `<Form>` and `serverActions` send the token. `MapAction` bodies are limited to 1 MB (`ActionBodySizeLimit`); an upload action sets `[RequestSizeLimit(bytes)]`. Page actions keep the server's limit.
74
- - Name actions `feature.verb`. Check authorization inside the action, or with `.RequireAuthorization()`, as for any endpoint.
75
+ - Name actions `feature.verb`. Names are compared ignoring case, and a second `MapAction` of a name, the app's or a page package's, stops the app at startup. Every action answers at `/_netjes/actions/{name}`, so `MapAction` in a `MapGroup` with a prefix throws; map it on the app, or on `MapGroup("")` for shared conventions. Check authorization inside the action, or with `.RequireAuthorization()`, as for any endpoint.
75
76
  - `app.MapPageAction("/guestbook", ...)` is the other kind: a POST to the page's own URL from `<Form method="post">`, fields typed in `forms['/guestbook']`. Its input's DataAnnotations and `IValidatableObject` are checked as for `MapAction`: on a failure the delegate does not run, the page renders again from its loader (422), and `const failed = usePageActionState()` gives `failed.errors` and `failed.values` (null otherwise). Checks that need data stay in the delegate: return `Page.Props(...)` with your own errors and the typed values (`.WithStatus(422)`), or `Page.Redirect(...)` after a change.
76
77
  - An action or post that sets or deletes a cookie (signing in or out) makes the client router forget the pages it keeps for back and forward, reload the page on screen and clear the query cache, in every tab of the app in that browser. Nothing to do in the action; a proxy must pass `X-Netjes-Cookies` through.
77
78
 
79
+ ## Page packages
80
+
81
+ A mounted page package's loaders, layout loaders, actions and endpoints are mapped by the package's extension, through its mount, and typed in the package's own generated file, not in the app's. The app's `generated/netjes.ts` routes the package's pages (`route('/help/contact')`) and holds only what the app maps itself; run `npx netjes generate` after updating a package. The app's `MapPageLoader`, `MapPageAction` and `MapLayoutLoader` refuse a package's page or layout, naming the mount. A page the app replaces with `.Override` renders with the package's loader, so it reads its props from the package's file: `definePage(pages['/contact'], ...)` with `pages` from `'@acme/help-center/generated'`. The app's own pages under the prefix, and their loaders, are the app's as before. Schemas both sides read by key, such as an export a package adds with `AddTypeScriptExport`, are in both files.
82
+
78
83
  ## JSON endpoints and TanStack Query
79
84
 
80
85
  ```csharp
@@ -83,16 +88,19 @@ app.MapGet("/api/projects/{slug}/stats", async Task<Results<Ok<ProjectStats>, No
83
88
  ```
84
89
 
85
90
  ```ts
86
- async function fetchStats(slug: string) {
87
- const response = await fetch(`/api/projects/${encodeURIComponent(slug)}/stats`);
91
+ async function fetchStats(url: string) {
92
+ const response = await fetch(url);
88
93
  if (!response.ok) throw new Error(`Stats request failed with ${response.status}`);
89
94
  return api['GET /api/projects/{slug}/stats'].response.parse(await response.json());
90
95
  }
91
- const stats = useQuery({ queryKey: ['projects', slug, 'stats'], queryFn: () => fetchStats(slug) });
96
+ const url = useHref(endpointUrl('GET /api/projects/{slug}/stats', { slug }));
97
+ const stats = useQuery({ queryKey: ['projects', slug, 'stats'], queryFn: () => fetchStats(url) });
92
98
  ```
93
99
 
94
- Typed results (`TypedResults`, `Results<...>`) give the endpoint its `api` schemas, keyed by method and route template. Seed the same query key from the loader with `.WithQuery(key, data)` and the query renders on the server with no fetch; keep the key in one place on each side. Seeded data is fresh for the default `staleTime` (one minute).
100
+ Typed results (`TypedResults`, `Results<...>`) give the endpoint its `api` schemas, keyed by method and route template. `endpointUrl(key, params, search)` builds the URL of the app's endpoints that are not pages, JSON or not (a JSON endpoint has a key per method except `HEAD`, any other endpoint one for `GET`, if it answers `GET`), by the same key: the route values and the query values its handler binds are typed from C#, an enum as its C# names and values, and `search` adds others. It has no base path: `useHref` adds it, for a `fetch` or an `<a href>`. Its result is a `string`, which the type of `<Link href>` does not accept, as an endpoint is not a page: link it with `<a href={useHref(endpointUrl(...))}>`. Seed the same query key from the loader with `.WithQuery(key, data)` and the query renders on the server with no fetch; keep the key in one place on each side. Seeded data is fresh for the default `staleTime` (one minute).
101
+
102
+ `MapGet` answers `GET` only: a `HEAD` to its URL goes to whatever else matches it, often the not-found page. For an endpoint that crawlers, link checkers or monitors ask with `HEAD`, map `app.MapMethods(pattern, [HttpMethods.Get, HttpMethods.Head], handler)`; its `api` key stays `GET` and the route. Pages, `/sitemap.xml`, `/robots.txt`, `/_netjes/image` and the locale redirect answer `HEAD` already.
95
103
 
96
104
  ## More
97
105
 
98
- [Data loading](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/data-loading.md) and [Actions and forms](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/actions-and-forms.md) in the guides.
106
+ [Data loading](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/data-loading.md), [Actions and forms](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/actions-and-forms.md) and [Page packages](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/page-packages.md) in the guides.
@@ -7,13 +7,14 @@ A Netjes app is an ASP.NET Core app that also needs Node at runtime: the .NET pr
7
7
  ```bash
8
8
  npx netjes generate --check # in CI: the generated types match the C# types
9
9
  npm test && npm run typecheck && dotnet build
10
+ dotnet test tests/MyApp.Tests -c Release # the C# tests, where the app has them; a plain dotnet test runs none
10
11
  dotnet publish -c Release -o bin/publish # runs `npm run build`, ships dist/ (client, server bundle, sidecar)
11
12
  cd bin/publish && ASPNETCORE_ENVIRONMENT=Production dotnet MyApp.dll
12
13
  ```
13
14
 
14
15
  `npx netjes start` runs the app in Production from the source tree (or `--dll bin/publish/MyApp.dll`, which runs from the publish folder). In production the routes come from the built client manifest, so a page file that was not built does not exist.
15
16
 
16
- For these startup failures the app logs one critical entry, saying what is wrong and what to do, and exits with code 1: no build in Production (`npm run build`), a `node` it cannot start (`Netjes:NodeExecutable`), a sidecar script that is not installed or built (`npm install` in development, `npm run build` in production), or in development the Vite dev server's port taken, an error in `vite.config.ts` (with Vite's code frame) or a package it imports that is not installed (`npm install`). Under `WebApplicationFactory` the start throws that message instead. Other exceptions at startup, such as a route conflict, keep their stack.
17
+ For these startup failures the app logs one critical entry, saying what is wrong and what to do, and exits with code 1: no build in Production (`npm run build`), a `node` it cannot start (`Netjes:NodeExecutable`), a sidecar script that is not installed or built (`npm install` in development, `npm run build` in production), or in development the Vite dev server's port taken, an error in `vite.config.ts` (with Vite's code frame) or a package it imports that is not installed (`npm install`). Under `WebApplicationFactory` the start throws that message instead. Other exceptions at startup, such as a route conflict, keep their stack. An exception that nothing catches, at startup or on a thread of the app's own, ends it as a .NET crash does: an abort, exit code 134 on Linux and macOS. ASP.NET Core catches a request's exceptions (a page's renders its `error.tsx`), and the host catches one from a `BackgroundService` and stops the app with exit code 0.
17
18
 
18
19
  ## Docker
19
20
 
@@ -21,6 +22,8 @@ For these startup failures the app logs one critical entry, saying what is wrong
21
22
 
22
23
  - `docker compose up`: development, with `dotnet watch` and Vite HMR, ports bound to localhost.
23
24
  - `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d`: the production image, a non-root user on a read-only filesystem, with a health check on `/healthz` and Data Protection keys in a named volume.
25
+ - Both publish the app on host port 8080; `APP_PORT` (in the shell or a `.env` file) moves it. Vite's 5173 stays.
26
+ - The production image runs `tini -s -- dotnet MyApp.dll`: tini is PID 1, passes `docker stop`'s SIGTERM on, and exits with the app's code, so a crash ends the container (134) and `restart: always` acts. When .NET itself is a container's PID 1, its abort does not end it. On arm64 it spins at 100% CPU and never exits; after a crash later than startup the rest of the app keeps serving and `/healthz` still passes. On x86-64 under emulation it has been seen to exit after a few seconds, with 139 instead of 134. An image of the app's own, or one from an older template, needs an init too: the template's `tini` stage, its `COPY` and the `ENTRYPOINT`, or the host's (`docker run --init`, Compose `init: true`, ECS `initProcessEnabled`).
24
27
 
25
28
  The image restores Netjes from NuGet and npm, so the app must reference the packages, as an app made with `npx netjes new` does (from a Netjes checkout, pass `--packages`).
26
29
 
@@ -31,9 +34,10 @@ The image restores Netjes from NuGet and npm, so the app must reference the pack
31
34
  - Data Protection keys sign antiforgery tokens and draft-mode cookies: keep them across restarts (the production compose file mounts a volume) and share them between instances.
32
35
  - Several instances behind a load balancer also share the output-cache store and need the same `Netjes:SegmentKeySecret` (32 bytes or more).
33
36
  - `Netjes:SidecarProcesses`: Node processes rendering in production (default 1; about one per core for CPU-heavy pages).
34
- - If no sidecar is ready (they crashed and are restarting), documents are sent for the browser to render instead of failing (`ClientRenderFallback`, on by default), and `/healthz` reports Unhealthy. Register the check with `builder.Services.AddHealthChecks().AddNetjes()` and `app.MapHealthChecks("/healthz")`.
37
+ - If no sidecar is ready (they crashed or stopped answering, and are restarting), documents are sent for the browser to render instead of failing (`ClientRenderFallback`, on by default), and `/healthz` reports Unhealthy. Register the check with `builder.Services.AddHealthChecks().AddNetjes()` and `app.MapHealthChecks("/healthz")`.
38
+ - The app restarts its Node processes itself: one that exits, and in production one that answers no health ping within `RenderTimeout` (at least 5 s). A restart policy on the container's exit (`restart: always`) is all the host needs. A custom `Netjes:SidecarScript` must answer `GET /health`, with any status, without reading a request body.
35
39
  - Output caching: set `SizeLimit`, and see [caching-and-streaming.md](caching-and-streaming.md).
36
- - Security headers: every document gets `X-Content-Type-Options`, `Referrer-Policy` and a same-origin framing policy unless the app sets them (`Netjes:SecurityHeaders`). A Content-Security-Policy is off until you set `NetjesOptions.ContentSecurityPolicy`, with `{nonce}` for a nonce-based policy.
40
+ - Security headers: every response through `UseNetjes()` gets `X-Content-Type-Options: nosniff`, so every response needs its real `Content-Type`: a script or stylesheet with a wrong one or none is refused, and a page that an endpoint writes with `Response.WriteAsync` shows as plain text unless the endpoint sets `Response.ContentType = "text/html; charset=utf-8"` first. Every document also gets `Referrer-Policy` and a same-origin framing policy, or the `X-Frame-Options` that the policy's `frame-ancestors` implies, unless the app sets them (`Netjes:SecurityHeaders`). A Content-Security-Policy is off until you set `NetjesOptions.ContentSecurityPolicy`: with `{nonce}` for a nonce-based policy, or without it, with `NetjesContentSecurityPolicy.ScriptHashes` in its `script-src`, for a hash-based one, which output-cached pages need ([caching-and-streaming.md](caching-and-streaming.md)).
37
41
  - `Netjes:PublicConfig` is sent to the browser as is; secrets go in other configuration.
38
42
  - Telemetry: add the `Netjes` ActivitySource and Meter (`NetjesTelemetry.Name`) to OpenTelemetry for loader, action and render spans and render durations.
39
43
 
@@ -7,11 +7,11 @@ ASP.NET Core does the routing: every page file becomes an endpoint with an ordin
7
7
  | File | Role |
8
8
  |---|---|
9
9
  | `page.tsx` | The page for its folder's URL. |
10
- | `layout.tsx` | Wraps every page below it and stays mounted across navigations (its state survives). The root `app/layout.tsx` imports the global stylesheet. |
10
+ | `layout.tsx` | Wraps every page below it and stays mounted across navigations (its state survives). The root `app/layout.tsx` imports the global stylesheet. Netjes renders `<html>`, `<head>` and `<body>` around the root layout, with `suppressHydrationWarning` on `<html>` and `<body>`, so no layout renders them: the root one returns what goes inside `<body>`. |
11
11
  | `template.tsx` | Like a layout, but remounted on every navigation. |
12
12
  | `loading.tsx` | Suspense fallback, streamed first while the page below waits on deferred values or lazy code. |
13
- | `error.tsx` | Error boundary below its layout; props `{ error, reset }` (`ErrorPageProps`), status in `error.status`. In development `error.message` can take several lines (a syntax error's code frame): render it with `whitespace-pre-wrap`. |
14
- | `not-found.tsx` | Rendered for `Page.NotFound()` from a loader below it, and the root one for unknown URLs. |
13
+ | `error.tsx` | Error boundary below its layout; props `{ error, reset }` (`ErrorPageProps`). `error.status` is the HTTP status of a server answer behind the error and is undefined for an error thrown in the browser, so show `error.status ?? 500`. Render `error.message` only when `!error.withheld`: `withheld` marks a stand-in for a message Netjes does not show (an exception's or a failed `Page.Defer` value's with error details off, a server action answer with no message, a thrown value that is neither an Error nor a string). `reset()` renders the segment again, or on an error page the server rendered loads the page again; on the page the router's own boundary shows it does nothing, so offer a link there too. In development `error.message` can take several lines (a syntax error's code frame): render it with `whitespace-pre-wrap`. |
14
+ | `not-found.tsx` | Rendered for `Page.NotFound()` from a loader below it, and the root one for unknown URLs, except below a page package's mount, where the package's renders. |
15
15
  | `forbidden.tsx`, `unauthorized.tsx` | Rendered for `Page.Forbidden()` / `Page.Unauthorized()` and for empty 403/401 responses from authorization; without one, the error page renders. |
16
16
  | `global-error.tsx` | In `app/` only: rendered without any layout when even the error page fails. |
17
17
 
@@ -31,14 +31,25 @@ Built-in pages stand in for missing boundary files.
31
31
 
32
32
  A literal segment beats a dynamic one (`blog/new` over `blog/[slug]`), as in Next.js. Two page files of the same shape (`[lang]/about` and `[slug]/about`) stop the app at startup. There is no pages router: Next.js pages router files in `pages/` stop the app at startup, and the message says where each goes (`pages/about.js` to `app/about/page.jsx`, `pages/404.tsx` to `app/not-found.tsx`, `pages/_app.tsx` and `pages/_document.tsx` to the root `app/layout.tsx`, `pages/api/` routes to minimal API endpoints). There are no route handlers either: a `route.ts` in `app/` stops the app in development and fails `vite build`, and an API is a minimal API endpoint in C# (`app.MapGet`, `app.MapPost`).
33
33
 
34
+ ## One URL per page
35
+
36
+ A page answers its file's spelling: a `GET` or `HEAD` for `/About` for `app/about/page.tsx` gets a 308 to `/about`, and a client navigation a redirect payload, so links and crawlers see one URL, and the output cache stores no other case that a browser sends (`RedirectLiteralCase`, on by default; `options.RedirectLiteralCase = false`, or `Netjes:RedirectLiteralCase=false`, answers every case). Other methods are answered as sent: a `POST` to `/About` is not redirected. Only literal segments change; route values (`/Blog/Hello` goes to `/blog/Hello`), the query and the base path stay as sent, so a loader that redirected to its own spelling of a literal can drop that check, while one that checks a slug's case keeps it. A trailing slash is answered as it is (`TrailingSlash.Ignore`); `options.TrailingSlash = TrailingSlash.Never` (`Netjes:TrailingSlash=Never`) sends `/about/` to `/about` and drops a catch-all's last slash. The `/robots.txt` and `/sitemap.xml` that `MapRobotsTxt` and `MapSitemap` serve follow both. `WebApplicationFactory` tests run on TestServer, which passes on no request target, so they see no redirect: check it end to end with Playwright, or set `IHttpRequestFeature.RawTarget` in a startup filter, as the testing guide shows.
37
+
34
38
  ## Loader parameters and route warnings
35
39
 
36
- Loaders bind like minimal APIs: a string or `TryParse` parameter takes the route value of the same name (any case), and otherwise the query string. `(string id)` on `/blog/[slug]` therefore reads `?id=`, never the slug, and `string[] path` on `[...path]` reads `?path=`. Netjes logs a warning for that at startup, and `npm run routes` prints it. Name parameters after the route segment, or mark query parameters `[FromQuery]`. The other warning is two pages that match the same URLs (`[id]` next to `[...rest]`); rename one, or accept it if the overlap is wanted.
40
+ Loaders bind like minimal APIs: a string or `TryParse` parameter takes the route value of the same name (any case), and otherwise the query string. `(string id)` on `/blog/[slug]` therefore reads `?id=`, never the slug, and `string[] path` on `[...path]` reads `?path=`. Netjes logs a warning for that at startup, and `npm run routes` prints it. Name parameters after the route segment, or mark query parameters `[FromQuery]`. Another warning is two pages that match the same URLs (`[id]` next to `[...rest]`); rename one, or accept it if the overlap is wanted. Others are an endpoint of the app that ties with a page (below), and a `[FromForm]` positional record with a `bool`, `bool?` or list field, which form binding refuses (400) when the post leaves that field out, as for an unchecked box: give the input `init` properties instead.
41
+
42
+ ## Endpoints next to pages
43
+
44
+ ASP.NET Core ranks the endpoints a URL matches by order (pages and `MapGet` endpoints have 0), then route precedence (a literal, a constrained parameter, a parameter, a constrained catch-all, then a catch-all), then HTTP methods, hosts and accepted content types, in that order. When two rank first together and both fit a request, it answers 500 (`AmbiguousMatchException`); Netjes warns about such an endpoint next to a page or a page action at startup and in `npm run routes`. A file next to a catch-all page, such as a Markdown copy of each docs page next to `app/docs/[...path]/page.tsx`, ranks the same as the page, so map it ahead of it and for HEAD as well: `app.MapMethods("/docs/{**path:regex(\\.md$)}", [HttpMethods.Get, HttpMethods.Head], ...).WithOrder(-1)`. Link it with `<a href={useHref(endpointUrl('GET /docs/{**path:regex(\\.md$)}', { path: 'routing.md' }))}>`, where `path` is the file's path as one string (on the docs page, `` `${path.join('/')}.md` `` from the catch-all's array); a bare `<a href>` misses the base path.
37
45
 
38
46
  ## Links and navigation
39
47
 
40
- - `<Link href={route('/blog/[slug]', { slug })}>`: client navigation with prefetch on hover and focus, `aria-current` on the active link. `route()` and the `Href` type come from `generated/netjes.ts`, so a wrong path or a missing parameter is a type error.
41
- - `useRouter()`: `push`, `replace`, `prefetch`, `refresh`, `back`, `forward`.
48
+ - `<Link href={route('/blog/[slug]', { slug })}>`: client navigation with prefetch on hover and focus; takes a `ref`, as `<Form>`, `<SubmitButton>` and `<Image>` do. `route()` and the `Href` type come from `generated/netjes.ts`, so a wrong path or a missing parameter is a type error.
49
+ - `route(pattern, params, search, { hash })` builds the query from `search` and a `#fragment` from `hash`, the id of an element on the page: `route('/docs/[...path]', { path }, undefined, { hash: 'keys' })`. Pass `undefined` as `search` for a link with a fragment and no query.
50
+ - `aria-current="page"` goes on a link to the page on screen: the same path (`/x` and `/x/` are one) and the page's values for every query parameter the link names. Parameters it leaves out do not count, and a `#` link is never marked. Pass `aria-current` to decide yourself: `undefined` on an "All" filter (`/blog`) while a filter is on, `"true"` on a section link (Docs) below the section's page, from `useSelectedLayoutSegments()` or `usePathname()`.
51
+ - `useRouter()`: `push`, `replace`, `prefetch`, `refresh`, `back`, `forward`. The promises of `push`, `replace` and `refresh` resolve once the router has handed the page to React, when the old page can still be on screen; code that needs the new page runs in an effect of that page, or in one that depends on `usePathname()` and `useSearchParams()`.
52
+ - After a navigation to another page the router announces it in a polite live region (its title, else its first `h1`, else its path) and starts the focus over at the top of the document. A link to a `#fragment`, on the page on screen or on another, moves the focus to that element instead, which is how the skip link works; Back and Forward restore the scroll position and give a fragment no focus. The region speaks only when the name differs from the previous page's, so give each page its own title, and keep the skip link the first thing in the root layout that takes the focus: `<Link href="#content" prefetch="none" className="sr-only focus:not-sr-only">Skip to content</Link>` and `<main id="content">`. The focus stays on the same page (query changes, refreshes, a post that stays, a modal), on an `autoFocus` element or wherever the app's code moved it during the navigation (a link's `onClick` and a form's `onSubmit` included), on a `scroll={false}` link that still has it, and in a field the user types into. To put it elsewhere on the new page, do it in that page's `useEffect`, which runs after the router's. If a menu closes in an effect after the navigation, give the focus back to its button only while the focus is still inside the menu. In end-to-end tests, focus a field before typing after a navigation (`locator.fill()` does), and read the element you mean rather than a container's whole text.
42
53
  - `usePathname()`, `useSearchParams(schema?)`, `useParams(schema?)`: with a schema the values are parsed; catch-all params are arrays. Search values a schema rejects are dropped so its defaults apply; a rejected route param goes to the error page, so constrain route params in C#.
43
54
  - `useSelectedLayoutSegment()`, `useSelectedLayoutSegments()`: the active child segment in a layout (tabs).
44
55
  - `useNavigation()`: `idle`, `loading` or `submitting`, for progress indicators.
@@ -46,20 +57,32 @@ Loaders bind like minimal APIs: a string or `TryParse` parameter takes the route
46
57
 
47
58
  ## Metadata
48
59
 
49
- - `export const metadata: Metadata = { title: 'About' }` on pages and layouts; a layout's `title: { default, template: '%s · Site' }` applies to the pages below it.
60
+ - `export const metadata: Metadata = { title: 'About' }` on pages and layouts; a layout's `title: { default, template: '%s · Site' }` applies to the pages below it. A `default` below the root goes through the nearest template above it, like a string (`'Docs'` under `'%s · Site'` is `Docs · Site`), never through the one beside it; `{ absolute: 'Docs' }` skips every template.
50
61
  - From C#: `.WithMetadata(new PageMetadata { Title = post.Title, Description = ... })` on the loader's result, merged over the files' metadata.
51
62
  - Below a layout that sets a title, use `metadata`, not a `<title>` element (that makes a second title).
63
+ - Code the first paint needs, such as a theme set on `<html>`, goes in `<Script id="theme" strategy="beforeInteractive">{code}</Script>` first in the root layout (a script takes no focus, so the skip link after it stays first): the server writes inline code where it stands and the parser runs it there. An external `beforeInteractive` script runs once the shell is parsed, in order, before the page's code; hydration waits for it, and inline code runs before it. That holds in a server-rendered document: a component that mounts in the browser, after a client navigation or when no sidecar was ready, adds its script after mounting, as `afterInteractive` does. Keep them outside Suspense boundaries. Under a hash-based policy, list the hash of the code as the document carries it, which `inlineScriptHashes` from `netjes/testing` gives, and an external script's origin in `script-src`.
52
64
 
53
65
  ## Protecting sections
54
66
 
55
- `app.MapLayoutLoader("/dashboard", ...).RequireAuthorization()` protects every page below `app/dashboard/`; `app.MapPages().RequireAuthorization()` protects every page that has no loader of its own; a page loader takes `.RequireAuthorization()` itself. With cookie authentication, answer 401/403 instead of redirecting (`OnRedirectToLogin`, `OnRedirectToAccessDenied`) so `unauthorized.tsx` and `forbidden.tsx` render.
67
+ `app.MapLayoutLoader("/dashboard", ...).RequireAuthorization()` protects every page below `app/dashboard/`; `app.MapPages().RequireAuthorization()` protects every page that has no loader of its own; a page loader takes `.RequireAuthorization()` itself. With cookie authentication, answer 401/403 instead of redirecting (`OnRedirectToLogin`, `OnRedirectToAccessDenied`) so `unauthorized.tsx` and `forbidden.tsx` render. `MapPages()`'s conventions never challenge an unknown URL, which stays a 404; a fallback policy covers the not-found fallback too, and challenges a visitor it refuses there. The not-found page, like the status and error pages of server actions and minimal APIs and a page's 401, 403 and pre-authorization status pages (a rate limiter's 429), runs a layout loader only for a visitor the pages in its folder would let in (its conventions and those above it, which pages with a loader carry too, and for file pages `MapPages()`'s or the fallback policy), and renders inside the layouts above it for the rest. So under `MapPages().RequireAuthorization()` the root `unauthorized.tsx` renders without the root layout when that has a loader: make it stand on its own, and never put `.AllowAnonymous()` on a layout loader to keep it, since that opens every page below. `Page.Render(pattern)` from a minimal API runs the page's layout loaders under that endpoint's authorization: protect it as the page is.
56
68
 
57
69
  A check that depends on data (is this user a member of this project?) goes in the page loader as well as in the layout loader: a page's loader runs before its layouts' loaders, and a layout loader is skipped on client navigations within its section while its own route values, the user and the roles stay the same. A layout loader also binds only the route values of its own URL, so it cannot check a deeper one.
58
70
 
71
+ ## Page packages
72
+
73
+ A page package is an npm package and a NuGet package of one version that bring pages, layouts, boundaries, loaders and actions. The app mounts it with the package's extension, before `app.MapPages()`, and lists the npm half in `netjes({ packages: ['@acme/help-center'] })`; both halves are installed at one version, or the app stops at startup in one line that says what to install.
74
+
75
+ - The prefix is in folder syntax: `/help`, `/(marketing)/help` (the same URL, inside `app/(marketing)/layout.tsx`), `/[tenant]/help` (`tenant` is a route value). The package's folder of pages merges into `app/` under it, folder by folder: its `layout.tsx` at the mount wraps the app's own pages in `app/help/`, and its `(center)` group wraps an app page in `app/help/(center)/`. Link to its pages with the app's `route('/help/articles/[slug]', { slug })`.
76
+ - An app page of the same shape as a package's page stops the app. To replace the package's page, put the app's in the same folder and add `.Override("/help/contact")` to the mount: it renders with the package's loader, so it reads `pages['/contact']` from the package's generated file (`import { pages } from '@acme/help-center/generated'`), and the app maps no loader for it. Layouts and boundaries cannot be replaced.
77
+ - Conventions on the mount (`.RequireAuthorization()`, `.ExcludeFromExport()`, `.ExcludeFromSitemap()`) cover the package's pages and endpoints, not the app's pages under the prefix; protect those with a layout loader above them, as before. An app layout loader above the mount protects the package's pages too.
78
+ - An unknown URL below the mount renders the package's `not-found.tsx`, after the layout loaders on the way, and `MapPages()`'s authorization, have authorized the visitor.
79
+ - A package that draws its own shell (a back-office package) should not render inside the site's header: keep `app/layout.tsx` to providers and the skip link, and move the site's header and footer into `app/(site)/layout.tsx` with the site's pages. URLs do not change. The `package-shell` route warning names an app that has not.
80
+ - Route warnings for packages: `package-shell`, `package-unmounted` (in the build but not mounted), `package-nonce` (a package whose pages stream, under a Content-Security-Policy without a nonce), and `overlap` for a literal page of one source next to a parameter page of the other (`/help/settings` from the package next to the app's `app/help/[section]`).
81
+
59
82
  ## Base path and locales
60
83
 
61
84
  `app.UsePathBase("/docs")` with `netjes({ basePath: '/docs' })`; links, forms and redirects add it, and app code is written as if at the root (`useHref(path)` for a plain `<a>`). Locale routing is a `[lang]` folder with `AddNetjesLocalization`, `UseNetjesLocalization` and `MapLocaleRedirect`; `useLocale()` reads it.
62
85
 
63
86
  ## More
64
87
 
65
- [Routing](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/routing.md) and [Metadata](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/metadata.md) in the guides, and the [React API](https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/react-api.md) for every component and hook.
88
+ [Routing](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/routing.md), [Page packages](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/page-packages.md) and [Metadata](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/metadata.md) in the guides, and the [React API](https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/react-api.md) for every component and hook.
@@ -4,7 +4,7 @@ Test first: write the test, watch it fail for the reason you expect, then change
4
4
 
5
5
  ## Components and pages: Vitest
6
6
 
7
- `netjes()` in `vite.config.ts` sets up Vitest: jsdom, Testing Library cleanup, and `e2e/`, `dist/`, `bin/` and `obj/` excluded. Tests sit next to the code as `*.test.tsx` and run with `npm test`.
7
+ `netjes()` in `vite.config.ts` sets up Vitest: jsdom, Testing Library cleanup (a config with `setupFiles` of its own lists `netjes/testing/setup` in them), `e2e/`, `dist/`, `bin/` and `obj/` excluded, and in every app a setup file of Netjes's own, which lets Testing Library work with fake timers and hands `createTestApp` the app's query defaults. Inline `test.projects` that share the root's Vite server, Vitest 5's default, get none of this: set `sharedViteServer: false` in the root's `test` options. Tests sit next to the code as `*.test.tsx` and run with `npm test`.
8
8
 
9
9
  ```tsx
10
10
  import { render, screen } from '@testing-library/react';
@@ -25,24 +25,40 @@ it('shows the validation error from C# next to the field', async () => {
25
25
  });
26
26
  ```
27
27
 
28
- `createTestApp({ url, params, shared, queries, navigation, antiforgery, actions })` returns `{ Wrapper, navigations, queryClient }`:
28
+ `createTestApp({ url, params, shared, queries, defaultOptions, navigation, antiforgery, actions })` returns `{ Wrapper, navigations, queryClient }`:
29
29
 
30
30
  - `url` and `params` set what `usePathname`, `useSearchParams` and `useParams` see; `shared` is what `useSharedProps` returns; `queries: [{ key, data }]` seeds TanStack Query as a loader's `.WithQuery` would.
31
- - `actions` answers server action calls by name: `{ ok: true, result }` or `{ ok: false, status: 422, errors }` (add `fields: ['email']` to name the fields a failed state's `values` keep, as the server does). A component that calls an action, even from an effect as it mounts, must render inside `app.Wrapper`: outside it the call fails with "called before the page was ready".
31
+ - The `QueryClient` starts from the app's `netjes({ query: { defaultOptions } })`: a failed query or mutation is tried as often as in the app, with no wait between the tries, so the test sees the error the app shows after its last try. `defaultOptions` goes over them: `{ queries: { retry: false } }` shows the error after the first request. Seeded data is fresh for the app's `staleTime` from when `createTestApp()` is called: with a `staleTime` of 0, or in a component rendered after a fake clock moved past it, a seeded query is fetched again as it mounts; answer that request (MSW) or pass `{ queries: { staleTime: 60_000 } }`.
32
+ - `actions` answers server action calls by name: `{ ok: true, result }` or `{ ok: false, status: 422, errors }` (add `fields: ['email']` to name the fields a failed state's `values` keep, as the server does). A failure other than a 422 throws a `ServerActionError`, withheld unless the answer has the `message` the server would send: `{ ok: false, status: 403, errors: {}, message: 'Forbidden' }`. A component that calls an action, even from an effect as it mounts, must render inside `app.Wrapper`: outside it the call fails with "called before the page was ready".
32
33
  - `navigations` records what happened: `{ type: 'push' | 'replace', href }`, `{ type: 'refresh' | 'back' | 'forward' }`, `{ type: 'submit', action, data }` for form posts, `{ type: 'action', name, input }` for action calls.
33
34
 
34
35
  Render a page component directly with the props its loader would send: `render(<Post title="Hello" body="..." />, { wrapper: createTestApp({ url: '/blog/hello' }).Wrapper })`. A page made with `definePage` parses them with its schema, so props the loader could not send fail the test with `Invalid props: ...`.
35
36
 
37
+ For the whole document, as the server sends it (the layouts, the head, Netjes's inline scripts), `renderDocument('app/blog/[slug]/page.tsx', { props, params: { slug: 'hello' }, url: '/blog/hello', layouts: { 'app/blog/layout.tsx': { categories } }, shared })` from `netjes/testing` resolves with the HTML. The test file starts with `// @vitest-environment node`: in a DOM environment it rejects, since the server renders without one. No C# runs, so pass what the loaders would send; a layout that `layouts` does not name gets no props. Nothing else the server adds is there: query entries seeded with `.WithQuery` (a `useQuery` renders without its data), metadata from C#, the antiforgery token, the locale, draft mode, the base path and the image optimiser's settings. A boundary file renders inside the layouts down to its own folder, and `global-error` without any layout, with no route. A Promise in the props is a `Page.Defer` value; pass `signal: AbortSignal.timeout(1000)` when one may never settle. `inlineScriptHashes(html)` is async: `await inlineScriptHashes(html)` resolves to the `'sha256-…'` hashes of the inline scripts in the document that a browser runs, for a Content-Security-Policy without a nonce. With no inline scripts of the app's own they equal `CSP_SCRIPT_HASHES.split(' ')` from `netjes`, as `app/document.test.ts` in a new app checks for the home page: `expect(await inlineScriptHashes(html)).toEqual(CSP_SCRIPT_HASHES.split(' '))`. The document is a crawler's, so React's scripts that reveal a Suspense boundary for a visitor are not in it, nor the inline code of an `afterInteractive` or `lazyOnload` `<Script>`, which the browser adds: its hash goes in the policy only. A `beforeInteractive` `<Script>`'s inline code is in the document, so its hash goes in the policy and in the list a test expects from `inlineScriptHashes`.
38
+
39
+ Fake timers work with Testing Library: after `vi.useFakeTimers()`, create the user with `userEvent.setup({ advanceTimers: vi.advanceTimersByTime })`, and let `findBy…` or `waitFor` wait, which moves the fake clock on (their timeout counts fake time). Run a timer that `fireEvent` started with `await act(() => vi.advanceTimersByTimeAsync(ms))`, and call `vi.useRealTimers()` in an `afterEach`.
40
+
36
41
  ## Loaders and actions: C#
37
42
 
43
+ An app made with `netjes new` has a test project in `tests/<App>.Tests`, with an `AppFactory` set up as below and tests of the home page: `dotnet test tests/<App>.Tests` runs them. In another app, copy that setup from the [testing guide](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/testing.md#c-tests-of-loaders-and-actions); the app's project needs `<DefaultItemExcludes>$(DefaultItemExcludes);tests/**;e2e/**</DefaultItemExcludes>` first, since the Web SDK compiles everything below it.
44
+
38
45
  - Loader logic: call the loader or the method it uses and read `PageResult<T>.Props` in an xUnit test.
39
- - Endpoints: test the real app with `WebApplicationFactory<Program>` from `Microsoft.AspNetCore.Mvc.Testing` (ASP.NET Core 10 makes the `Program` class of top-level statements public, so the test project sees it). No Node is needed when the test sets `Netjes:Mode` to `Development` (routes come from `app/` on disk) and registers an `ISsrRenderer` (`Netjes.Rendering`) of its own that returns fixed HTML, in `ConfigureServices`. Request a page as the client router does, with the header `X-Netjes-Data: 1` (`NetjesHeaders.Data`): the answer is the page payload as JSON, with `props`, `status` and `antiforgery`; a page with `Page.Defer` values answers NDJSON, the payload first. When the app cannot start (no build in Production, `node` not found), `CreateClient()` throws an `InvalidOperationException` that says why.
46
+ - Endpoints: test the real app with `WebApplicationFactory<Program>` from `Microsoft.AspNetCore.Mvc.Testing` (ASP.NET Core 10 makes the `Program` class of top-level statements public, so the test project sees it). No Node is needed when the test sets `Netjes:Mode` to `Development` (routes come from `app/` on disk) and registers an `ISsrRenderer` (`Netjes.Rendering`) of its own that returns fixed HTML, in `ConfigureServices`. Set `NetjesOptions.TypesFile` to `null` there too (`services.Configure<NetjesOptions>(o => o.TypesFile = null)`): in development mode the app rewrites `generated/netjes.ts` at startup, and a test run that did would let a later `netjes generate --check` pass on a stale committed file. Run it in the `Testing` environment (`builder.UseEnvironment("Testing")`), as the testing guide's factory does: `WebApplicationFactory` uses `Development` unless told otherwise, and there Netjes output-caches no page, so a test of `CachePage` would see every request render. Request a page as the client router does, with the header `X-Netjes-Data: 1` (`NetjesHeaders.Data`): the answer is the page payload as JSON, with `props`, `status` and `antiforgery`; a page with `Page.Defer` values answers NDJSON, the payload first. When the app cannot start (no build in Production, `node` not found), `CreateClient()` throws an `InvalidOperationException` that says why.
40
47
  - Server actions are `POST /_netjes/actions/{name}`. Call one as the client router does: the fields form-encoded, `X-Netjes-Data: 1`, and the payload's `antiforgery.requestToken` in the header named by `antiforgery.headerName` (a client that keeps cookies, so the antiforgery cookie goes along; `GET /_netjes/antiforgery` returns a token too). A plain form post, as without JavaScript, sends the token as the `antiforgery.formFieldName` field instead.
41
48
  - Use a real database for data access tests (Testcontainers), not an in-memory fake.
42
49
 
43
50
  ## End to end: Playwright
44
51
 
45
- Put Playwright tests in `e2e/` (Vitest skips it). Run them against the app in Development (`dotnet run --no-launch-profile` with `ASPNETCORE_ENVIRONMENT=Development`) and in Production (`npm run build`, then `dotnet run --no-launch-profile` with `ASPNETCORE_ENVIRONMENT=Production`), waiting on `/healthz`. Test the forms with JavaScript turned off too (`test.use({ javaScriptEnabled: false })`): they must post, show errors and keep what was typed.
52
+ An app made with `netjes new` has this set up: `playwright.config.ts`, `e2e/home.spec.ts` and `npm run test:e2e`, after `npx playwright install chromium` once. Put Playwright tests in `e2e/` (Vitest skips it). Build the app once (`dotnet build`, as `npm run test:e2e` does), then run them against it in Development (`dotnet run --no-build --no-launch-profile` with `ASPNETCORE_ENVIRONMENT=Development` and a `Netjes__ViteDevPort` of its own) and in Production (`npm run build`, then `dotnet run --no-build --no-launch-profile` with `ASPNETCORE_ENVIRONMENT=Production`), each waiting on `/healthz`. Playwright starts its servers at once, and two `dotnet run` without `--no-build` would build the same project at the same time. Test the forms with JavaScript turned off too (`test.use({ javaScriptEnabled: false })`): they must post, show errors and keep what was typed.
53
+
54
+ With JavaScript on, wait for hydration after each full page load, before the first click or keystroke; until then links load pages in full and forms post natively, so the test can pass without the client. Netjes puts `data-netjes-hydrated` on `<html>` once the client router runs in the document:
55
+
56
+ ```ts
57
+ await page.goto('/');
58
+ await expect(page.locator('html')).toHaveAttribute('data-netjes-hydrated', '');
59
+ ```
60
+
61
+ `page.locator('html[data-netjes-hydrated]').waitFor({ state: 'attached' })` waits for the same thing without `expect`'s 5-second limit. Never wait on `history.state`: a reload keeps it, so it is there before the page's scripts have run. Content inside a Suspense boundary can hydrate after the mark; React hydrates it first when a click lands on it.
46
62
 
47
63
  ## Before calling a change done
48
64
 
@@ -50,8 +66,8 @@ Put Playwright tests in `e2e/` (Vitest skips it). Run them against the app in De
50
66
  npm test && npm run typecheck && dotnet build && npx netjes generate --check
51
67
  ```
52
68
 
53
- and `npm run routes` without new warnings.
69
+ and `npm run routes` without new warnings. Where the app has them: `dotnet test tests/<App>.Tests`, and `npm run test:e2e` for a change to a page or a form.
54
70
 
55
71
  ## More
56
72
 
57
- [Testing](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/testing.md) in the guides, and `createTestApp` in the [React API](https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/react-api.md#createtestapp).
73
+ [Testing](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/testing.md) in the guides, and [`createTestApp`](https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/react-api.md#createtestapp) and [`renderDocument`](https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/react-api.md#renderdocument) in the React API.
package/src/actions.tsx CHANGED
@@ -23,13 +23,19 @@ export interface ServerAction<TInput, TResult> {
23
23
  readonly url: string;
24
24
  }
25
25
 
26
- /** Thrown into the nearest error boundary when the server could not run the action (not for validation errors). */
26
+ /**
27
+ * Thrown into the nearest error boundary when the server could not run the action (not for validation errors).
28
+ * `status` is the status of the server's answer. `withheld` is true when the server sent no message, as for a 5xx
29
+ * with error details off: the message is then Netjes's own, which names the action and the status.
30
+ */
27
31
  export class ServerActionError extends Error {
28
32
  override name = 'ServerActionError';
29
33
  readonly status: number;
30
- constructor(message: string, status: number) {
34
+ readonly withheld: boolean;
35
+ constructor(message: string, status: number, withheld = false) {
31
36
  super(message);
32
37
  this.status = status;
38
+ this.withheld = withheld;
33
39
  }
34
40
  }
35
41
 
@@ -170,7 +176,7 @@ export function serverAction<TInput = FormData, TResult = unknown>(
170
176
  const active = client ?? (await readyClient(name));
171
177
  const answer = await active.invoke(name, url, input);
172
178
  if (!answer.ok && answer.status !== 422) {
173
- throw new ServerActionError(answer.message ?? `Server action ${name} failed with status ${answer.status}`, answer.status);
179
+ throw new ServerActionError(answer.message ?? `Server action ${name} failed with status ${answer.status}`, answer.status, answer.message === undefined);
174
180
  }
175
181
  if (!answer.ok) {
176
182
  const values = submittedValues(input, active.tokenField?.(), answer.fields);
@@ -1,4 +1,6 @@
1
- // Must come first: installs the React Fast Refresh runtime before any component module runs (empty in builds).
1
+ // Must come first, ahead of anything that could make a zod schema: turns zod's compiled parsers off (ADR 0036).
2
+ import './zod-jitless.ts';
3
+ // Must come before any component module: installs the React Fast Refresh runtime before one runs (empty in builds).
2
4
  import '@vitejs/plugin-react/preamble';
3
5
  import { createRoot, hydrateRoot } from 'react-dom/client';
4
6
  import { pages } from 'virtual:netjes/pages';
@@ -7,6 +9,7 @@ import { type RouteModules, createModuleLoader } from './modules.ts';
7
9
  import { DATA_ELEMENT_ID, SHELL_END_ID, parseInitialPayload } from './payload.ts';
8
10
  import { Root } from './root.tsx';
9
11
  import { withClockOffset } from './router.tsx';
12
+ import { BEFORE_INTERACTIVE_MARKER, adoptServerScripts } from './script.tsx';
10
13
 
11
14
  /**
12
15
  * The payload is the body's first element, so it is normally parsed by the time this async script runs. If the
@@ -112,6 +115,18 @@ async function firstPageModules(): Promise<RouteModules | null> {
112
115
  }
113
116
  }
114
117
 
118
+ /**
119
+ * A `<Script strategy="beforeInteractive">` in the server's document puts a marker in the head, which is complete by
120
+ * the time the payload is read. Such a page waits for the end of the shell, where the parser has run the inline ones,
121
+ * then runs the external ones in order and waits for them, before the page's modules run and hydrate (ADR 0044). A
122
+ * page without one loads its modules while the rest of the shell arrives (ADR 0022).
123
+ */
124
+ const beforeInteractive = document.head.querySelector(`meta[name="${BEFORE_INTERACTIVE_MARKER}"]`) !== null;
125
+ if (beforeInteractive) {
126
+ await shellParsed();
127
+ await adoptServerScripts(payload.document.nonce);
128
+ }
129
+
115
130
  const modules = await firstPageModules();
116
131
  if (modules) {
117
132
  const root = <Root payload={payload} modules={modules} loader={loader} dataJson={dataJson} />;
@@ -120,7 +135,10 @@ if (modules) {
120
135
  // the scripts and stylesheets already in it and replaces the rest.
121
136
  createRoot(document).render(root);
122
137
  } else {
123
- await shellParsed();
138
+ if (!beforeInteractive) {
139
+ await shellParsed();
140
+ await adoptServerScripts(payload.document.nonce);
141
+ }
124
142
  hydrateRoot(document, root);
125
143
  }
126
144
  }
package/src/deferred.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // Deferred loader values (Page.Defer in C#): placeholders in the payload that become promises the page reads with
2
2
  // React's use() inside a Suspense boundary.
3
+ import { withheldError } from './errors.ts';
3
4
  import type { PagePayload, SegmentRef } from './payload.ts';
4
5
 
5
6
  /** A promise that records its outcome the way React's `use()` reads it, so a settled one renders without suspending. */
@@ -94,8 +95,9 @@ export function deferredRegistry() {
94
95
  resolve(id: string, value: unknown) {
95
96
  entry(id).resolve(value);
96
97
  },
98
+ /** Fails a value with the server's message; an empty one is a message the server withheld (no error details). */
97
99
  reject(id: string, message: string) {
98
- entry(id).reject(new Error(message));
100
+ entry(id).reject(message === '' ? withheldError() : new Error(message));
99
101
  },
100
102
  /** Rejects every value still pending, e.g. when the stream that was to deliver them ended. */
101
103
  rejectPending(message: (id: string) => string) {
@@ -115,6 +117,17 @@ export function deferredRegistry() {
115
117
  export const DEFERRED_BOOTSTRAP =
116
118
  "self.__netjes_d=self.__netjes_d||function(i,v,e){self.__netjes_v[i]=[v,e]};Object.prototype.hasOwnProperty.call(self,'__netjes_v')||(self.__netjes_v={})";
117
119
 
120
+ /**
121
+ * The `script-src` sources that allow Netjes's fixed inline scripts by their hashes, for a policy without a nonce: each
122
+ * `'sha256-…'` in its quotes, separated by spaces, as in `` `script-src 'self' ${CSP_SCRIPT_HASHES}` ``. A fixed script
123
+ * has the same text in every document. In this version there is one, the bootstrap that collects streamed `Page.Defer`
124
+ * values, and a Netjes version that changes it or adds another changes the value. The scripts that deliver those
125
+ * values, and React's scripts that reveal Suspense boundaries, differ from render to render, so no hash covers them.
126
+ * React's script for form actions that are functions is left out too, since its text follows the React version. The
127
+ * same value as `NetjesContentSecurityPolicy.ScriptHashes` in C#.
128
+ */
129
+ export const CSP_SCRIPT_HASHES: string = "'sha256-FSjVV+FzmXP8KKrLKLdvst/5N/bEWSkde01XzpGKRgs='";
130
+
118
131
  const UNSAFE = /[<>&\u2028\u2029]/g;
119
132
 
120
133
  /** JSON that can sit inside a <script> element. */
package/src/document.tsx CHANGED
@@ -15,8 +15,17 @@ type DocumentProps = { info: DocumentInfo; dataJson: string; children: ReactNode
15
15
  */
16
16
  export function Document({ info, dataJson, children }: DocumentProps) {
17
17
  const { nonce } = info;
18
+ // Netjes owns <html> and <body>: the app's layout renders inside the body, so its markup cannot give either one an
19
+ // attribute. A script that runs before hydration can (a theme, a browser extension, a translator), and in development
20
+ // React logs a mismatch for each attribute on an element that its props do not have. suppressHydrationWarning ends
21
+ // that check for the element's own attributes. It also makes React accept any text that is the element's own child,
22
+ // in production too: text that a layout or a page renders outside any element or Suspense boundary sits directly in
23
+ // the body, and a mismatch there is neither reported nor repaired. The elements in the body are checked as before,
24
+ // their attributes and their text. React does not write the prop into the markup, and production does not check
25
+ // attributes at all. The check is off for `lang` too: the server and the client read it from the same payload, so it
26
+ // differs only when a script changed it, or when Netjes has a bug.
18
27
  return (
19
- <html lang={info.lang}>
28
+ <html lang={info.lang} suppressHydrationWarning>
20
29
  <head>
21
30
  <meta charSet="utf-8" />
22
31
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -28,8 +37,18 @@ export function Document({ info, dataJson, children }: DocumentProps) {
28
37
  {info.assets.stylesheets.map((href) => (
29
38
  <link key={href} rel="stylesheet" href={href} nonce={nonce} />
30
39
  ))}
40
+ {/* In development, the CSS the page imports, so that it is styled before its scripts run. Vite's client owns
41
+ each element's text once it runs: the CSS module with that id sets it as it loads, before hydration, and
42
+ again after every edit. The sidecar inlines the build that module carries, but a file edited between the
43
+ render and the import differs, so React leaves the text unchecked. */}
31
44
  {info.devStyles?.map((style) => (
32
- <style key={style.id} data-vite-dev-id={style.id} nonce={nonce} dangerouslySetInnerHTML={{ __html: style.css }} />
45
+ <style
46
+ key={style.id}
47
+ data-vite-dev-id={style.id}
48
+ nonce={nonce}
49
+ suppressHydrationWarning
50
+ dangerouslySetInnerHTML={{ __html: style.css }}
51
+ />
33
52
  ))}
34
53
  {info.assets.modulePreloads.map((href) => (
35
54
  <link key={href} rel="modulepreload" href={href} nonce={nonce} />
@@ -38,7 +57,7 @@ export function Document({ info, dataJson, children }: DocumentProps) {
38
57
  <script key={src} type="module" src={src} async nonce={nonce} />
39
58
  ))}
40
59
  </head>
41
- <body>
60
+ <body suppressHydrationWarning>
42
61
  {/* Already escaped for HTML by the server; the client reads it back verbatim to hydrate. */}
43
62
  <script id={DATA_ELEMENT_ID} type="application/json" nonce={nonce} dangerouslySetInnerHTML={{ __html: dataJson }} />
44
63
  {children}
package/src/errors.ts ADDED
@@ -0,0 +1,12 @@
1
+ // The mark on errors whose message Netjes withheld: `withheld` is true when the message is Netjes's stand-in for one it
2
+ // does not show (error details are off, or the thrown value had none), so `!error.withheld` means it is worth showing.
3
+
4
+ /** Netjes's stand-in for a message it does not show, marked as one. */
5
+ export function withheldError(): Error & { withheld: true } {
6
+ return Object.assign(new Error('Something went wrong'), { withheld: true as const });
7
+ }
8
+
9
+ /** Whether `error` is marked as one whose message was withheld. */
10
+ export function isWithheld(error: unknown): boolean {
11
+ return error instanceof Error && (error as { withheld?: unknown }).withheld === true;
12
+ }
package/src/form.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { ButtonHTMLAttributes, FormHTMLAttributes, SubmitEvent } from 'react';
1
+ import type { ComponentPropsWithRef, SubmitEvent } from 'react';
2
2
  import { RETURN_FIELD, actionMetaOf } from './actions.tsx';
3
3
  import { encodeForm } from './form-data.ts';
4
4
  import type { Href } from './href.ts';
@@ -7,8 +7,8 @@ import { useBasePath, useInternalRouter, usePagePayload, withBasePath } from './
7
7
  /** A React form action, e.g. the `formAction` from `useAction(serverAction('name'))`. */
8
8
  export type FormAction = (formData: FormData) => void | Promise<void>;
9
9
 
10
- /** The props of `<Form>`: a form's own, with client navigation and server action support. */
11
- export type FormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'method' | 'action'> & {
10
+ /** The props of `<Form>`: a form's own and its ref, with client navigation and server action support. */
11
+ export type FormProps = Omit<ComponentPropsWithRef<'form'>, 'method' | 'action'> & {
12
12
  method?: 'get' | 'post';
13
13
  /**
14
14
  * Where to submit. Default: the current URL, which is where `MapPageAction` handlers for this page listen. Or a
@@ -74,6 +74,9 @@ export function Form({ method = 'get', action, replace = false, reloadDocument =
74
74
  }
75
75
 
76
76
  function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
77
+ // Read before the app's own onSubmit, which runs as the form is submitted: where it moves the focus, the router
78
+ // leaves it, as after a link's onClick.
79
+ const focusedAtStart = document.activeElement;
77
80
  onSubmit?.(event);
78
81
  if (event.defaultPrevented || reloadDocument) return;
79
82
  const form = event.currentTarget;
@@ -88,7 +91,7 @@ export function Form({ method = 'get', action, replace = false, reloadDocument =
88
91
  const data = formDataOf(form, submitter);
89
92
  const submitMethod = (submitter?.getAttribute('formmethod') ?? method).toLowerCase();
90
93
  if (submitMethod === 'post') {
91
- void router.submit(url, encodeForm(data, form.enctype === 'multipart/form-data'));
94
+ void router.submit(url, encodeForm(data, form.enctype === 'multipart/form-data'), { focusedAtStart });
92
95
  return;
93
96
  }
94
97
  const query = new URLSearchParams();
@@ -97,7 +100,7 @@ export function Form({ method = 'get', action, replace = false, reloadDocument =
97
100
  url.hash = '';
98
101
  // Built from the form's own action, which is not one of the app's typed routes.
99
102
  const href = url.href as Href;
100
- void (replace ? router.replace(href) : router.push(href));
103
+ void (replace ? router.replace(href, { focusedAtStart }) : router.push(href, { focusedAtStart }));
101
104
  }
102
105
 
103
106
  return (
@@ -108,8 +111,8 @@ export function Form({ method = 'get', action, replace = false, reloadDocument =
108
111
  );
109
112
  }
110
113
 
111
- /** The props of `<SubmitButton>`: a button's own, except `type`. */
112
- export type SubmitButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'>;
114
+ /** The props of `<SubmitButton>`: a button's own and its ref, except `type`. */
115
+ export type SubmitButtonProps = Omit<ComponentPropsWithRef<'button'>, 'type'>;
113
116
 
114
117
  /**
115
118
  * A submit button whose `formAction` is rendered under the base path, so it posts to the right place with and