netjes 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/cli/agents.mjs +25 -2
- package/cli/commands.mjs +56 -6
- package/cli/help.mjs +7 -2
- package/cli/info.mjs +7 -2
- package/cli/scaffold.mjs +31 -4
- package/dist/actions.d.ts +7 -2
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +9 -3
- package/dist/actions.js.map +1 -1
- package/dist/client-entry.d.ts +1 -0
- package/dist/client-entry.d.ts.map +1 -1
- package/dist/client-entry.js +43 -2
- package/dist/client-entry.js.map +1 -1
- package/dist/deferred.d.ts +11 -0
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +15 -1
- package/dist/deferred.js.map +1 -1
- package/dist/document.d.ts +4 -3
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +15 -5
- package/dist/document.js.map +1 -1
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +11 -0
- package/dist/errors.js.map +1 -0
- package/dist/form.d.ts +5 -5
- package/dist/form.d.ts.map +1 -1
- package/dist/form.js +5 -2
- package/dist/form.js.map +1 -1
- package/dist/href.d.ts +24 -3
- package/dist/href.d.ts.map +1 -1
- package/dist/href.js +117 -7
- package/dist/href.js.map +1 -1
- package/dist/image.d.ts +3 -3
- package/dist/image.d.ts.map +1 -1
- package/dist/image.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/link.d.ts +15 -7
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +38 -7
- package/dist/link.js.map +1 -1
- package/dist/metadata.d.ts +8 -2
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +14 -5
- package/dist/metadata.js.map +1 -1
- package/dist/modules.d.ts +15 -1
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js.map +1 -1
- package/dist/navigation-focus.d.ts +38 -0
- package/dist/navigation-focus.d.ts.map +1 -0
- package/dist/navigation-focus.js +71 -0
- package/dist/navigation-focus.js.map +1 -0
- package/dist/payload.d.ts +2 -0
- package/dist/payload.d.ts.map +1 -1
- package/dist/payload.js +2 -0
- package/dist/payload.js.map +1 -1
- package/dist/query.d.ts +5 -2
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +11 -8
- package/dist/query.js.map +1 -1
- package/dist/render-document.d.ts +60 -0
- package/dist/render-document.d.ts.map +1 -0
- package/dist/render-document.js +330 -0
- package/dist/render-document.js.map +1 -0
- package/dist/route-announcer.d.ts +18 -0
- package/dist/route-announcer.d.ts.map +1 -0
- package/dist/route-announcer.js +58 -0
- package/dist/route-announcer.js.map +1 -0
- package/dist/route-tree.d.ts +6 -2
- package/dist/route-tree.d.ts.map +1 -1
- package/dist/route-tree.js +17 -7
- package/dist/route-tree.js.map +1 -1
- package/dist/router.d.ts +28 -5
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +71 -26
- package/dist/router.js.map +1 -1
- package/dist/script.d.ts +28 -5
- package/dist/script.d.ts.map +1 -1
- package/dist/script.js +192 -21
- package/dist/script.js.map +1 -1
- package/dist/server-render.d.ts.map +1 -1
- package/dist/server-render.js +4 -1
- package/dist/server-render.js.map +1 -1
- package/dist/testing-vitest.d.ts +8 -0
- package/dist/testing-vitest.d.ts.map +1 -0
- package/dist/testing-vitest.js +26 -0
- package/dist/testing-vitest.js.map +1 -0
- package/dist/testing.d.ts +11 -1
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +17 -3
- package/dist/testing.js.map +1 -1
- package/dist/zod-jitless.d.ts +2 -0
- package/dist/zod-jitless.d.ts.map +1 -0
- package/dist/zod-jitless.js +4 -0
- package/dist/zod-jitless.js.map +1 -0
- package/package.json +1 -1
- package/sidecar/server.mjs +73 -6
- package/skills/netjes/SKILL.md +13 -6
- package/skills/netjes/references/caching-and-streaming.md +37 -4
- package/skills/netjes/references/data-and-actions.md +9 -5
- package/skills/netjes/references/deployment.md +7 -3
- package/skills/netjes/references/routing.md +18 -6
- package/skills/netjes/references/testing.md +23 -7
- package/src/actions.tsx +9 -3
- package/src/client-entry.tsx +43 -2
- package/src/deferred.ts +14 -1
- package/src/document.tsx +28 -7
- package/src/errors.ts +12 -0
- package/src/form.tsx +10 -7
- package/src/href.ts +123 -8
- package/src/image.tsx +3 -3
- package/src/index.ts +2 -1
- package/src/link.tsx +47 -13
- package/src/metadata.tsx +19 -6
- package/src/modules.ts +15 -2
- package/src/navigation-focus.ts +75 -0
- package/src/payload.ts +2 -0
- package/src/query.tsx +14 -8
- package/src/render-document.tsx +386 -0
- package/src/route-announcer.tsx +69 -0
- package/src/route-tree.tsx +17 -6
- package/src/router.tsx +116 -31
- package/src/script.tsx +214 -23
- package/src/server-render.tsx +4 -1
- package/src/testing-vitest.ts +37 -0
- package/src/testing.tsx +31 -5
- package/src/virtual.d.ts +2 -0
- package/src/zod-jitless.ts +15 -0
- package/template/.dockerignore +3 -0
- package/template/Dockerfile +11 -1
- package/template/README.md +11 -7
- package/template/_AGENTS.md +36 -8
- package/template/__NAME__.csproj +2 -0
- package/template/app/document.test.ts +29 -0
- package/template/app/error.tsx +3 -2
- package/template/app/layout.tsx +10 -1
- package/template/docker-compose.override.yml +4 -3
- package/template/docker-compose.prod.yml +2 -1
- package/template/e2e/home.spec.ts +32 -0
- package/template/generated/netjes.ts +43 -14
- package/template/package.json +4 -1
- package/template/playwright.config.ts +36 -0
- package/template/tests/__NAME__.Tests/AppFactory.cs +31 -0
- package/template/tests/__NAME__.Tests/HomePageTests.cs +55 -0
- package/template/tests/__NAME__.Tests/__NAME__.Tests.csproj +30 -0
- package/template/tsconfig.app.json +20 -0
- package/template/tsconfig.json +2 -17
- package/template/tsconfig.node.json +8 -0
- package/vite/plugin.d.ts +9 -1
- package/vite/plugin.mjs +17 -5
package/sidecar/server.mjs
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// production: renders with the prebuilt dist/server/server-entry.js.
|
|
5
5
|
// Protocol: POST /render with the HTML-safe page payload JSON as body -> 200 text/html, streamed from the moment the
|
|
6
6
|
// shell is ready (or once everything is, with X-Netjes-Wait-All: 1), or 500 {"error":{message,stack}} in plain text
|
|
7
|
-
// (no terminal colours) if the shell fails.
|
|
8
|
-
//
|
|
7
|
+
// (no terminal colours) if the shell fails. GET /health -> 200 at once, without a body read. In production the parent
|
|
8
|
+
// pings a process that has answered no render for a while; any answer will do, and a process that sends nothing back
|
|
9
|
+
// within the render timeout (at least 5 s) is killed and restarted. Every request must carry the X-Netjes-Token the
|
|
10
|
+
// parent passed in NETJES_TOKEN. Renders are aborted when the caller disconnects, when the shell is not ready after
|
|
9
11
|
// NETJES_RENDER_TIMEOUT_MS, or when the whole stream takes longer than NETJES_STREAM_TIMEOUT_MS. Exits when stdin
|
|
10
12
|
// closes.
|
|
11
13
|
// Startup: prints NETJES_READY {"port","viteOrigin"} on stdout once it listens. When the Vite dev server does not start,
|
|
@@ -127,7 +129,10 @@ if (mode === 'development') {
|
|
|
127
129
|
|
|
128
130
|
/**
|
|
129
131
|
* Inlines the CSS imported by the page and its segment files, so the server-rendered page is styled before the client
|
|
130
|
-
* script runs. Vite's client adopts these <style data-vite-dev-id> tags and keeps them updated through HMR
|
|
132
|
+
* script runs. Vite's client adopts these <style data-vite-dev-id> tags and keeps them updated through HMR: each CSS
|
|
133
|
+
* module sets its tag's text to its own as it loads, before hydration. The files are found in the SSR environment,
|
|
134
|
+
* which rendered the page, and their text comes from the client environment's build, which that module carries
|
|
135
|
+
* (clientCss): React then hydrates the text the server rendered.
|
|
131
136
|
* @param {import('vite').ViteDevServer} server
|
|
132
137
|
* @param {string} data
|
|
133
138
|
*/
|
|
@@ -149,8 +154,7 @@ async function withDevStyles(server, data) {
|
|
|
149
154
|
if (!node?.id || seen.has(node.id)) return;
|
|
150
155
|
seen.add(node.id);
|
|
151
156
|
if (node.file && CSS_FILE.test(node.file) && !node.id.includes('?')) {
|
|
152
|
-
|
|
153
|
-
styles.set(node.file, String(css.default));
|
|
157
|
+
styles.set(node.file, await clientCss(server, node));
|
|
154
158
|
return;
|
|
155
159
|
}
|
|
156
160
|
for (const imported of node.importedModules) await collect(imported);
|
|
@@ -165,6 +169,66 @@ async function withDevStyles(server, data) {
|
|
|
165
169
|
return JSON.stringify(payload).replace(/[<>&\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
166
170
|
}
|
|
167
171
|
|
|
172
|
+
/** How a CSS module in the client environment declares the text it sets: this, then the text as a JSON string. */
|
|
173
|
+
const CLIENT_CSS_LINE = 'const __vite__css = ';
|
|
174
|
+
/** Whether a module without that line, or without a JSON string on it, has been reported: once per process. */
|
|
175
|
+
let clientCssMissing = false;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A CSS file's text as Vite's client sets it in the page: the string that the file's module in the client environment
|
|
179
|
+
* declares and passes to updateStyle. The browser imports that module and gets it from the same cache, until an edit
|
|
180
|
+
* invalidates it; a module that does not accept its own updates, and that no browser has loaded yet, is built again
|
|
181
|
+
* for the browser (below). The SSR environment's `?inline` build is a second build of the file, which a plugin with
|
|
182
|
+
* state of its own in each environment, as Tailwind CSS has, can make differently. A module without that line, or with
|
|
183
|
+
* something other than a JSON string after it, from a Vite or a plugin that writes it otherwise, gets the `?inline`
|
|
184
|
+
* build all the same, and a warning.
|
|
185
|
+
* @param {import('vite').ViteDevServer} server
|
|
186
|
+
* @param {import('vite').EnvironmentModuleNode} node
|
|
187
|
+
*/
|
|
188
|
+
async function clientCss(server, node) {
|
|
189
|
+
const client = server.environments.client;
|
|
190
|
+
const built = await client.transformRequest(node.url);
|
|
191
|
+
const module = await client.moduleGraph.getModuleByUrl(node.url);
|
|
192
|
+
if (module && module.importers.size === 0 && module.isSelfAccepting === false) {
|
|
193
|
+
// Nothing in the client environment imports the module, so no browser has loaded a page with it, and the module
|
|
194
|
+
// does not accept its own updates, as a CSS Modules file's does not. Vite would take an edit to the file for one
|
|
195
|
+
// that no page can apply, and reload every open page, where without this read the module would not be there and
|
|
196
|
+
// the edit would reach no browser. So that it still reaches none, the module counts as not yet analyzed, where Vite
|
|
197
|
+
// stops an update, and its build is dropped: Vite builds it again for the browser that loads the page, and learns
|
|
198
|
+
// from that build how the module takes updates.
|
|
199
|
+
client.moduleGraph.invalidateModule(module);
|
|
200
|
+
module.isSelfAccepting = undefined;
|
|
201
|
+
}
|
|
202
|
+
// By lines, not with a regular expression: JSON.stringify leaves U+2028 and U+2029 in a string, and `.` stops at both.
|
|
203
|
+
const line = built?.code.split('\n').find((text) => text.startsWith(CLIENT_CSS_LINE));
|
|
204
|
+
const css = line === undefined ? undefined : jsonString(line.slice(CLIENT_CSS_LINE.length));
|
|
205
|
+
if (css !== undefined) return css;
|
|
206
|
+
if (!clientCssMissing) {
|
|
207
|
+
clientCssMissing = true;
|
|
208
|
+
const unread = line === undefined ? 'has no line that starts with' : 'has no JSON string after';
|
|
209
|
+
console.warn(
|
|
210
|
+
`netjes: Vite's client module for ${node.url} ${unread} "${CLIENT_CSS_LINE}", so development pages inline the ` +
|
|
211
|
+
"SSR build of their CSS: it can differ from the browser's build, which Vite's client puts in its place once " +
|
|
212
|
+
"the page's scripts run. Logged once, for the first such file.",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const inline = await server.ssrLoadModule(`${node.url}?inline`);
|
|
216
|
+
return String(inline.default);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {string} json
|
|
221
|
+
* @returns {string | undefined} The string `json` holds, or undefined when it holds no string.
|
|
222
|
+
*/
|
|
223
|
+
function jsonString(json) {
|
|
224
|
+
try {
|
|
225
|
+
const value = JSON.parse(json);
|
|
226
|
+
return typeof value === 'string' ? value : undefined;
|
|
227
|
+
} catch {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
168
232
|
/**
|
|
169
233
|
* Page.Defer values per render, as .NET posts them to /resolve. Values may arrive before the render asks for them;
|
|
170
234
|
* entries nobody claims are dropped after the stream timeout.
|
|
@@ -240,7 +304,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
240
304
|
const { renderId, id, value, error } = JSON.parse(await readBody(req));
|
|
241
305
|
if (typeof renderId !== 'string' || typeof id !== 'string') throw new Error('renderId and id are required');
|
|
242
306
|
const pending = pendingValue(renderId, id);
|
|
243
|
-
|
|
307
|
+
// An empty error is a failure whose message .NET withheld (error details are off): the render gets a stand-in
|
|
308
|
+
// marked as one, as the browser does (src/errors.ts), and hands the browser an empty error again.
|
|
309
|
+
if (error === '') pending.reject(Object.assign(new Error('Something went wrong'), { withheld: true }));
|
|
310
|
+
else if (typeof error === 'string') pending.reject(new Error(error));
|
|
244
311
|
else pending.resolve(value);
|
|
245
312
|
res.writeHead(204).end();
|
|
246
313
|
} catch (error) {
|
package/skills/netjes/SKILL.md
CHANGED
|
@@ -13,13 +13,17 @@ Netjes renders React pages from ASP.NET Core. C# receives every request and owns
|
|
|
13
13
|
- `Features/<Feature>/*.cs`: the C# side of a feature, mapped by an extension method that `Program.cs` calls before `app.MapPages()`. Loaders and actions: [references/data-and-actions.md](references/data-and-actions.md).
|
|
14
14
|
- `generated/netjes.ts`: zod schemas and typed references generated from the C# types and the page files. Read it to see what a page receives; never edit it.
|
|
15
15
|
- `components/`: React components and their tests. Testing: [references/testing.md](references/testing.md).
|
|
16
|
+
- `tests/<App>.Tests/`: the C# tests (xUnit v3 and `WebApplicationFactory`), where the app has them, as an app made with `netjes new` does.
|
|
17
|
+
- `e2e/`: the Playwright specs, which `npm run test:e2e` runs.
|
|
18
|
+
- The root folders `tests/` and `e2e/` are outside the app's build (`DefaultItemExcludes` in a new app's project file), so the app's own C# does not go there.
|
|
19
|
+
- `scripts/`: code that runs only in Node. In the TypeScript configs `netjes new` writes, `tsconfig.app.json` checks the app's code without Node's types, so a component that reads `process` or imports `node:*` fails `npm run typecheck`; `tsconfig.node.json` checks `scripts/`, `e2e/`, `vite.config.ts` and `playwright.config.ts` with them. Do not add `node` to the `types` of `tsconfig.app.json`.
|
|
16
20
|
|
|
17
21
|
## The loop for any change
|
|
18
22
|
|
|
19
|
-
1. Write the failing test first: a Vitest component test, or a C# test for loader and action logic.
|
|
23
|
+
1. Write the failing test first: a Vitest component test, or a C# test for loader and action logic (in `tests/<App>.Tests`, where a new app has its test project).
|
|
20
24
|
2. Change the C# record, loader or action, then run `npx netjes generate` so `generated/netjes.ts` matches (a running `npm run dev` rewrites it when it restarts).
|
|
21
|
-
3. Use the generated names in React: `pages['/pattern']`, `layouts['app/folder']`, `serverActions['name']`, `api['GET /api/...']`, `route('/pattern', params)`.
|
|
22
|
-
4. Check: `npm test`, `npm run typecheck`, `dotnet build` (warnings are errors), `npx netjes generate --check`, and `npm run routes` for route warnings.
|
|
25
|
+
3. Use the generated names in React: `pages['/pattern']`, `layouts['app/folder']`, `serverActions['name']`, `api['GET /api/...']`, `route('/pattern', params)`, `endpointUrl('GET /api/...', params)`.
|
|
26
|
+
4. Check: `npm test`, `npm run typecheck`, `dotnet build` (warnings are errors), `npx netjes generate --check`, and `npm run routes` for route warnings. Where the app has them, which an app made with `netjes new` does: `dotnet test tests/<App>.Tests` for the C# tests, and `npm run test:e2e` for a change to a page or a form.
|
|
23
27
|
|
|
24
28
|
## A page with data, end to end
|
|
25
29
|
|
|
@@ -65,11 +69,12 @@ export default definePage(pages['/blog/[slug]'], function Post({ title, body })
|
|
|
65
69
|
|
|
66
70
|
- Decisions live in C#: data access, validation, authorization (`.RequireAuthorization()`, `Page.Forbidden()`), redirects, caching. React renders what it is sent.
|
|
67
71
|
- Loaders and actions return Netjes results (`Page.Props(...)`, `Page.NotFound()`, `ServerAction.Ok(...)`), so their types reach `generated/netjes.ts`. Use records for everything that crosses to the browser.
|
|
68
|
-
- zod at every boundary: `definePage`/`defineLayout` with the generated schemas, `useSearchParams(schema)` and `useParams(schema)` (schemas at module level), `api[...].response.parse(...)` for fetched JSON, `usePublicConfig(schema)`. No `any`, no casts of server data.
|
|
72
|
+
- zod at every boundary: `definePage`/`defineLayout` with the generated schemas, `useSearchParams(schema)` and `useParams(schema)` (schemas at module level), `api[...].response.parse(...)` for fetched JSON (from `useHref(endpointUrl(...))`, never a hand-built URL), `usePublicConfig(schema)`, `Href.parse(...)` or `isHref(...)` for URLs from a CMS. A link C# sends to one of the app's pages is a `Href` in the record (`new Href("/about")`), generated as the `Href` schema, so it reaches `<Link href>` checked; a link to an endpoint or a file stays a `string`. No `any`, no casts of server data.
|
|
69
73
|
- Server state goes through TanStack Query, seeded from the loader with `.WithQuery(key, data)`; no `useEffect` fetching.
|
|
70
74
|
- Internal links use `<Link>` and `route()`; forms use `<Form>` with `useAction`, and must work without JavaScript.
|
|
75
|
+
- Give each page a title of its own (`metadata`, or `PageMetadata` from C#), and keep the "Skip to content" link the first thing in `app/layout.tsx` that takes the focus: after a client navigation the router announces the page by its title and starts the focus over at the top ([references/routing.md](references/routing.md)).
|
|
71
76
|
- Never edit `generated/netjes.ts`; regenerate it and commit it with the change.
|
|
72
|
-
- Anything in `Netjes:PublicConfig` or shared props is sent to the browser.
|
|
77
|
+
- Anything in `Netjes:PublicConfig` or shared props is sent to the browser. So is the route of each endpoint with an `endpointUrl` key, one mapped only in Development included, once any component calls `endpointUrl`: keep secrets out of routes, and authorize on the endpoint.
|
|
73
78
|
- Caching, streaming and static export have sharp edges: read [references/caching-and-streaming.md](references/caching-and-streaming.md) before using `.CachePage`, `Page.Defer` or `netjes export`.
|
|
74
79
|
- Shipping: [references/deployment.md](references/deployment.md).
|
|
75
80
|
|
|
@@ -79,6 +84,8 @@ export default definePage(pages['/blog/[slug]'], function Post({ title, body })
|
|
|
79
84
|
|---|---|
|
|
80
85
|
| `npm run dev` | `dotnet watch` with Vite HMR on http://localhost:5080 |
|
|
81
86
|
| `npm test`, `npm run typecheck` | Vitest; strict TypeScript |
|
|
87
|
+
| `dotnet test tests/<App>.Tests` | The C# tests, in an app that has that project; no Node and no client build |
|
|
88
|
+
| `npm run test:e2e` | Playwright, in an app that has that script; `npx playwright install chromium` once first |
|
|
82
89
|
| `npx netjes generate [--check]` | Rewrite `generated/netjes.ts`; `--check` fails when it is stale |
|
|
83
90
|
| `npm run routes` (`netjes routes [--json]`) | The route table and route warnings, as ASP.NET Core sees them |
|
|
84
91
|
| `npx netjes build` | Types, then the client and server bundles |
|
|
@@ -88,6 +95,6 @@ export default definePage(pages['/blog/[slug]'], function Post({ title, body })
|
|
|
88
95
|
|
|
89
96
|
## More
|
|
90
97
|
|
|
91
|
-
The guides go further than these references: [routing](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/routing.md), [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), [
|
|
98
|
+
The guides go further than these references: [routing](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/routing.md), [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), [rendering and streaming](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/rendering-and-streaming.md), [caching](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/caching.md), [images](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/images.md), [metadata](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/metadata.md), [security](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/security.md), [testing](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/testing.md) and [deployment](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/deployment.md), with the [React API](https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/react-api.md), the [C# API](https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/csharp-api.md) and the [options](https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/options.md) as reference.
|
|
92
99
|
|
|
93
100
|
The same docs as plain text: https://raw.githubusercontent.com/DennisVerhoeven/netjes/main/llms.txt (an index) and https://raw.githubusercontent.com/DennisVerhoeven/netjes/main/llms-full.txt (everything in one file). They follow the main branch; the installed version's sources and types are in `node_modules/netjes/`.
|
|
@@ -17,15 +17,43 @@ app.MapPageLoader("/blog/[slug]", (string slug, PostStore posts) => ...)
|
|
|
17
17
|
|
|
18
18
|
- A stored page is served to every visitor, shared props included. Cache only pages that are the same for everyone: their loaders and shared props must not depend on cookies or other per-visitor state (Netjes warns once per page when a cached render reads `Request.Cookies`). Signed-in users and requests with an `Authorization` header bypass the cache.
|
|
19
19
|
- Evict from an action with `ServerAction.Ok().Revalidate("posts")` or `.RevalidatePath("/blog/hello")`, and from anywhere else (a webhook, a background job) with `IPageCache.RevalidateTagAsync(tag)` / `RevalidatePathAsync(path)`.
|
|
20
|
+
- Each query string as sent is an entry of its own, so `?utm_source=...` and a made-up `?&&` each store another copy of the page, and `SetVaryByQuery` cannot narrow that. `.CacheQueryKeys("page")` names the keys a page reads, and `.CacheQueryKeys()` says it reads none: a request with another key, an empty segment (`?page=2&`) or a segment without a name renders each time and is never stored; the page rendered for it goes out `no-store, private` unless the page sets its own `Cache-Control`, and keeps its URL as sent. Do not leave out a key that a loader or `useSearchParams()` reads: those requests still work but are not cached, and nothing warns. Weigh it on a page with many visits through tagged links, which then renders for each of them. It does nothing on an endpoint that is neither a page nor under `CachePage`, so it can stay on a group that mixes pages and APIs.
|
|
20
21
|
- Cached pages carry no antiforgery token: forms on them fetch one first, so they need JavaScript.
|
|
21
|
-
- A Content-Security-Policy is off unless the app sets one. A document with a CSP nonce is never stored, so cached pages under a policy need a hash-based one (`ContentSecurityPolicy` without `{nonce}`), and under such a policy a page must not stream.
|
|
22
|
+
- A Content-Security-Policy is off unless the app sets one. A document with a CSP nonce is never stored, so cached pages under a policy need a hash-based one (`ContentSecurityPolicy` without `{nonce}`), and under such a policy a page must not stream. Build it from `NetjesContentSecurityPolicy.ScriptHashes`, the hashes of Netjes's inline scripts, never from a pasted hash, which a Netjes upgrade can make stale: `$"script-src 'self' {NetjesContentSecurityPolicy.ScriptHashes}; object-src 'none'; base-uri 'self'"`. Keep `'self'` for the built app's module scripts, and leave out `'strict-dynamic'`, which needs a nonce. In the Development environment the module scripts come from the Vite dev server, so add its origin there, and only there, from a variable:
|
|
23
|
+
|
|
24
|
+
```csharp
|
|
25
|
+
builder.Services.AddNetjes(options =>
|
|
26
|
+
{
|
|
27
|
+
var devServer = builder.Environment.IsDevelopment() ? $" http://localhost:{options.ViteDevPort}" : "";
|
|
28
|
+
options.ContentSecurityPolicy =
|
|
29
|
+
$"script-src 'self'{devServer} {NetjesContentSecurityPolicy.ScriptHashes}; object-src 'none'; base-uri 'self'";
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
When browsers reach the dev server at another address, the one set in `NETJES_VITE_ORIGIN`, list that origin instead. In Development a policy also needs these, or set it outside Development only:
|
|
34
|
+
|
|
35
|
+
- With `connect-src`, or `default-src` without `connect-src`: Vite's HMR socket, `ws://` (`wss://` for an https origin) and the dev server's host and port, such as `ws://localhost:{options.ViteDevPort}`, which neither `'self'` nor a nonce covers.
|
|
36
|
+
- With `style-src-elem` or `style-src`, or `default-src` without them: the document's nonce or `'unsafe-inline'`, for the CSS that Development inlines.
|
|
37
|
+
- With `img-src` or `font-src`, or `default-src` without them: the dev server's origin, for the images and fonts Vite serves.
|
|
38
|
+
|
|
39
|
+
Neither the nonce-based nor the hash-based policy needs `'unsafe-eval'`: zod parses without compiled code in the browser, from zod 4.4.0 on. Under a hash-based policy, give forms server actions through `useAction` and `<Form action={formAction}>`: any other function action, and any function on `<SubmitButton formAction>`, makes React add an inline script whose hash follows the React version and is not in `ScriptHashes`. In JavaScript, such as a static host's headers for exported pages, the same value is `CSP_SCRIPT_HASHES` from `netjes`. A nonce-based policy that the app writes itself takes its nonce from `HttpContext.GetCspNonce()`, the one Netjes puts on the document. Netjes logs a warning, once per policy, when the policy on a document refuses Netjes's bootstrap or module scripts or, in Development, the dev server. It does not check the inline code of a `<Script>`, so list that hash yourself ([routing.md](routing.md)).
|
|
22
40
|
- Keys include the host and the full URL, case included. In production pin `AllowedHosts` and set the output cache's `SizeLimit`.
|
|
23
41
|
- Pages the output cache does not store go out `Cache-Control: no-store, private`, for visitors and signed-in users alike, unless the page sets its own header.
|
|
42
|
+
- In the Development environment pages are never output-cached: each request renders the page, nothing is stored, and an output-cached page goes out `no-store, private` unless it sets its own `Cache-Control`, so `UseOutputCache()` can stay in every environment, with no Development guard. Cache slow or billed upstream data in the service that loads it (`IMemoryCache`, `HybridCache`). Try caching on the built app (`npx netjes build && npx netjes start`); a `WebApplicationFactory` test of it runs in the `Testing` environment with `Netjes:Mode=Development`.
|
|
24
43
|
- Draft pages and draft redirects (a loader's `Page.Redirect` in draft mode), the client-rendered fallback, the page for a failed form post, action answers and antiforgery tokens always go out `no-store`, and without the headers a CDN reads ahead of `Cache-Control` (`CDN-Cache-Control`, any other `*-Cache-Control`, `Surrogate-Control`, `Edge-Control`, `X-Accel-Expires`), whatever the page set.
|
|
25
44
|
|
|
45
|
+
## Endpoints that are not pages
|
|
46
|
+
|
|
47
|
+
JSON APIs, text files and webhooks are not pages, unless `CachePage` is put on them or on their group. The page rules do not apply to their cache entries.
|
|
48
|
+
|
|
49
|
+
- Netjes adds no `Cache-Control` to their answers, including the 400 that minimal API binding answers, and none under `CachePage` either (the page rules decide entries, not headers): the handler's own goes out as set, and the output cache sends the headers set before the body is written again with every stored answer, with `Age`, and adds none. Netjes's own endpoints: `/_netjes/image` (`public, max-age=` the image `CacheDuration` with an image or a 304, none with an error), `MapSitemap` (`public, max-age=` its `cacheFor`), action answers and `/_netjes/antiforgery` (`no-store`); `MapRobotsTxt` and `MapLocaleRedirect` send none.
|
|
50
|
+
- ASP.NET Core's keys apply. The path in any case shares one entry, route values included (`/codes/Ab` and `/codes/aB`): `AddOutputCache(o => o.UseCaseSensitivePaths = true)` gives each spelling its own entry, app-wide, and `.CacheOutput(p => p.With(c => c.HttpContext.Request.Path.Value == "/llms.txt"))` stores that one spelling only. Every query key varies the entry: `SetVaryByQuery([])` keeps one entry whatever the query (there is no call without arguments), `SetVaryByQuery("page")` varies on the keys it lists. Both are safe only when the answer does not depend on the keys they leave out and its body does not repeat the URL.
|
|
51
|
+
- Only the answer to a `GET` or `HEAD` without an `Authorization` header or a signed-in user is stored, when it is a 200 that sets no cookie. `RevalidatePath` never evicts these entries: give the policy a tag (`.CacheOutput(p => p.Tag("docs"))`) and use `.Revalidate("docs")` or `IPageCache.RevalidateTagAsync("docs")`.
|
|
52
|
+
- They stay output-cached in the Development environment, where only pages are not. Draft mode bypasses them, the image endpoint apart.
|
|
53
|
+
|
|
26
54
|
## TanStack Query
|
|
27
55
|
|
|
28
|
-
Netjes creates one `QueryClient` per server render and per page load. `.WithQuery(key, data)` on a loader result seeds it, so `useQuery` with that key has data on the first render and fetches nothing until the data is stale (default `staleTime` one minute). Data the browser fetched more recently is kept. After an action, `.Invalidate(key)` refetches and `.WithQuery(key, data)` sets the new value. Configure the client with `netjes({ query: { defaultOptions } })` in `vite.config.ts`.
|
|
56
|
+
Netjes creates one `QueryClient` per server render and per page load. `.WithQuery(key, data)` on a loader result seeds it, so `useQuery` with that key has data on the first render and fetches nothing until the data is stale (default `staleTime` one minute). Data the browser fetched more recently is kept. After an action, `.Invalidate(key)` refetches and `.WithQuery(key, data)` sets the new value. A fresh query is not fetched again on a mount or a window focus, whatever `Cache-Control` its endpoint sent; the `fetch` of a refetch can be answered by the browser's HTTP cache, and `staleTime: 0` leaves a query to HTTP caching. Configure the client with `netjes({ query: { defaultOptions } })` in `vite.config.ts`.
|
|
29
57
|
|
|
30
58
|
## Streaming slow data
|
|
31
59
|
|
|
@@ -52,14 +80,19 @@ function ActivityFeed({ activity }: { activity: PromiseLike<ActivityItem[]> }) {
|
|
|
52
80
|
- A deferred task runs alongside the rest of the request: give it its own `DbContext`, never the request's, and pass `http.RequestAborted`. Either create one from `IDbContextFactory<T>`, as above, or use the overload that runs the work in its own DI scope, started once the page's payload is written: `Page.Defer(services => services.GetRequiredService<AppDb>().ActivityAsync(id, http.RequestAborted))`.
|
|
53
81
|
- Without JavaScript, a fallback that is still pending stays. Crawlers (`CrawlerUserAgents`), apps with `StreamDocuments = false` and `netjes export` get the complete document.
|
|
54
82
|
- A boundary that is ready when the shell goes out is written in place, so it shows without JavaScript too.
|
|
83
|
+
- A value that fails rejects, so `use()` throws to the nearest `error.tsx`: with error details off its message is a stand-in marked `withheld`, and a cancelled task fails with "The value was cancelled".
|
|
55
84
|
|
|
56
85
|
## Static export
|
|
57
86
|
|
|
58
|
-
`npx netjes export [dir]` builds, runs the app once in Production and writes every page as static HTML (default `out/`). Dynamic pages need `.StaticParams(...)` (`.StaticParams<PostStore>(s => s.All.Select(p => new { slug = p.Slug }))`, arrays for catch-all parameters); pages that need a server take `.ExcludeFromExport()`. The export fails before writing anything for a dynamic page without static params, a page with a page action, or a page that requires authorization. Exported pages navigate with full page loads and carry no antiforgery token.
|
|
87
|
+
`npx netjes export [dir]` builds, runs the app once in Production and writes every page as static HTML (default `out/`). `dir` must be inside the app: `netjes export` refuses any other folder before it builds. Dynamic pages need `.StaticParams(...)` (`.StaticParams<PostStore>(s => s.All.Select(p => new { slug = p.Slug }))`, arrays for catch-all parameters); pages that need a server take `.ExcludeFromExport()`. The export fails before writing anything for a dynamic page without static params, a page with a page action, or a page that requires authorization. Exported pages navigate with full page loads and carry no antiforgery token.
|
|
88
|
+
|
|
89
|
+
It also writes `sitemap.xml` and `robots.txt` when the app maps `MapSitemap` and `MapRobotsTxt`, the sitemap listing only exported pages (none marked `.ExcludeFromExport()`), and any other `GET` endpoint marked `.IncludeInExport()` as a file at its URL: `app.MapGet("/llms.txt", ...).IncludeInExport()` writes `out/llms.txt`, the body only. The export requests it with no query string, and any status but 200, a redirect included, fails the export; one that reads the query string is warned about in `Netjes.Export.QueryString`, and one that requires a value there fails the export. Such an endpoint needs a URL with a file extension and no route parameters, and must allow anonymous requests; a file in `public/` with its path (a `public/robots.txt` next to `MapRobotsTxt`) fails the export. `.ExcludeFromExport()` on the Map call leaves a file out, and wins over `.IncludeInExport()`; when you leave `MapSitemap` out, leave `MapRobotsTxt` out too, since `robots.txt` always names the sitemap. Both files use the `siteUrl` of the environment the export runs in, Production unless `ASPNETCORE_ENVIRONMENT` is set, so set the static host's URL for the export (`SiteUrl=https://www.example.com npx netjes export` when the app reads `app.Configuration["SiteUrl"]`); a loopback one, such as a `http://localhost:5080` fallback, is written and warned about.
|
|
90
|
+
|
|
91
|
+
The export requests every URL without a query string, and a static host serves each file for every query string. A page whose loader, or a layout loader above it, reads the query string is still exported, as the page without one, and the export logs a warning in `Netjes.Export.QueryString`. To export a page for each value, make it a route parameter with `.StaticParams(...)` (`/blog/tags/[tag]`, not `/blog?tag=`); to keep the page on a server, exclude it. A loader parameter with no default value and a type that is not nullable (`int page`, `string q`) requires the value: without one the page answers 400 and the export fails, so give it a default value or a nullable type (`int page = 1`, `string? q`) to export the page without one. On an exported page `useSearchParams()` sees no query string either, whatever the address bar shows: read `window.location.search` in an effect there, and parse it with the same schema.
|
|
59
92
|
|
|
60
93
|
## Sitemap and robots
|
|
61
94
|
|
|
62
|
-
`app.MapSitemap(siteUrl)` serves `/sitemap.xml` from the route table (dynamic pages through `.StaticParams`, pages that need authorization or call `.ExcludeFromSitemap()` left out) and `app.MapRobotsTxt(siteUrl, disallow)` serves `/robots.txt`.
|
|
95
|
+
`app.MapSitemap(siteUrl)` serves `/sitemap.xml` from the route table (dynamic pages through `.StaticParams`, pages that need authorization or call `.ExcludeFromSitemap()` left out) and `app.MapRobotsTxt(siteUrl, disallow)` serves `/robots.txt`. `netjes export` writes both ([Static export](#static-export)).
|
|
63
96
|
|
|
64
97
|
Leave `/_netjes/` out of `disallow`: `<Image>` serves every image through `/_netjes/image`, so a crawler that may not fetch it cannot index your images. The other endpoints there take posts or hand out an antiforgery token, which a crawler has no use for. Under a base path, `disallow` paths are written as given, so include the base path in them. See [Metadata](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/metadata.md).
|
|
65
98
|
|
|
@@ -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,7 +68,7 @@ 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.
|
|
@@ -83,15 +84,18 @@ app.MapGet("/api/projects/{slug}/stats", async Task<Results<Ok<ProjectStats>, No
|
|
|
83
84
|
```
|
|
84
85
|
|
|
85
86
|
```ts
|
|
86
|
-
async function fetchStats(
|
|
87
|
-
const response = await fetch(
|
|
87
|
+
async function fetchStats(url: string) {
|
|
88
|
+
const response = await fetch(url);
|
|
88
89
|
if (!response.ok) throw new Error(`Stats request failed with ${response.status}`);
|
|
89
90
|
return api['GET /api/projects/{slug}/stats'].response.parse(await response.json());
|
|
90
91
|
}
|
|
91
|
-
const
|
|
92
|
+
const url = useHref(endpointUrl('GET /api/projects/{slug}/stats', { slug }));
|
|
93
|
+
const stats = useQuery({ queryKey: ['projects', slug, 'stats'], queryFn: () => fetchStats(url) });
|
|
92
94
|
```
|
|
93
95
|
|
|
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).
|
|
96
|
+
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).
|
|
97
|
+
|
|
98
|
+
`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
99
|
|
|
96
100
|
## More
|
|
97
101
|
|
|
@@ -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
|
|
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,10 +7,10 @@ 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`)
|
|
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
14
|
| `not-found.tsx` | Rendered for `Page.NotFound()` from a loader below it, and the root one for unknown URLs. |
|
|
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. |
|
|
@@ -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]`.
|
|
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
|
|
41
|
-
- `
|
|
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,9 +57,10 @@ 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
|
|
|
@@ -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
|
|
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
|
-
- `
|
|
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).
|
|
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`
|
|
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
|
-
/**
|
|
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
|
-
|
|
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);
|