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.
Files changed (154) hide show
  1. package/README.md +11 -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 +7 -2
  6. package/cli/scaffold.mjs +31 -4
  7. package/dist/actions.d.ts +7 -2
  8. package/dist/actions.d.ts.map +1 -1
  9. package/dist/actions.js +9 -3
  10. package/dist/actions.js.map +1 -1
  11. package/dist/client-entry.d.ts +1 -0
  12. package/dist/client-entry.d.ts.map +1 -1
  13. package/dist/client-entry.js +43 -2
  14. package/dist/client-entry.js.map +1 -1
  15. package/dist/deferred.d.ts +11 -0
  16. package/dist/deferred.d.ts.map +1 -1
  17. package/dist/deferred.js +15 -1
  18. package/dist/deferred.js.map +1 -1
  19. package/dist/document.d.ts +4 -3
  20. package/dist/document.d.ts.map +1 -1
  21. package/dist/document.js +15 -5
  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 +24 -3
  32. package/dist/href.d.ts.map +1 -1
  33. package/dist/href.js +117 -7
  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 +2 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +2 -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/navigation-focus.d.ts +38 -0
  54. package/dist/navigation-focus.d.ts.map +1 -0
  55. package/dist/navigation-focus.js +71 -0
  56. package/dist/navigation-focus.js.map +1 -0
  57. package/dist/payload.d.ts +2 -0
  58. package/dist/payload.d.ts.map +1 -1
  59. package/dist/payload.js +2 -0
  60. package/dist/payload.js.map +1 -1
  61. package/dist/query.d.ts +5 -2
  62. package/dist/query.d.ts.map +1 -1
  63. package/dist/query.js +11 -8
  64. package/dist/query.js.map +1 -1
  65. package/dist/render-document.d.ts +60 -0
  66. package/dist/render-document.d.ts.map +1 -0
  67. package/dist/render-document.js +330 -0
  68. package/dist/render-document.js.map +1 -0
  69. package/dist/route-announcer.d.ts +18 -0
  70. package/dist/route-announcer.d.ts.map +1 -0
  71. package/dist/route-announcer.js +58 -0
  72. package/dist/route-announcer.js.map +1 -0
  73. package/dist/route-tree.d.ts +6 -2
  74. package/dist/route-tree.d.ts.map +1 -1
  75. package/dist/route-tree.js +17 -7
  76. package/dist/route-tree.js.map +1 -1
  77. package/dist/router.d.ts +28 -5
  78. package/dist/router.d.ts.map +1 -1
  79. package/dist/router.js +71 -26
  80. package/dist/router.js.map +1 -1
  81. package/dist/script.d.ts +28 -5
  82. package/dist/script.d.ts.map +1 -1
  83. package/dist/script.js +192 -21
  84. package/dist/script.js.map +1 -1
  85. package/dist/server-render.d.ts.map +1 -1
  86. package/dist/server-render.js +4 -1
  87. package/dist/server-render.js.map +1 -1
  88. package/dist/testing-vitest.d.ts +8 -0
  89. package/dist/testing-vitest.d.ts.map +1 -0
  90. package/dist/testing-vitest.js +26 -0
  91. package/dist/testing-vitest.js.map +1 -0
  92. package/dist/testing.d.ts +11 -1
  93. package/dist/testing.d.ts.map +1 -1
  94. package/dist/testing.js +17 -3
  95. package/dist/testing.js.map +1 -1
  96. package/dist/zod-jitless.d.ts +2 -0
  97. package/dist/zod-jitless.d.ts.map +1 -0
  98. package/dist/zod-jitless.js +4 -0
  99. package/dist/zod-jitless.js.map +1 -0
  100. package/package.json +1 -1
  101. package/sidecar/server.mjs +73 -6
  102. package/skills/netjes/SKILL.md +13 -6
  103. package/skills/netjes/references/caching-and-streaming.md +37 -4
  104. package/skills/netjes/references/data-and-actions.md +9 -5
  105. package/skills/netjes/references/deployment.md +7 -3
  106. package/skills/netjes/references/routing.md +18 -6
  107. package/skills/netjes/references/testing.md +23 -7
  108. package/src/actions.tsx +9 -3
  109. package/src/client-entry.tsx +43 -2
  110. package/src/deferred.ts +14 -1
  111. package/src/document.tsx +28 -7
  112. package/src/errors.ts +12 -0
  113. package/src/form.tsx +10 -7
  114. package/src/href.ts +123 -8
  115. package/src/image.tsx +3 -3
  116. package/src/index.ts +2 -1
  117. package/src/link.tsx +47 -13
  118. package/src/metadata.tsx +19 -6
  119. package/src/modules.ts +15 -2
  120. package/src/navigation-focus.ts +75 -0
  121. package/src/payload.ts +2 -0
  122. package/src/query.tsx +14 -8
  123. package/src/render-document.tsx +386 -0
  124. package/src/route-announcer.tsx +69 -0
  125. package/src/route-tree.tsx +17 -6
  126. package/src/router.tsx +116 -31
  127. package/src/script.tsx +214 -23
  128. package/src/server-render.tsx +4 -1
  129. package/src/testing-vitest.ts +37 -0
  130. package/src/testing.tsx +31 -5
  131. package/src/virtual.d.ts +2 -0
  132. package/src/zod-jitless.ts +15 -0
  133. package/template/.dockerignore +3 -0
  134. package/template/Dockerfile +11 -1
  135. package/template/README.md +11 -7
  136. package/template/_AGENTS.md +36 -8
  137. package/template/__NAME__.csproj +2 -0
  138. package/template/app/document.test.ts +29 -0
  139. package/template/app/error.tsx +3 -2
  140. package/template/app/layout.tsx +10 -1
  141. package/template/docker-compose.override.yml +4 -3
  142. package/template/docker-compose.prod.yml +2 -1
  143. package/template/e2e/home.spec.ts +32 -0
  144. package/template/generated/netjes.ts +43 -14
  145. package/template/package.json +4 -1
  146. package/template/playwright.config.ts +36 -0
  147. package/template/tests/__NAME__.Tests/AppFactory.cs +31 -0
  148. package/template/tests/__NAME__.Tests/HomePageTests.cs +55 -0
  149. package/template/tests/__NAME__.Tests/__NAME__.Tests.csproj +30 -0
  150. package/template/tsconfig.app.json +20 -0
  151. package/template/tsconfig.json +2 -17
  152. package/template/tsconfig.node.json +8 -0
  153. package/vite/plugin.d.ts +9 -1
  154. package/vite/plugin.mjs +17 -5
@@ -2,6 +2,7 @@ import { type Readable, Transform, Writable } from 'node:stream';
2
2
  import type { ReactNode } from 'react';
3
3
  import { renderToPipeableStream } from 'react-dom/server';
4
4
  import { type DeferredSource, deferredScript, revivePayload } from './deferred.ts';
5
+ import { isWithheld } from './errors.ts';
5
6
  import { type ModuleMap, createModuleLoader } from './modules.ts';
6
7
  import { type InitialPayload, parseInitialPayload } from './payload.ts';
7
8
  import { RenderCleanupContext } from './render-cleanup.ts';
@@ -75,7 +76,9 @@ function renderToStream(
75
76
  for (const id of deferredIds) {
76
77
  source(id).then(
77
78
  (value) => send(deferredScript(id, value, undefined, nonce)),
78
- (error: unknown) => send(deferredScript(id, null, error instanceof Error ? error.message : String(error), nonce)),
79
+ // A withheld message goes on as an empty one, which the browser replaces with its own stand-in.
80
+ (error: unknown) =>
81
+ send(deferredScript(id, null, isWithheld(error) ? '' : error instanceof Error ? error.message : String(error), nonce)),
79
82
  );
80
83
  }
81
84
  let started = false;
@@ -0,0 +1,37 @@
1
+ // Registered by netjes() as a Vitest setup file in every app, after the app's own: what the test harness needs from the
2
+ // runner. Named by path, not exported.
3
+ import type { DefaultOptions } from '@tanstack/react-query';
4
+ import { inject, vi } from 'vitest';
5
+
6
+ declare module 'vitest' {
7
+ interface ProvidedContext {
8
+ /** The app's `netjes({ query: { defaultOptions } })`, which netjes() provides to its tests. */
9
+ 'netjes:query': DefaultOptions;
10
+ }
11
+ }
12
+
13
+ // The app's query defaults, for createTestApp. They wait under a global symbol rather than in a module: the app's
14
+ // imports of netjes/testing need not share module instances with this file, which Vitest loads by its path.
15
+ const appQueryDefaults: unknown = inject('netjes:query');
16
+ if (typeof appQueryDefaults === 'object' && appQueryDefaults !== null) {
17
+ (globalThis as Record<symbol, unknown>)[Symbol.for('netjes.testing.queryDefaults')] = appQueryDefaults;
18
+ }
19
+
20
+ // Testing Library advances fake timers only through Jest's global: without one, waitFor, findBy and every user-event
21
+ // call wait on a faked setTimeout that nothing advances, so they never return. While Vitest's timers are fake, `jest`
22
+ // is a bridge to them; otherwise it is undefined, so code that looks for Jest finds none. A jest global that was
23
+ // already there is left alone, and one a test assigns replaces the bridge.
24
+ if (!('jest' in globalThis)) {
25
+ const bridge = {
26
+ advanceTimersByTime(ms: number) {
27
+ vi.advanceTimersByTime(ms);
28
+ },
29
+ };
30
+ Object.defineProperty(globalThis, 'jest', {
31
+ configurable: true,
32
+ get: () => (vi.isFakeTimers() ? bridge : undefined),
33
+ set(value: unknown) {
34
+ Object.defineProperty(globalThis, 'jest', { configurable: true, enumerable: true, writable: true, value });
35
+ },
36
+ });
37
+ }
package/src/testing.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  // Test harness for components and pages that use Netjes: `render(<Page />, { wrapper: createTestApp().Wrapper })`.
2
2
  // The router is a fake: it records what the component asked for and moves to the new URL, but loads nothing.
3
+ // renderDocument renders a page's whole document instead, as the server would send it.
3
4
  import { type DefaultOptions, QueryClient, QueryClientProvider, hydrate } from '@tanstack/react-query';
4
5
  import { type ReactNode, useLayoutEffect, useMemo, useState } from 'react';
5
6
  import { type ActionClient, type ActionState, setActionClient } from './actions.tsx';
@@ -8,6 +9,8 @@ import { PublicConfigProvider } from './public-config.tsx';
8
9
  import { dehydratedStateOf, mergeOptions } from './query.tsx';
9
10
  import { type InternalRouter, type NavigationState, RouterContexts, type SubmitBody } from './router.tsx';
10
11
 
12
+ export { type RenderDocumentOptions, inlineScriptHashes, renderDocument } from './render-document.tsx';
13
+
11
14
  /** What the fake app of `createTestApp` reports to the components under test. */
12
15
  export type TestAppOptions = {
13
16
  /** The current URL. Default `/`. */
@@ -25,7 +28,10 @@ export type TestAppOptions = {
25
28
  shared?: unknown;
26
29
  /** Seeded into the QueryClient the way `PageResult.WithQuery` seeds it, as of now. */
27
30
  queries?: ReadonlyArray<{ key: readonly unknown[]; data: unknown }>;
28
- /** Like `netjes({ query: { defaultOptions } })`; the harness also turns retries off. */
31
+ /**
32
+ * Like `netjes({ query: { defaultOptions } })`, merged over the harness's own: failed queries and mutations are tried
33
+ * as often as in the app, with no wait between the tries, and no query's data is garbage-collected.
34
+ */
29
35
  defaultOptions?: DefaultOptions;
30
36
  /** What `useNavigation()` returns. Default idle. */
31
37
  navigation?: NavigationState;
@@ -45,6 +51,12 @@ export type TestActionAnswer = ActionState<unknown> & {
45
51
  invalidate?: ReadonlyArray<readonly unknown[]>;
46
52
  /** After a failure: the submitted fields the state's `values` keep, as the server names them (the action's declared, non-private fields). Default: all. */
47
53
  fields?: readonly string[];
54
+ /**
55
+ * After a failure other than a 422: the message the server would send, which the `ServerActionError` then carries, as
56
+ * the server does for a 4xx (`Forbidden`) and for a 5xx with error details on. Without one, the call fails as a 5xx
57
+ * does with error details off: the error's message is Netjes's own and `withheld` is true.
58
+ */
59
+ message?: string;
48
60
  };
49
61
 
50
62
  /** One thing the component under test did: a navigation, a router call, a form post or a server action call. */
@@ -76,6 +88,20 @@ function RegisterActionClient({ client }: { client: ActionClient }) {
76
88
 
77
89
  const TEST_TOKEN: AntiforgeryToken = { formFieldName: '__RequestVerificationToken', headerName: 'RequestVerificationToken', requestToken: 'test-token' };
78
90
 
91
+ /**
92
+ * The harness's own query defaults, over the app's: a failed query or mutation is tried as often as in the app, but
93
+ * without the wait between the tries (TanStack Query's 1, 2 and 4 seconds would outlast a test), and data stays in the
94
+ * client for the whole test.
95
+ */
96
+ const HARNESS_DEFAULTS: DefaultOptions = { queries: { retryDelay: 0, gcTime: Number.POSITIVE_INFINITY }, mutations: { retryDelay: 0 } };
97
+
98
+ /** The app's `netjes({ query })` defaults, where Netjes's Vitest setup file keeps them (src/testing-vitest.ts). */
99
+ const APP_QUERY_DEFAULTS = Symbol.for('netjes.testing.queryDefaults');
100
+
101
+ function appQueryDefaults(): DefaultOptions | undefined {
102
+ return (globalThis as Record<symbol, DefaultOptions | undefined>)[APP_QUERY_DEFAULTS];
103
+ }
104
+
79
105
  function dataOf(body: SubmitBody): URLSearchParams | FormData {
80
106
  return body.contentType === null ? body.body : new URLSearchParams(body.body);
81
107
  }
@@ -83,10 +109,9 @@ function dataOf(body: SubmitBody): URLSearchParams | FormData {
83
109
  /** Creates a fake app around the components under test: pass its `Wrapper` to Testing Library's `render`. The router loads nothing; it records what was asked of it. */
84
110
  export function createTestApp(options: TestAppOptions = {}): TestApp {
85
111
  const navigations: TestNavigation[] = [];
86
- const defaults = mergeOptions(options.defaultOptions);
87
- const queryClient =
88
- options.queryClient ??
89
- new QueryClient({ defaultOptions: { ...defaults, queries: { retry: false, gcTime: Number.POSITIVE_INFINITY, ...defaults.queries } } });
112
+ // The Netjes defaults, then the app's, then the harness's, then the test's.
113
+ const defaultOptions = mergeOptions(appQueryDefaults(), HARNESS_DEFAULTS, options.defaultOptions);
114
+ const queryClient = options.queryClient ?? new QueryClient({ defaultOptions });
90
115
  const now = Date.now();
91
116
  const seeded = dehydratedStateOf(options.queries?.map((query) => ({ ...query, updatedAt: now })));
92
117
  if (seeded) hydrate(queryClient, seeded);
@@ -109,6 +134,7 @@ export function createTestApp(options: TestAppOptions = {}): TestApp {
109
134
  status: answer.status,
110
135
  errors: answer.errors,
111
136
  ...(answer.fields ? { fields: [...answer.fields] } : {}),
137
+ ...(answer.message !== undefined ? { message: answer.message } : {}),
112
138
  revalidate: false,
113
139
  };
114
140
  },
package/src/virtual.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  declare module 'virtual:netjes/pages' {
2
2
  /** Every page, layout and other convention file, keyed `/app/...`, as lazy imports. Generated by the netjes Vite plugin. */
3
3
  export const pages: Record<string, () => Promise<unknown>>;
4
+ /** The folder of the page files, as the plugin's `appDir` names it: `app`, or e.g. `src/app`. */
5
+ export const appDir: string;
4
6
  }
5
7
 
6
8
  declare module 'virtual:netjes/query' {
@@ -0,0 +1,15 @@
1
+ // Turns zod's compiled parsers off in the browser (ADR 0036). zod 4.4.0 and newer read this object whenever they make
2
+ // an object schema, whether zod loaded before this ran or after. Without `jitless`, zod tests whether it may make a
3
+ // function from a string, and a browser reports that test as a violation of any Content-Security-Policy whose
4
+ // script-src (or default-src, without a script-src) leaves out 'unsafe-eval', although zod catches the error.
5
+ //
6
+ // It must stay the client entry's first import, so that it runs ahead of the entry's other imports and of the page
7
+ // modules, which make their schemas as they load. (A production build moves the modules the entry shares with pages,
8
+ // such as React and the router, into chunks that run before the entry's own code; none of them makes a zod schema.)
9
+ // It imports nothing, so the entry still loads no zod.
10
+ type ZodGlobals = { __zod_globalConfig?: { jitless?: boolean } };
11
+
12
+ ((globalThis as unknown as ZodGlobals).__zod_globalConfig ??= {}).jitless = true;
13
+
14
+ // A module, not a script, so that the type above stays its own.
15
+ export {};
@@ -5,6 +5,9 @@ publish
5
5
  **/dist
6
6
  **/.vite
7
7
  **/test-results
8
+ **/playwright-report
9
+ tests
10
+ e2e
8
11
  .git
9
12
  **/.env
10
13
  **/.env.*
@@ -44,9 +44,15 @@ COPY . .
44
44
  COPY --from=client /src/dist dist
45
45
  RUN dotnet publish -c Release -o /app --no-restore -p:NetjesSkipClientBuild=true
46
46
 
47
+ # tini, the production image's PID 1 (below): Ubuntu's package, installed in the SDK image the build stage uses. The
48
+ # production stage takes its static binary, which needs no package manager and no library in RUNTIME_IMAGE.
49
+ FROM ${SDK_IMAGE} AS tini
50
+ RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
51
+
47
52
  # Production: ASP.NET Core runtime plus the node binary for the SSR sidecar. Runs as the non-root app user.
48
53
  FROM ${RUNTIME_IMAGE} AS production
49
54
  COPY --from=node /usr/local/bin/node /usr/local/bin/node
55
+ COPY --from=tini /usr/bin/tini-static /usr/bin/tini
50
56
  # Where ASP.NET Core keeps its Data Protection keys; docker-compose.prod.yml mounts a volume here.
51
57
  RUN mkdir -p /home/app/.aspnet/DataProtection-Keys && chown -R app:app /home/app/.aspnet
52
58
  WORKDIR /app
@@ -55,4 +61,8 @@ USER $APP_UID
55
61
  EXPOSE 8080
56
62
  HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \
57
63
  CMD ["node", "-e", "fetch('http://127.0.0.1:8080/healthz').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
58
- ENTRYPOINT ["dotnet", "__NAME__.dll"]
64
+ # tini is PID 1 and runs the app as its child. The kernel ignores a signal that a container's PID 1 has no handler for,
65
+ # so an app that is PID 1 never dies of its own abort(): after a crash it spins (arm64) or exits with another code than
66
+ # 134. tini passes signals on (docker stop), reaps orphans and exits with the app's code; -s keeps it reaping when
67
+ # another process is PID 1, as under docker run --init.
68
+ ENTRYPOINT ["tini", "-s", "--", "dotnet", "__NAME__.dll"]
@@ -1,24 +1,28 @@
1
1
  # __TITLE__
2
2
 
3
- A React app on ASP.NET Core, built with Netjes: Vite, Tailwind CSS, TanStack Query, zod and Vitest are set up.
3
+ A React app on ASP.NET Core, built with Netjes: Vite, Tailwind CSS, TanStack Query, zod, Vitest, xUnit and Playwright are set up.
4
4
 
5
5
  ```bash
6
6
  npm run dev # dotnet watch + Vite HMR, http://localhost:5080
7
- npm test # Vitest component tests
8
- npm run typecheck
7
+ npm test # Vitest: component tests and the home page's document
8
+ dotnet test tests/__NAME__.Tests # C# tests of the loader and the server action, in memory
9
+ npm run test:e2e # Playwright, on a development and a production server (npx playwright install chromium once first)
10
+ npm run typecheck # every .ts and .tsx file
9
11
  npm run routes # the route table, from C#
10
12
  npx netjes build # types, client and server bundles
11
13
  npx netjes info # check Node.js, the .NET SDK, the packages and this setup
12
14
  dotnet publish -c Release
13
15
  ```
14
16
 
15
- - `app/` holds the pages, as in the Next.js App Router: `page.tsx` is `/`, `about/page.tsx` is `/about`, `layout.tsx` wraps them all, `not-found.tsx` and `error.tsx` handle the rest.
17
+ - `app/` holds the pages, as in the Next.js App Router: `page.tsx` is `/`, `about/page.tsx` is `/about`, `layout.tsx` wraps them all, `not-found.tsx` and `error.tsx` handle the rest. `app/document.test.ts` renders the home page's whole document, as the server sends it, with `renderDocument` from `netjes/testing`.
16
18
  - `public/` is served at the site root. `public/favicon.svg`, which the metadata in `app/layout.tsx` links, is the Netjes mark: replace it with the app's own.
17
19
  - `Features/` holds the C# side, one folder per feature: `Features/Home/HomePage.cs` maps the home page's loader and the `home.greet` server action the form calls.
20
+ - `tests/__NAME__.Tests/` holds the C# tests: `AppFactory.cs` runs the real app in memory, with no Node and no client build, and `HomePageTests.cs` tests the home page's loader and form. `e2e/` holds the Playwright tests, which `playwright.config.ts` runs against the app in Development and in Production. `__NAME__.csproj` leaves these two root folders out of the app's build, so keep the app's own C# out of them.
21
+ - `tsconfig.json` points to two configs: `tsconfig.app.json` checks the app's code without Node's types, `tsconfig.node.json` checks `vite.config.ts`, `playwright.config.ts`, `e2e/` and `scripts/` with them.
18
22
  - `AGENTS.md` tells coding agents how the app is laid out and what the rules are; `CLAUDE.md` imports it for Claude Code.
19
23
  - `Properties/launchSettings.json` holds the `http` launch profile that `dotnet run` and IDEs (F5) use: the app in Development on http://localhost:5080, as `npm run dev` runs it but without `dotnet watch`.
20
- - `generated/netjes.ts` holds zod schemas for what the loaders and actions return, typed server action references and typed routes. The app rewrites it when it starts in Development; commit it, and run `npx netjes generate --check` in CI.
24
+ - `generated/netjes.ts` holds zod schemas for what the loaders and actions return, typed server action references, typed routes and typed endpoint URLs. The app rewrites it when it starts in Development; commit it, and run `npx netjes generate --check` in CI.
21
25
 
22
- Outside Development, `Program.cs` sends HSTS on https responses and redirects http to https once the app knows its https port (`ASPNETCORE_HTTPS_PORT` or `ASPNETCORE_HTTPS_PORTS`). Behind a proxy that terminates TLS, add `UseForwardedHeaders()` (trusting only that proxy) so proxied requests count as https and get HSTS, and let the proxy redirect plain http. Netjes adds `X-Content-Type-Options`, `Referrer-Policy` and `X-Frame-Options: SAMEORIGIN` to every page; see `NetjesOptions.SecurityHeaders` to change that.
26
+ Outside Development, `Program.cs` sends HSTS on https responses and redirects http to https once the app knows its https port (`ASPNETCORE_HTTPS_PORT` or `ASPNETCORE_HTTPS_PORTS`). Behind a proxy that terminates TLS, add `UseForwardedHeaders()` (trusting only that proxy) so proxied requests count as https and get HSTS, and let the proxy redirect plain http. Netjes adds `X-Content-Type-Options: nosniff` to every response that passes through `UseNetjes()`, static files and API endpoints included, and `Referrer-Policy` and `X-Frame-Options` (`SAMEORIGIN`, or what a Content-Security-Policy's `frame-ancestors` implies) to every document it renders; see `NetjesOptions.SecurityHeaders` to change that.
23
27
 
24
- With Docker: `docker compose up` for development, `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d` for production. In Development `appsettings.Development.json` limits `AllowedHosts` to loopback names (a DNS-rebinding guard); add a name there if you reach the dev app by another host. The Docker build restores Netjes from NuGet and npm, which is how any app made by `npx netjes new` references it. Only an app scaffolded from a Netjes checkout references the checkout instead; scaffold it with `netjes new --packages` to build it in Docker.
28
+ With Docker: `docker compose up` for development, `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d` for production. Both publish the app on port 8080 of the host, development on 127.0.0.1 only; set `APP_PORT`, in the shell or a `.env` file, to use another port (`APP_PORT=8090 docker compose up`). The production image runs the app under tini, which passes the signal of `docker stop` on to the app and ends the container when the app crashes, with exit code 134, so `restart: always` starts it again. In Development `appsettings.Development.json` limits `AllowedHosts` to loopback names (a DNS-rebinding guard); add a name there if you reach the dev app by another host. The Docker build restores Netjes from NuGet and npm, which is how any app made by `npx netjes new` references it. Only an app scaffolded from a Netjes checkout references the checkout instead; scaffold it with `netjes new --packages` to build it in Docker.
@@ -12,11 +12,17 @@ How this app is built, for coding agents and the people working with them. It is
12
12
 
13
13
  | Path | What |
14
14
  |---|---|
15
- | `app/` | Pages, as in the Next.js App Router: `page.tsx` is `/`, `about/page.tsx` is `/about`, `blog/[slug]/page.tsx` is `/blog/{slug}`. `layout.tsx` wraps the pages below it and stays mounted; `loading.tsx`, `error.tsx`, `not-found.tsx`, `forbidden.tsx` and `unauthorized.tsx` are boundaries. `(group)` folders add no URL segment, `_private` folders are not routed. |
15
+ | `app/` | Pages, as in the Next.js App Router: `page.tsx` is `/`, `about/page.tsx` is `/about`, `blog/[slug]/page.tsx` is `/blog/{slug}`. `layout.tsx` wraps the pages below it and stays mounted. Netjes renders `<html>`, `<head>` and `<body>` around the root layout, so no layout renders them: the root one returns what goes inside `<body>`. `loading.tsx`, `error.tsx`, `not-found.tsx`, `forbidden.tsx` and `unauthorized.tsx` are boundaries. `(group)` folders add no URL segment, `_private` folders are not routed. `document.test.ts` renders the home page's whole document, as the server sends it, with `renderDocument` from `netjes/testing`. |
16
16
  | `Features/` | The C# side, one folder per feature (`namespace __NAME__.Features.Home`): page loaders (`MapPageLoader`), layout loaders (`MapLayoutLoader`), server actions (`MapAction`) and JSON endpoints, with records for what they take and return. Each feature has a `Map...` extension method that `Program.cs` calls. |
17
17
  | `Program.cs` | Services and the pipeline: `AddNetjes`, `UseNetjes`, authentication (when you add it), `UseAntiforgery`, each feature's `Map...`, and `app.MapPages()` last. |
18
18
  | `components/` | React components, with their Vitest tests next to them (`*.test.tsx`). |
19
- | `generated/netjes.ts` | Generated from the C# types and the page files: zod schemas for page and layout props (`pages`, `layouts`), `sharedProps`, typed `serverActions`, `api` schemas for JSON endpoints, `route()` and the `Href` type. Never edit it. |
19
+ <!--netjes:if tests-->
20
+ | `tests/__NAME__.Tests/` | The C# tests: a new test of a loader or an action goes here. In the project `netjes new` writes, on xUnit v3, `AppFactory.cs` runs the real app in memory with `WebApplicationFactory`, with no Node and no client build, and `HomePageTests.cs` requests the home page and posts its form as the client router does. C# for the app does not go in the root folders `tests/` and `e2e/`, which `netjes new` leaves out of the app's build (`DefaultItemExcludes` in `__NAME__.csproj`). |
21
+ <!--netjes:end-->
22
+ <!--netjes:if e2e-->
23
+ | `e2e/` | Playwright tests, which `npm run test:e2e` runs. The `playwright.config.ts` that `netjes new` writes runs them against the app twice: in Development (the Vite dev server) and in Production (the build). |
24
+ <!--netjes:end-->
25
+ | `generated/netjes.ts` | Generated from the C# types and the page files: zod schemas for page and layout props (`pages`, `layouts`), `sharedProps`, typed `serverActions`, `api` schemas for JSON endpoints, `endpointUrl()` for the URLs of the app's endpoints, `route()` and the `Href` type. Never edit it. |
20
26
  | `styles/global.css` | Tailwind CSS (`@import 'tailwindcss'`), imported by `app/layout.tsx`. |
21
27
  | `public/` | Static files, served at the site root. `favicon.svg` is the Netjes mark, linked by `icons` in the root layout's `metadata`; replace it with the app's own. |
22
28
 
@@ -25,8 +31,14 @@ How this app is built, for coding agents and the people working with them. It is
25
31
  ```bash
26
32
  npm run dev # dotnet watch + Vite HMR on http://localhost:5080; rewrites generated/netjes.ts on start
27
33
  npm test # Vitest
28
- npm run typecheck # strict TypeScript
34
+ npm run typecheck # strict TypeScript; in the configs netjes new writes, the app's code without Node's types
29
35
  dotnet build # C#, warnings are errors
36
+ <!--netjes:if tests-->
37
+ dotnet test tests/__NAME__.Tests # the C# tests; a plain dotnet test here finds only the app's project and runs none
38
+ <!--netjes:end-->
39
+ <!--netjes:if e2e-->
40
+ npm run test:e2e # Playwright (run npx playwright install chromium once first)
41
+ <!--netjes:end-->
30
42
  npx netjes generate # rewrite generated/netjes.ts after changing a C# type (--check: fail when stale)
31
43
  npm run routes # the route table as ASP.NET Core sees it, with route warnings
32
44
  npx netjes build # types, then the client and server bundles
@@ -39,7 +51,7 @@ dotnet publish -c Release # runs the build and ships dist/
39
51
  2. Call its `Map...` method from `Program.cs`, before `app.MapPages()`.
40
52
  3. Run `npx netjes generate` (or restart `npm run dev`).
41
53
  4. Write `app/blog/[slug]/page.tsx`: `export default definePage(pages['/blog/[slug]'], function Post({ post }) { ... })`.
42
- 5. Test the component with Vitest and `createTestApp` from `netjes/testing`; run the checks below.
54
+ 5. Test the loader in C# (in `tests/__NAME__.Tests`, where the app has it) and the component with Vitest and `createTestApp` from `netjes/testing`; run the checks below.
43
55
 
44
56
  A page without a loader is a plain default export and gets empty props. Forms call server actions: `app.MapAction("posts.like", ...)` in C#, `useAction(serverActions['posts.like'])` and `<Form action={formAction}>` in React; they also work without JavaScript.
45
57
 
@@ -47,18 +59,34 @@ A page without a loader is a plain default export and gets empty props. Forms ca
47
59
 
48
60
  - C# decides: data access, validation, authorization (`.RequireAuthorization()` on a loader, a layout loader or `MapPages()`; `Page.Forbidden()`), redirects and caching. React renders what the loader sends; hiding a button is not access control.
49
61
  - Never edit `generated/netjes.ts`. Change the C# type and regenerate; commit the file with the change. CI runs `npx netjes generate --check`.
50
- - zod at every boundary: `definePage` and `defineLayout` with the generated schemas, `useSearchParams(schema)` and `useParams(schema)` for the URL, `api['GET /api/...'].response.parse(...)` for JSON you fetch, `usePublicConfig(schema)` for public config, `Href` (or `isHref`) from the generated file for URLs that come from data. No `any`, no casts of server data.
62
+ - zod at every boundary: `definePage` and `defineLayout` with the generated schemas (extended, as in `pages['/x'].extend({ link: Href })`, only where no C# type fits), `useSearchParams(schema)` and `useParams(schema)` for the URL, `api['GET /api/...'].response.parse(...)` for JSON you fetch from `useHref(endpointUrl('GET /api/...', params))`, `usePublicConfig(schema)` for public config, `Href` (or `isHref`) from the generated file for URLs that come from data, such as a CMS. No `any`, no casts of server data.
63
+ - A link that C# sends to one of the app's pages is a `Href` in the C# record (`new Href("/about")`): it is generated as the `Href` schema, checked against the pages when the props are parsed, and goes to `<Link href>` as it is. C# checks only its shape, and a root-relative `Href` that no page serves fails the whole page, so a link to an endpoint or a file stays a `string`.
51
64
  - Server state goes through TanStack Query (`useQuery`), not `useEffect` and `fetch`. Seed it from the loader with `.WithQuery(key, data)` so it is server-rendered; actions update it with `.Invalidate(key)` or `.WithQuery(key, data)`.
52
- - Internal links are `<Link href={route('/blog/[slug]', { slug })}>`; a typo in an href is a type error.
65
+ - Internal links are `<Link href={route('/blog/[slug]', { slug })}>`; a typo in an href is a type error. `route(pattern, params, search, { hash })` builds the query from `search` and a `#fragment` from `hash`, the id of an element on the page; pass `undefined` as `search` for a link with a fragment and no query.
66
+ - After a client navigation to another page, the router starts the focus over at the top of the document, as a page load does, and announces the page to screen readers by its title. Keep the "Skip to content" link the first thing in `app/layout.tsx` that takes the focus, with the content in `<main id="content">`, and give each page a title of its own (`metadata`, or `PageMetadata` from C#): the router says nothing for a page whose title is the previous page's.
53
67
  - Action inputs are records with `init` properties: `public sealed record NoteInput { [Required] public string? Text { get; init; } public bool Pinned { get; init; } }`. A field the post leaves out, such as an unchecked checkbox, keeps its default; form binding refuses (400) a post that lacks one of a positional record's constructor fields. DataAnnotations on them (`[Required]`, `[StringLength]`) are checked before the action runs and come back as field errors, with no setup, also for a post that has none of the input's fields. Return `ServerAction.Invalid(field, message)` for other errors the user can fix.
54
- - `.CachePage(...)` only for pages that are the same for every visitor: their loaders and shared props must not depend on cookies or other per-visitor state.
55
- - `Netjes:PublicConfig` is sent to the browser: never put secrets there.
68
+ - `.CachePage(...)` only for pages that are the same for every visitor: their loaders and shared props must not depend on cookies or other per-visitor state. In the Development environment pages are never output-cached (each request renders them), so check caching on `npx netjes build && npx netjes start`, or in a `WebApplicationFactory` test in the `Testing` environment with `Netjes:Mode` set to `Development`, as the `AppFactory.cs` that `netjes new` writes runs. Each query string as sent is an entry of its own: name the keys a cached page reads with `.CacheQueryKeys("page")`, or `.CacheQueryKeys()` for none, so other query strings render without being stored (`.claude/skills/netjes/references/caching-and-streaming.md`).
69
+ - In the TypeScript configs `netjes new` writes, `tsconfig.app.json` checks the app's code without Node's types, so a component or page that reads `process` or imports `node:*` fails `npm run typecheck`. Code that runs only in Node goes in `scripts/`, which `tsconfig.node.json` checks with them; do not add `node` to the `types` of `tsconfig.app.json`.
70
+ - An `error.tsx` renders `error.message` only when `!error.withheld`, and shows `error.status ?? 500` if it shows a status.
71
+ - `Netjes:PublicConfig` is sent to the browser: never put secrets there. 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 ([security](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/security.md#errors-and-configuration)).
56
72
  - Write the failing test first, then the change.
57
73
 
58
74
  ## Before you finish
59
75
 
76
+ <!--netjes:if tests-->
77
+ ```bash
78
+ npm test && npm run typecheck && dotnet build && dotnet test tests/__NAME__.Tests && npx netjes generate --check
79
+ ```
80
+ <!--netjes:else-->
60
81
  ```bash
61
82
  npm test && npm run typecheck && dotnet build && npx netjes generate --check
62
83
  ```
63
84
 
85
+ No C# test project was at `tests/__NAME__.Tests/` when this file was written. If the app has C# tests elsewhere, run them too; [testing](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/testing.md#c-tests-of-loaders-and-actions) says how to add a project there. After adding one, run `npx netjes agents --force`, which writes this file again with it, and `CLAUDE.md` and `.claude/skills/netjes/` too; keep your own additions to them with `git diff`.
86
+ <!--netjes:end-->
87
+
64
88
  Then `npm run routes` should print no warnings, and the page should work in `npm run dev`, including with JavaScript turned off for forms.
89
+ <!--netjes:if e2e-->
90
+
91
+ For a change to a page or a form, also run `npm run test:e2e`.
92
+ <!--netjes:end-->
@@ -5,6 +5,8 @@
5
5
  <Nullable>enable</Nullable>
6
6
  <ImplicitUsings>enable</ImplicitUsings>
7
7
  <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
8
+ <!-- The SDK compiles everything below this file: the C# tests and the Playwright specs have builds of their own. -->
9
+ <DefaultItemExcludes>$(DefaultItemExcludes);tests/**;e2e/**</DefaultItemExcludes>
8
10
  </PropertyGroup>
9
11
 
10
12
  <!--NETJES_REFERENCES-->
@@ -0,0 +1,29 @@
1
+ // @vitest-environment node
2
+ // The home page's whole document, as the server sends it to a crawler, from the props its C# loader returns
3
+ // (Features/Home/HomePage.cs). renderDocument renders without a DOM, as the server does, hence the node environment.
4
+ import { CSP_SCRIPT_HASHES } from 'netjes';
5
+ import { inlineScriptHashes, renderDocument } from 'netjes/testing';
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ const home = () =>
9
+ renderDocument('app/page.tsx', {
10
+ props: { message: 'Hello from C#', renderedAt: '2026-11-05T12:00:00Z' },
11
+ shared: { siteName: '__TITLE__' },
12
+ });
13
+
14
+ describe('the home page', () => {
15
+ it("is a document with the loader's heading, inside the root layout", async () => {
16
+ const html = await home();
17
+
18
+ expect(html.startsWith('<!DOCTYPE html><html lang="en">')).toBe(true);
19
+ expect(html).toMatch(/<h1[^>]*>Hello from C#<\/h1>/);
20
+ expect(html).toContain('<nav aria-label="Main"');
21
+ });
22
+
23
+ it('has no inline script in its document but the one whose hash Netjes lists for a policy without a nonce', async () => {
24
+ // NetjesContentSecurityPolicy.ScriptHashes in C# holds the same hashes. The inline code of an afterInteractive or
25
+ // lazyOnload <Script> is added in the browser and is in no document: its hash goes in the policy, not here. That of
26
+ // a beforeInteractive <Script> is in the document: its hash goes in both.
27
+ expect(await inlineScriptHashes(await home())).toEqual(CSP_SCRIPT_HASHES.split(' '));
28
+ });
29
+ });
@@ -5,8 +5,9 @@ export default function ErrorPage({ error, reset }: ErrorPageProps) {
5
5
  return (
6
6
  <section role="alert" className="space-y-4">
7
7
  <h1 className="text-3xl font-bold tracking-tight">Something went wrong</h1>
8
- {/* In development the message can take several lines: a syntax error comes with a code frame. */}
9
- <p className="whitespace-pre-wrap wrap-anywhere text-slate-600">{error.message}</p>
8
+ {/* Withheld: a stand-in for a message Netjes does not show, such as an exception's with error details off. A
9
+ message can take several lines: in development a syntax error comes with a code frame. */}
10
+ {!error.withheld && <p className="whitespace-pre-wrap wrap-anywhere text-slate-600">{error.message}</p>}
10
11
  <button type="button" onClick={reset} className="cursor-pointer rounded-full bg-slate-900 px-4 py-2 font-medium text-white">
11
12
  Try again
12
13
  </button>
@@ -14,6 +14,15 @@ export default function RootLayout({ children }: LayoutProps) {
14
14
  const site = useSharedProps(sharedProps);
15
15
  return (
16
16
  <div className="mx-auto min-h-screen max-w-3xl px-6 py-10 font-sans text-slate-900">
17
+ {/* The first element Tab reaches, after a page load and after every client navigation, when the router starts
18
+ the focus over at the top of the document. It points to a place on this page, which needs no prefetch. */}
19
+ <Link
20
+ href="#content"
21
+ prefetch="none"
22
+ className="sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-50 focus:rounded focus:bg-white focus:px-4 focus:py-2 focus:shadow"
23
+ >
24
+ Skip to content
25
+ </Link>
17
26
  <header className="mb-12 flex items-baseline justify-between">
18
27
  <Link href="/" className="text-xl font-semibold no-underline">
19
28
  {site.siteName}
@@ -27,7 +36,7 @@ export default function RootLayout({ children }: LayoutProps) {
27
36
  </Link>
28
37
  </nav>
29
38
  </header>
30
- <main>{children}</main>
39
+ <main id="content">{children}</main>
31
40
  </div>
32
41
  );
33
42
  }
@@ -1,11 +1,12 @@
1
- # Development: dotnet watch for C#, the Vite dev server for pages (HMR on 5173). Open http://localhost:8080.
2
- # Both ports are bound to localhost only: dev error pages show stack traces and Vite serves the source.
1
+ # Development: dotnet watch for C#, the Vite dev server for pages (HMR on 5173). Open http://localhost:8080, or the port
2
+ # APP_PORT names. Both ports are bound to localhost only: dev error pages show stack traces and Vite serves the source.
3
3
  services:
4
4
  app:
5
5
  build:
6
6
  target: dev
7
7
  ports:
8
- - "127.0.0.1:8080:8080"
8
+ # The host's port for the app: APP_PORT, from the shell or a .env file next to this file, or 8080. Vite keeps 5173.
9
+ - "127.0.0.1:${APP_PORT:-8080}:8080"
9
10
  - "127.0.0.1:5173:5173"
10
11
  volumes:
11
12
  - .:/src
@@ -3,7 +3,8 @@ services:
3
3
  app:
4
4
  restart: always
5
5
  ports:
6
- - "8080:8080"
6
+ # The host's port: APP_PORT, from the shell or a .env file next to this file, or 8080.
7
+ - "${APP_PORT:-8080}:8080"
7
8
  read_only: true
8
9
  tmpfs:
9
10
  - /tmp
@@ -0,0 +1,32 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ // These tests pass with or without hydration: a link and a form work before the client router runs. A test that needs
4
+ // the client, such as one that clicks a button with an onClick handler, first waits for it after each full page load:
5
+ // await expect(page.locator('html')).toHaveAttribute('data-netjes-hydrated', '');
6
+
7
+ test('the home page shows the message from its C# loader', async ({ page }) => {
8
+ await page.goto('/');
9
+
10
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('Hello from C#');
11
+ });
12
+
13
+ test('the link on the home page leads to the about page', async ({ page }) => {
14
+ await page.goto('/');
15
+
16
+ await page.getByRole('link', { name: 'About this app' }).click();
17
+
18
+ await expect(page).toHaveURL('/about');
19
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('About');
20
+ });
21
+
22
+ test.describe('with JavaScript turned off', () => {
23
+ test.use({ javaScriptEnabled: false });
24
+
25
+ test('the form asks for the name it did not get', async ({ page }) => {
26
+ await page.goto('/');
27
+
28
+ await page.getByRole('button', { name: 'Say hello' }).click();
29
+
30
+ await expect(page.getByText('Tell us your name.')).toBeVisible();
31
+ });
32
+ });
@@ -1,8 +1,19 @@
1
1
  // Generated by Netjes from the app's C# types and page files. Do not edit: the app rewrites it when it starts in
2
2
  // Development, and `netjes generate` writes it for CI (`netjes generate --check` fails when it is out of date).
3
- import { buildHref, isRouteHref, serverAction, type SearchParamsInput } from 'netjes';
3
+ import { buildEndpointUrl, buildHref, isRouteHref, serverAction, type SearchParamsInput } from 'netjes';
4
4
  import { z } from 'zod';
5
5
 
6
+ /** Every page's URL pattern. */
7
+ export const routePatterns = ["/", "/about"] as const;
8
+
9
+ /** Whether a URL that comes from data (a CMS, shared props) is one of the app's pages or another site. */
10
+ export function isHref(value: string): value is Href {
11
+ return isRouteHref(value, routePatterns);
12
+ }
13
+
14
+ /** Checks such a URL at the boundary: `<Link href={Href.parse(item.href)}>`. The schemas below use it for each C# `Netjes.Href`. */
15
+ export const Href = z.custom<Href>((value) => typeof value === 'string' && isHref(value), 'Not a URL this app serves');
16
+
6
17
  export const HomeProps = z.object({
7
18
  message: z.string(),
8
19
  renderedAt: z.iso.datetime({ offset: true, local: true }),
@@ -29,6 +40,29 @@ export const sharedProps = SiteProps;
29
40
  /** JSON endpoints by method and route: what they accept and return. */
30
41
  export const api = {} as const;
31
42
 
43
+ /**
44
+ * What the app's endpoints read from their URLs, by method and route as in `api`: the route values, then the
45
+ * query string values the handler binds. A JSON endpoint has a key for each of its methods except HEAD, and any
46
+ * other endpoint one for GET, if it answers GET.
47
+ */
48
+ export type EndpointParams = Record<never, never>;
49
+ export type EndpointKey = keyof EndpointParams;
50
+ const endpointTemplates: Record<EndpointKey, string> = {};
51
+
52
+ /**
53
+ * The URL of one of the app's endpoints, with its values encoded: `endpointUrl('GET /api/posts/{slug}/likes/', { slug })`.
54
+ * The values the route does not name go in the query string, followed by the third argument. The URL has no base
55
+ * path: pass it through `useHref` for a `fetch` or an `<a href>`.
56
+ */
57
+ export function endpointUrl<K extends EndpointKey>(
58
+ key: K,
59
+ ...args: {} extends EndpointParams[K]
60
+ ? [params?: EndpointParams[K], search?: SearchParamsInput]
61
+ : [params: EndpointParams[K], search?: SearchParamsInput]
62
+ ): string {
63
+ return buildEndpointUrl(endpointTemplates[key], args[0] ?? {}, args[1], key);
64
+ }
65
+
32
66
  /** Form fields of page actions (`MapPageAction`), by page pattern. */
33
67
  export const forms = {} as const;
34
68
 
@@ -48,23 +82,18 @@ export type Route = '/' | '/about';
48
82
  /** What `<Link href>` and `router.push` accept: the app's routes, with a query or fragment, or another site. */
49
83
  export type Href = Route | `${Route}?${string}` | `${Route}#${string}` | `?${string}` | `#${string}` | `http://${string}` | `https://${string}` | `mailto:${string}` | `tel:${string}`;
50
84
 
51
- /** The URL of a page, with its parameters encoded: `route('/blog/[slug]', { slug })`. */
85
+ /**
86
+ * The URL of a page, with its parameters encoded: `route('/blog/[slug]', { slug })`. The third argument is the
87
+ * query, and `{ hash }` in the fourth adds a fragment: `route('/docs/[...path]', { path }, undefined, { hash: 'keys' })`.
88
+ */
52
89
  export function route<P extends RoutePattern>(
53
90
  pattern: P,
54
- ...args: {} extends RouteParams[P] ? [params?: RouteParams[P], search?: SearchParamsInput] : [params: RouteParams[P], search?: SearchParamsInput]
91
+ ...args: {} extends RouteParams[P]
92
+ ? [params?: RouteParams[P], search?: SearchParamsInput, options?: { hash?: string | undefined }]
93
+ : [params: RouteParams[P], search?: SearchParamsInput, options?: { hash?: string | undefined }]
55
94
  ): Href {
56
- return buildHref(pattern, args[0] ?? {}, args[1]) as Href;
95
+ return buildHref(pattern, args[0] ?? {}, args[1], args[2]) as Href;
57
96
  }
58
- /** Every page's URL pattern. */
59
- export const routePatterns = ["/", "/about"] as const;
60
-
61
- /** Whether a URL that comes from data (a CMS, shared props) is one of the app's pages or another site. */
62
- export function isHref(value: string): value is Href {
63
- return isRouteHref(value, routePatterns);
64
- }
65
-
66
- /** Checks such a URL at the boundary: `<Link href={Href.parse(item.href)}>`. */
67
- export const Href = z.custom<Href>((value) => typeof value === 'string' && isHref(value), 'Not a URL this app serves');
68
97
 
69
98
  declare module 'netjes' {
70
99
  interface Register {
@@ -9,7 +9,8 @@
9
9
  "dev": "netjes dev",
10
10
  "build": "vite build && vite build --ssr",
11
11
  "test": "vitest run",
12
- "typecheck": "tsc -p tsconfig.json --noEmit",
12
+ "test:e2e": "dotnet build && playwright test",
13
+ "typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.node.json --noEmit",
13
14
  "generate": "netjes generate",
14
15
  "routes": "netjes routes"
15
16
  },
@@ -21,10 +22,12 @@
21
22
  "zod": "^4.6.7"
22
23
  },
23
24
  "devDependencies": {
25
+ "@playwright/test": "^1.64.1",
24
26
  "@tailwindcss/vite": "^4.3.3",
25
27
  "@testing-library/dom": "^10.4.2",
26
28
  "@testing-library/react": "^16.3.3",
27
29
  "@testing-library/user-event": "^14.6.8",
30
+ "@types/node": "^22.20.5",
28
31
  "@types/react": "^19.3.0",
29
32
  "@types/react-dom": "^19.3.0",
30
33
  "jsdom": "^30.1.3",
@@ -0,0 +1,36 @@
1
+ import { defineConfig, devices } from '@playwright/test';
2
+
3
+ // Each project runs the app as its own ASP.NET Core process: one in Development (the Vite dev server) and one in
4
+ // Production (the `npm run build` output). `npm run test:e2e` builds the .NET app first; install the browser once with
5
+ // `npx playwright install chromium`. E2E_DEV_PORT, E2E_PROD_PORT and E2E_VITE_PORT move the ports, so that several runs
6
+ // can share a machine. With CI set, Playwright always starts fresh servers; otherwise it reuses one that is listening.
7
+ const DEV_URL = `http://127.0.0.1:${process.env['E2E_DEV_PORT'] ?? '5090'}`;
8
+ const PROD_URL = `http://127.0.0.1:${process.env['E2E_PROD_PORT'] ?? '5091'}`;
9
+ const VITE_PORT = process.env['E2E_VITE_PORT'] ?? '5190';
10
+
11
+ export default defineConfig({
12
+ testDir: './e2e',
13
+ forbidOnly: !!process.env['CI'],
14
+ retries: process.env['CI'] ? 1 : 0,
15
+ use: { ...devices['Desktop Chrome'], trace: 'retain-on-failure' },
16
+ projects: [
17
+ { name: 'development', use: { baseURL: DEV_URL } },
18
+ { name: 'production', use: { baseURL: PROD_URL } },
19
+ ],
20
+ webServer: [
21
+ {
22
+ command: 'dotnet run --no-build --no-launch-profile',
23
+ url: `${DEV_URL}/healthz`,
24
+ env: { ASPNETCORE_ENVIRONMENT: 'Development', ASPNETCORE_URLS: DEV_URL, Netjes__ViteDevPort: VITE_PORT },
25
+ reuseExistingServer: !process.env['CI'],
26
+ timeout: 120_000,
27
+ },
28
+ {
29
+ command: 'npm run build && dotnet run --no-build --no-launch-profile',
30
+ url: `${PROD_URL}/healthz`,
31
+ env: { ASPNETCORE_ENVIRONMENT: 'Production', ASPNETCORE_URLS: PROD_URL },
32
+ reuseExistingServer: !process.env['CI'],
33
+ timeout: 180_000,
34
+ },
35
+ ],
36
+ });