netjes 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -2
  3. package/bin/netjes.mjs +13 -0
  4. package/cli/agents.mjs +66 -0
  5. package/cli/commands.mjs +467 -0
  6. package/cli/help.mjs +89 -0
  7. package/cli/info.mjs +454 -0
  8. package/cli/main.mjs +167 -0
  9. package/cli/packages.mjs +19 -0
  10. package/cli/process-io.mjs +163 -0
  11. package/cli/routes.mjs +31 -0
  12. package/cli/scaffold.mjs +193 -0
  13. package/cli/suggest.mjs +50 -0
  14. package/cli/terminal.mjs +40 -0
  15. package/dist/actions.d.ts +98 -0
  16. package/dist/actions.d.ts.map +1 -0
  17. package/dist/actions.js +176 -0
  18. package/dist/actions.js.map +1 -0
  19. package/dist/client-entry.d.ts +2 -0
  20. package/dist/client-entry.d.ts.map +1 -0
  21. package/dist/client-entry.js +126 -0
  22. package/dist/client-entry.js.map +1 -0
  23. package/dist/deferred.d.ts +39 -0
  24. package/dist/deferred.d.ts.map +1 -0
  25. package/dist/deferred.js +123 -0
  26. package/dist/deferred.js.map +1 -0
  27. package/dist/define-page.d.ts +15 -0
  28. package/dist/define-page.d.ts.map +1 -0
  29. package/dist/define-page.js +27 -0
  30. package/dist/define-page.js.map +1 -0
  31. package/dist/document.d.ts +19 -0
  32. package/dist/document.d.ts.map +1 -0
  33. package/dist/document.js +17 -0
  34. package/dist/document.js.map +1 -0
  35. package/dist/draft-mode.d.ts +5 -0
  36. package/dist/draft-mode.d.ts.map +1 -0
  37. package/dist/draft-mode.js +6 -0
  38. package/dist/draft-mode.js.map +1 -0
  39. package/dist/dynamic.d.ts +20 -0
  40. package/dist/dynamic.d.ts.map +1 -0
  41. package/dist/dynamic.js +37 -0
  42. package/dist/dynamic.js.map +1 -0
  43. package/dist/form-data.d.ts +10 -0
  44. package/dist/form-data.d.ts.map +1 -0
  45. package/dist/form-data.js +10 -0
  46. package/dist/form-data.js.map +1 -0
  47. package/dist/form.d.ts +35 -0
  48. package/dist/form.d.ts.map +1 -0
  49. package/dist/form.js +80 -0
  50. package/dist/form.js.map +1 -0
  51. package/dist/href.d.ts +25 -0
  52. package/dist/href.d.ts.map +1 -0
  53. package/dist/href.js +71 -0
  54. package/dist/href.js.map +1 -0
  55. package/dist/image.d.ts +36 -0
  56. package/dist/image.d.ts.map +1 -0
  57. package/dist/image.js +60 -0
  58. package/dist/image.js.map +1 -0
  59. package/dist/index.d.ts +18 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +14 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/link.d.ts +17 -0
  64. package/dist/link.d.ts.map +1 -0
  65. package/dist/link.js +46 -0
  66. package/dist/link.js.map +1 -0
  67. package/dist/locale.d.ts +3 -0
  68. package/dist/locale.d.ts.map +1 -0
  69. package/dist/locale.js +6 -0
  70. package/dist/locale.js.map +1 -0
  71. package/dist/metadata.d.ts +83 -0
  72. package/dist/metadata.d.ts.map +1 -0
  73. package/dist/metadata.js +100 -0
  74. package/dist/metadata.js.map +1 -0
  75. package/dist/modules.d.ts +74 -0
  76. package/dist/modules.d.ts.map +1 -0
  77. package/dist/modules.js +85 -0
  78. package/dist/modules.js.map +1 -0
  79. package/dist/nonce.d.ts +8 -0
  80. package/dist/nonce.d.ts.map +1 -0
  81. package/dist/nonce.js +12 -0
  82. package/dist/nonce.js.map +1 -0
  83. package/dist/payload.d.ts +171 -0
  84. package/dist/payload.d.ts.map +1 -0
  85. package/dist/payload.js +273 -0
  86. package/dist/payload.js.map +1 -0
  87. package/dist/public-config.d.ts +11 -0
  88. package/dist/public-config.d.ts.map +1 -0
  89. package/dist/public-config.js +10 -0
  90. package/dist/public-config.js.map +1 -0
  91. package/dist/query.d.ts +22 -0
  92. package/dist/query.d.ts.map +1 -0
  93. package/dist/query.js +68 -0
  94. package/dist/query.js.map +1 -0
  95. package/dist/render-cleanup.d.ts +6 -0
  96. package/dist/render-cleanup.d.ts.map +1 -0
  97. package/dist/render-cleanup.js +7 -0
  98. package/dist/render-cleanup.js.map +1 -0
  99. package/dist/root.d.ts +12 -0
  100. package/dist/root.d.ts.map +1 -0
  101. package/dist/root.js +26 -0
  102. package/dist/root.js.map +1 -0
  103. package/dist/route-tree.d.ts +56 -0
  104. package/dist/route-tree.d.ts.map +1 -0
  105. package/dist/route-tree.js +86 -0
  106. package/dist/route-tree.js.map +1 -0
  107. package/dist/router.d.ts +118 -0
  108. package/dist/router.d.ts.map +1 -0
  109. package/dist/router.js +938 -0
  110. package/dist/router.js.map +1 -0
  111. package/dist/schema.d.ts +35 -0
  112. package/dist/schema.d.ts.map +1 -0
  113. package/dist/schema.js +74 -0
  114. package/dist/schema.js.map +1 -0
  115. package/dist/script.d.ts +23 -0
  116. package/dist/script.d.ts.map +1 -0
  117. package/dist/script.js +89 -0
  118. package/dist/script.js.map +1 -0
  119. package/dist/server-entry.d.ts +2 -0
  120. package/dist/server-entry.d.ts.map +1 -0
  121. package/dist/server-entry.js +4 -0
  122. package/dist/server-entry.js.map +1 -0
  123. package/dist/server-render.d.ts +19 -0
  124. package/dist/server-render.d.ts.map +1 -0
  125. package/dist/server-render.js +193 -0
  126. package/dist/server-render.js.map +1 -0
  127. package/dist/testing-setup.d.ts +2 -0
  128. package/dist/testing-setup.d.ts.map +1 -0
  129. package/dist/testing-setup.js +8 -0
  130. package/dist/testing-setup.js.map +1 -0
  131. package/dist/testing.d.ts +76 -0
  132. package/dist/testing.d.ts.map +1 -0
  133. package/dist/testing.js +106 -0
  134. package/dist/testing.js.map +1 -0
  135. package/dist/vitals-config.d.ts +4 -0
  136. package/dist/vitals-config.d.ts.map +1 -0
  137. package/dist/vitals-config.js +5 -0
  138. package/dist/vitals-config.js.map +1 -0
  139. package/dist/vitals.d.ts +13 -0
  140. package/dist/vitals.d.ts.map +1 -0
  141. package/dist/vitals.js +60 -0
  142. package/dist/vitals.js.map +1 -0
  143. package/dist/zod.d.ts +19 -0
  144. package/dist/zod.d.ts.map +1 -0
  145. package/dist/zod.js +65 -0
  146. package/dist/zod.js.map +1 -0
  147. package/package.json +147 -4
  148. package/sidecar/server.mjs +325 -0
  149. package/skills/netjes/SKILL.md +93 -0
  150. package/skills/netjes/references/caching-and-streaming.md +68 -0
  151. package/skills/netjes/references/data-and-actions.md +98 -0
  152. package/skills/netjes/references/deployment.md +57 -0
  153. package/skills/netjes/references/routing.md +65 -0
  154. package/skills/netjes/references/testing.md +57 -0
  155. package/src/actions.tsx +241 -0
  156. package/src/client-entry.tsx +126 -0
  157. package/src/deferred.ts +134 -0
  158. package/src/define-page.tsx +41 -0
  159. package/src/document.tsx +49 -0
  160. package/src/draft-mode.ts +6 -0
  161. package/src/dynamic.tsx +64 -0
  162. package/src/form-data.ts +10 -0
  163. package/src/form.tsx +122 -0
  164. package/src/href.ts +79 -0
  165. package/src/image.tsx +91 -0
  166. package/src/index.ts +40 -0
  167. package/src/link.tsx +73 -0
  168. package/src/locale.ts +6 -0
  169. package/src/metadata.tsx +181 -0
  170. package/src/modules.ts +133 -0
  171. package/src/nonce.tsx +14 -0
  172. package/src/payload.ts +405 -0
  173. package/src/public-config.tsx +19 -0
  174. package/src/query.tsx +82 -0
  175. package/src/render-cleanup.ts +7 -0
  176. package/src/root.tsx +39 -0
  177. package/src/route-tree.tsx +139 -0
  178. package/src/router.tsx +1188 -0
  179. package/src/schema.ts +98 -0
  180. package/src/script.tsx +103 -0
  181. package/src/server-entry.tsx +4 -0
  182. package/src/server-render.tsx +223 -0
  183. package/src/testing-setup.ts +8 -0
  184. package/src/testing.tsx +177 -0
  185. package/src/virtual.d.ts +12 -0
  186. package/src/vitals-config.ts +6 -0
  187. package/src/vitals.tsx +68 -0
  188. package/src/zod.ts +72 -0
  189. package/template/.dockerignore +11 -0
  190. package/template/Dockerfile +58 -0
  191. package/template/Features/Home/HomePage.cs +32 -0
  192. package/template/Program.cs +28 -0
  193. package/template/Properties/launchSettings.json +13 -0
  194. package/template/README.md +24 -0
  195. package/template/SiteProps.cs +4 -0
  196. package/template/_AGENTS.md +64 -0
  197. package/template/_CLAUDE.md +1 -0
  198. package/template/__NAME__.csproj +11 -0
  199. package/template/_gitattributes +2 -0
  200. package/template/_gitignore +11 -0
  201. package/template/app/about/page.tsx +17 -0
  202. package/template/app/error.tsx +15 -0
  203. package/template/app/layout.tsx +33 -0
  204. package/template/app/not-found.tsx +14 -0
  205. package/template/app/page.tsx +14 -0
  206. package/template/appsettings.Development.json +3 -0
  207. package/template/appsettings.json +9 -0
  208. package/template/components/Greeting.test.tsx +25 -0
  209. package/template/components/Greeting.tsx +26 -0
  210. package/template/components/HelloForm.test.tsx +27 -0
  211. package/template/components/HelloForm.tsx +34 -0
  212. package/template/docker-compose.override.yml +15 -0
  213. package/template/docker-compose.prod.yml +26 -0
  214. package/template/docker-compose.yml +16 -0
  215. package/template/generated/netjes.ts +73 -0
  216. package/template/package.json +36 -0
  217. package/template/public/favicon.svg +6 -0
  218. package/template/styles/global.css +7 -0
  219. package/template/tsconfig.json +19 -0
  220. package/template/vite.config.ts +7 -0
  221. package/vite/plugin.d.ts +29 -0
  222. package/vite/plugin.mjs +291 -0
package/src/vitals.tsx ADDED
@@ -0,0 +1,68 @@
1
+ // netjes/vitals: Core Web Vitals, measured with the web-vitals package (an optional peer dependency of netjes).
2
+ import { useContext, useEffect, useRef } from 'react';
3
+ import { type Metric, onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
4
+ import { usePagePayload } from './router.tsx';
5
+ import { VitalsEndpointContext } from './vitals-config.ts';
6
+
7
+ /** One measurement, as `web-vitals` reports it, with the route pattern of the page it was measured on. */
8
+ export type WebVitalsMetric = Pick<Metric, 'name' | 'value' | 'rating' | 'delta' | 'id' | 'navigationType'> & {
9
+ route: string | null;
10
+ };
11
+
12
+ type Subscriber = (metric: WebVitalsMetric) => void;
13
+
14
+ const subscribers = new Set<Subscriber>();
15
+ const reported: WebVitalsMetric[] = [];
16
+ let started = false;
17
+
18
+ /** Web vitals describe the page load, so they are measured once, for the route the document was loaded on. */
19
+ function start(route: string | null) {
20
+ if (started) return;
21
+ started = true;
22
+ const report = ({ name, value, rating, delta, id, navigationType }: Metric) => {
23
+ const metric: WebVitalsMetric = { name, value, rating, delta, id, navigationType, route };
24
+ reported.push(metric);
25
+ for (const subscriber of subscribers) subscriber(metric);
26
+ };
27
+ onCLS(report);
28
+ onFCP(report);
29
+ onINP(report);
30
+ onLCP(report);
31
+ onTTFB(report);
32
+ }
33
+
34
+ /** Like Next.js's `useReportWebVitals`: calls back with each Core Web Vital of this page load (also ones measured earlier). */
35
+ export function useReportWebVitals(callback: (metric: WebVitalsMetric) => void) {
36
+ const { route } = usePagePayload();
37
+ const latest = useRef(callback);
38
+ latest.current = callback;
39
+
40
+ useEffect(() => {
41
+ const subscriber: Subscriber = (metric) => latest.current(metric);
42
+ for (const metric of reported) subscriber(metric);
43
+ subscribers.add(subscriber);
44
+ start(route);
45
+ return () => {
46
+ subscribers.delete(subscriber);
47
+ };
48
+ // Subscribes once; the route is the one the page was loaded on.
49
+ }, []);
50
+ }
51
+
52
+ /** The measurements a `<WebVitals />` has sent, so one that mounts again does not send the replayed ones twice. */
53
+ const sent = new WeakSet<WebVitalsMetric>();
54
+
55
+ /**
56
+ * Sends Core Web Vitals to the server, which records them as `netjes.web_vitals.*` histograms (turn on
57
+ * `NetjesOptions.CollectWebVitals`). Render it once, e.g. in the root layout.
58
+ */
59
+ export function WebVitals() {
60
+ const endpoint = useContext(VitalsEndpointContext);
61
+ useReportWebVitals((metric) => {
62
+ if (!endpoint || sent.has(metric)) return;
63
+ sent.add(metric);
64
+ const { name, value, rating, route, navigationType } = metric;
65
+ navigator.sendBeacon(endpoint, JSON.stringify({ name, value, rating, route, navigationType }));
66
+ });
67
+ return null;
68
+ }
package/src/zod.ts ADDED
@@ -0,0 +1,72 @@
1
+ // zod helpers (netjes/zod). Only this module imports zod, so apps on another schema library do not need it.
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * What `deferred()` parses to: a thenable that React's `use()` reads (it records `status` and `value` as React
6
+ * expects). Not a Promise, because zod would then treat the whole schema as async.
7
+ */
8
+ export class DeferredValue<T> implements PromiseLike<T> {
9
+ status: 'pending' | 'fulfilled' | 'rejected' = 'pending';
10
+ value?: T;
11
+ reason?: unknown;
12
+ private readonly settled: Promise<T>;
13
+
14
+ constructor(source: PromiseLike<unknown>, parse: (value: unknown) => T) {
15
+ this.settled = Promise.resolve(source).then(
16
+ (raw) => {
17
+ try {
18
+ const value = parse(raw);
19
+ this.status = 'fulfilled';
20
+ this.value = value;
21
+ return value;
22
+ } catch (error) {
23
+ this.status = 'rejected';
24
+ this.reason = error;
25
+ throw error;
26
+ }
27
+ },
28
+ (reason) => {
29
+ this.status = 'rejected';
30
+ this.reason = reason;
31
+ throw reason;
32
+ },
33
+ );
34
+ // Values nobody reads must not surface as unhandled rejections.
35
+ this.settled.catch(() => {});
36
+ }
37
+
38
+ then<R1 = T, R2 = never>(
39
+ onFulfilled?: ((value: T) => R1 | PromiseLike<R1>) | null,
40
+ onRejected?: ((reason: unknown) => R2 | PromiseLike<R2>) | null,
41
+ ): PromiseLike<R1 | R2> {
42
+ return this.settled.then(onFulfilled, onRejected);
43
+ }
44
+ }
45
+
46
+ const validated = new WeakMap<object, Map<z.ZodType, DeferredValue<unknown>>>();
47
+
48
+ /**
49
+ * A `Page.Defer` value in page or layout props: a promise of `schema`'s output. Read it with React's `use()` inside
50
+ * a Suspense boundary. The resolved value is validated; a mismatch rejects (and reaches the error boundary).
51
+ */
52
+ export function deferred<Schema extends z.ZodType>(schema: Schema): z.ZodType<PromiseLike<z.output<Schema>>, Promise<unknown>> {
53
+ const parsed = z
54
+ .custom<Promise<unknown>>((value) => value instanceof Promise, { message: 'Expected a deferred value (Page.Defer)' })
55
+ .transform((promise): DeferredValue<z.output<Schema>> => {
56
+ // The same thenable every time, so use() does not suspend again after each render.
57
+ let bySchema = validated.get(promise);
58
+ if (!bySchema) validated.set(promise, (bySchema = new Map()));
59
+ let result = bySchema.get(schema);
60
+ if (!result) {
61
+ result = new DeferredValue(promise, (value) => {
62
+ const parsed = schema.safeParse(value);
63
+ if (parsed.success) return parsed.data;
64
+ throw new Error(`Invalid deferred value: ${parsed.error.issues.map((i) => `${i.path.join('.') || '(value)'}: ${i.message}`).join('; ')}`);
65
+ });
66
+ bySchema.set(schema, result);
67
+ }
68
+ return result as DeferredValue<z.output<Schema>>;
69
+ });
70
+ // zod types a transform returning a thenable as its awaited value; it is the thenable.
71
+ return parsed as unknown as z.ZodType<PromiseLike<z.output<Schema>>, Promise<unknown>>;
72
+ }
@@ -0,0 +1,11 @@
1
+ **/node_modules
2
+ **/bin
3
+ **/obj
4
+ publish
5
+ **/dist
6
+ **/.vite
7
+ **/test-results
8
+ .git
9
+ **/.env
10
+ **/.env.*
11
+ !**/.env.example
@@ -0,0 +1,58 @@
1
+ # syntax=docker/dockerfile:1
2
+ # docker compose up -> dev (dotnet watch + Vite HMR)
3
+ # docker compose -f docker-compose.yml -f docker-compose.prod.yml up -> production image
4
+ # Builds against the published Netjes NuGet and npm packages, as any app made by `npx netjes new` does. Only an app
5
+ # scaffolded from a Netjes checkout references the checkout, which this build cannot see: scaffold it with --packages.
6
+ ARG NODE_IMAGE=node:24.22.1-bookworm-slim
7
+ ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0.403-noble
8
+ ARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0.14-noble
9
+
10
+ FROM ${NODE_IMAGE} AS node
11
+
12
+ # npm dependencies, cached until package.json or the lockfile changes.
13
+ FROM node AS deps
14
+ WORKDIR /src
15
+ COPY package.json package-lock.json ./
16
+ RUN npm ci
17
+
18
+ # Development: the source is bind-mounted by docker-compose.override.yml.
19
+ FROM ${SDK_IMAGE} AS dev
20
+ COPY --from=node /usr/local/bin/node /usr/local/bin/node
21
+ COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
22
+ RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
23
+ && ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
24
+ WORKDIR /src
25
+ COPY --from=deps /src ./
26
+ ENV ASPNETCORE_ENVIRONMENT=Development \
27
+ ASPNETCORE_URLS=http://+:8080 \
28
+ DOTNET_USE_POLLING_FILE_WATCHER=1 \
29
+ DOTNET_WATCH_SUPPRESS_BROWSER_REFRESH=1 \
30
+ NETJES_VITE_HOST=0.0.0.0
31
+ EXPOSE 8080 5173
32
+ CMD ["dotnet", "watch", "run", "--no-launch-profile"]
33
+
34
+ # Client bundle (dist/client) and server bundle + sidecar (dist/server).
35
+ FROM deps AS client
36
+ COPY . .
37
+ RUN npm run build
38
+
39
+ FROM ${SDK_IMAGE} AS build
40
+ WORKDIR /src
41
+ COPY __NAME__.csproj ./
42
+ RUN dotnet restore
43
+ COPY . .
44
+ COPY --from=client /src/dist dist
45
+ RUN dotnet publish -c Release -o /app --no-restore -p:NetjesSkipClientBuild=true
46
+
47
+ # Production: ASP.NET Core runtime plus the node binary for the SSR sidecar. Runs as the non-root app user.
48
+ FROM ${RUNTIME_IMAGE} AS production
49
+ COPY --from=node /usr/local/bin/node /usr/local/bin/node
50
+ # Where ASP.NET Core keeps its Data Protection keys; docker-compose.prod.yml mounts a volume here.
51
+ RUN mkdir -p /home/app/.aspnet/DataProtection-Keys && chown -R app:app /home/app/.aspnet
52
+ WORKDIR /app
53
+ COPY --from=build /app ./
54
+ USER $APP_UID
55
+ EXPOSE 8080
56
+ HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \
57
+ 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"]
@@ -0,0 +1,32 @@
1
+ using Microsoft.AspNetCore.Mvc;
2
+
3
+ namespace __NAME__.Features.Home;
4
+
5
+ /// <summary>
6
+ /// The home page's loader (the C# counterpart of getServerSideProps) and a server action. Their types are generated
7
+ /// as zod schemas and typed references in generated/netjes.ts.
8
+ /// </summary>
9
+ public static class HomePage
10
+ {
11
+ public sealed record HomeProps(string Message, DateTimeOffset RenderedAt);
12
+
13
+ // Form inputs have init properties: a field the post leaves out, such as an unchecked checkbox, keeps its default,
14
+ // where form binding would refuse the post for a positional record.
15
+ public sealed record GreetInput
16
+ {
17
+ public string? Name { get; init; }
18
+ }
19
+
20
+ public sealed record Greeting(string Text);
21
+
22
+ public static void MapHomePage(this IEndpointRouteBuilder app)
23
+ {
24
+ app.MapPageLoader("/", (TimeProvider time) => Page.Props(new HomeProps("Hello from C#", time.GetUtcNow())));
25
+
26
+ // Called from components/HelloForm.tsx through serverActions['home.greet'].
27
+ app.MapAction("home.greet", ([FromForm] GreetInput input) =>
28
+ string.IsNullOrWhiteSpace(input.Name)
29
+ ? ServerAction.Invalid("name", "Tell us your name.")
30
+ : ServerAction.Ok(new Greeting($"Hello, {input.Name.Trim()}!")));
31
+ }
32
+ }
@@ -0,0 +1,28 @@
1
+ using __NAME__;
2
+ using __NAME__.Features.Home;
3
+
4
+ var builder = WebApplication.CreateBuilder(args);
5
+
6
+ builder.Services.AddNetjes(options =>
7
+ // Available in every page and layout through useSharedProps(sharedProps); typed in generated/netjes.ts.
8
+ options.UseSharedProps(_ => ValueTask.FromResult(new SiteProps("__TITLE__"))));
9
+ builder.Services.AddHealthChecks().AddNetjes();
10
+
11
+ var app = builder.Build();
12
+
13
+ if (!app.Environment.IsDevelopment())
14
+ {
15
+ // Once the site is served over https: HSTS on https responses, and http redirected to https. Behind a proxy
16
+ // that terminates TLS, add UseForwardedHeaders() first so the app sees the original scheme.
17
+ app.UseHsts();
18
+ app.UseHttpsRedirection();
19
+ }
20
+
21
+ app.UseNetjes();
22
+ app.UseAntiforgery(); // after authentication, if you add it
23
+
24
+ app.MapHealthChecks("/healthz");
25
+ app.MapHomePage();
26
+ app.MapPages(); // every other page file, plus the 404 fallback
27
+
28
+ app.Run();
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/launchsettings.json",
3
+ "profiles": {
4
+ "http": {
5
+ "commandName": "Project",
6
+ "launchBrowser": false,
7
+ "applicationUrl": "http://localhost:5080",
8
+ "environmentVariables": {
9
+ "ASPNETCORE_ENVIRONMENT": "Development"
10
+ }
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,24 @@
1
+ # __TITLE__
2
+
3
+ A React app on ASP.NET Core, built with Netjes: Vite, Tailwind CSS, TanStack Query, zod and Vitest are set up.
4
+
5
+ ```bash
6
+ npm run dev # dotnet watch + Vite HMR, http://localhost:5080
7
+ npm test # Vitest component tests
8
+ npm run typecheck
9
+ npm run routes # the route table, from C#
10
+ npx netjes build # types, client and server bundles
11
+ npx netjes info # check Node.js, the .NET SDK, the packages and this setup
12
+ dotnet publish -c Release
13
+ ```
14
+
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.
16
+ - `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
+ - `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.
18
+ - `AGENTS.md` tells coding agents how the app is laid out and what the rules are; `CLAUDE.md` imports it for Claude Code.
19
+ - `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.
21
+
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.
23
+
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.
@@ -0,0 +1,4 @@
1
+ namespace __NAME__;
2
+
3
+ /// <summary>The shared props every page and layout gets.</summary>
4
+ public sealed record SiteProps(string SiteName);
@@ -0,0 +1,64 @@
1
+ # __TITLE__
2
+
3
+ How this app is built, for coding agents and the people working with them. It is a [Netjes](https://github.com/DennisVerhoeven/netjes) app: ASP.NET Core receives every request and owns routing, data loading, server actions, authorization and caching; a Node sidecar renders the React pages; the browser hydrates them and navigates on the client.
4
+
5
+ ## Netjes docs
6
+
7
+ - https://raw.githubusercontent.com/DennisVerhoeven/netjes/main/llms.txt: an index of the docs; https://raw.githubusercontent.com/DennisVerhoeven/netjes/main/llms-full.txt has the ones for building apps in one file. For people, the same docs are at https://github.com/DennisVerhoeven/netjes/blob/main/docs/README.md.
8
+ - `.claude/skills/netjes/`: the conventions in short, with references for routing, data and actions, caching and streaming, testing and deployment. Read `SKILL.md` before changing pages, loaders or actions.
9
+ - The docs on main can be ahead of the version this app uses (`npx netjes --version`). The installed package, with its types, is in `node_modules/netjes/`.
10
+
11
+ ## Layout
12
+
13
+ | Path | What |
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. |
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
+ | `Program.cs` | Services and the pipeline: `AddNetjes`, `UseNetjes`, authentication (when you add it), `UseAntiforgery`, each feature's `Map...`, and `app.MapPages()` last. |
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. |
20
+ | `styles/global.css` | Tailwind CSS (`@import 'tailwindcss'`), imported by `app/layout.tsx`. |
21
+ | `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
+
23
+ ## Commands
24
+
25
+ ```bash
26
+ npm run dev # dotnet watch + Vite HMR on http://localhost:5080; rewrites generated/netjes.ts on start
27
+ npm test # Vitest
28
+ npm run typecheck # strict TypeScript
29
+ dotnet build # C#, warnings are errors
30
+ npx netjes generate # rewrite generated/netjes.ts after changing a C# type (--check: fail when stale)
31
+ npm run routes # the route table as ASP.NET Core sees it, with route warnings
32
+ npx netjes build # types, then the client and server bundles
33
+ dotnet publish -c Release # runs the build and ships dist/
34
+ ```
35
+
36
+ ## Adding a page with data
37
+
38
+ 1. Write the C# loader in `Features/<Feature>/`: a record for the props and `app.MapPageLoader("/blog/[slug]", (string slug, PostStore posts) => ...)` returning `Page.Props(new PostProps(...))`, `Page.NotFound()` or `Page.Redirect(url)`. Route parameters bind by name, like any minimal API.
39
+ 2. Call its `Map...` method from `Program.cs`, before `app.MapPages()`.
40
+ 3. Run `npx netjes generate` (or restart `npm run dev`).
41
+ 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.
43
+
44
+ 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
+
46
+ ## Rules
47
+
48
+ - 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
+ - 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.
51
+ - 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.
53
+ - 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.
56
+ - Write the failing test first, then the change.
57
+
58
+ ## Before you finish
59
+
60
+ ```bash
61
+ npm test && npm run typecheck && dotnet build && npx netjes generate --check
62
+ ```
63
+
64
+ Then `npm run routes` should print no warnings, and the page should work in `npm run dev`, including with JavaScript turned off for forms.
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,11 @@
1
+ <Project Sdk="Microsoft.NET.Sdk.Web">
2
+
3
+ <PropertyGroup>
4
+ <TargetFramework>net10.0</TargetFramework>
5
+ <Nullable>enable</Nullable>
6
+ <ImplicitUsings>enable</ImplicitUsings>
7
+ <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
8
+ </PropertyGroup>
9
+
10
+ <!--NETJES_REFERENCES-->
11
+ </Project>
@@ -0,0 +1,2 @@
1
+ # The generator writes LF; keep it so on checkout, or `netjes generate --check` sees a difference.
2
+ generated/** text eol=lf
@@ -0,0 +1,11 @@
1
+ node_modules/
2
+ bin/
3
+ obj/
4
+ /publish/
5
+ dist/
6
+ out/
7
+ .vite/
8
+ test-results/
9
+ playwright-report/
10
+ .env
11
+ .env.*
@@ -0,0 +1,17 @@
1
+ import type { Metadata } from 'netjes';
2
+
3
+ export const metadata: Metadata = { title: 'About' };
4
+
5
+ // No loader is mapped for this page, so it renders with empty props.
6
+ export default function About() {
7
+ return (
8
+ <article className="space-y-4">
9
+ <h1 className="text-3xl font-bold tracking-tight">About</h1>
10
+ <p className="text-slate-600">
11
+ Pages are React components in <code>app/</code>, as in the Next.js App Router. ASP.NET Core routes every
12
+ request, runs the page's C# loader and renders the page on the server; after that, links navigate in the
13
+ browser, and forms call C# server actions.
14
+ </p>
15
+ </article>
16
+ );
17
+ }
@@ -0,0 +1,15 @@
1
+ import type { ErrorPageProps } from 'netjes';
2
+
3
+ /** Errors below the root layout land here; the layout stays usable. */
4
+ export default function ErrorPage({ error, reset }: ErrorPageProps) {
5
+ return (
6
+ <section role="alert" className="space-y-4">
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>
10
+ <button type="button" onClick={reset} className="cursor-pointer rounded-full bg-slate-900 px-4 py-2 font-medium text-white">
11
+ Try again
12
+ </button>
13
+ </section>
14
+ );
15
+ }
@@ -0,0 +1,33 @@
1
+ import { Link, type LayoutProps, type Metadata, useSharedProps } from 'netjes';
2
+ import { sharedProps } from '../generated/netjes.ts';
3
+ import '../styles/global.css';
4
+
5
+ export const metadata: Metadata = {
6
+ title: { default: '__TITLE__', template: '%s · __TITLE__' },
7
+ icons: { icon: '/favicon.svg' },
8
+ };
9
+
10
+ const navLink = 'text-slate-500 no-underline hover:text-slate-900 aria-[current=page]:text-slate-900';
11
+
12
+ /** The root layout: wraps every page and stays mounted across navigations. */
13
+ export default function RootLayout({ children }: LayoutProps) {
14
+ const site = useSharedProps(sharedProps);
15
+ return (
16
+ <div className="mx-auto min-h-screen max-w-3xl px-6 py-10 font-sans text-slate-900">
17
+ <header className="mb-12 flex items-baseline justify-between">
18
+ <Link href="/" className="text-xl font-semibold no-underline">
19
+ {site.siteName}
20
+ </Link>
21
+ <nav aria-label="Main" className="flex gap-5">
22
+ <Link href="/" className={navLink}>
23
+ Home
24
+ </Link>
25
+ <Link href="/about" className={navLink}>
26
+ About
27
+ </Link>
28
+ </nav>
29
+ </header>
30
+ <main>{children}</main>
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,14 @@
1
+ import { Link, type Metadata, usePathname } from 'netjes';
2
+
3
+ export const metadata: Metadata = { title: 'Not found' };
4
+
5
+ export default function NotFound() {
6
+ return (
7
+ <section className="space-y-4">
8
+ <h1 className="text-3xl font-bold tracking-tight">Nothing at {usePathname()}</h1>
9
+ <Link href="/" className="underline">
10
+ Back to the home page
11
+ </Link>
12
+ </section>
13
+ );
14
+ }
@@ -0,0 +1,14 @@
1
+ import { definePage } from 'netjes';
2
+ import { Greeting } from '../components/Greeting.tsx';
3
+ import { HelloForm } from '../components/HelloForm.tsx';
4
+ import { pages } from '../generated/netjes.ts';
5
+
6
+ // The props schema is generated from the C# HomeProps record (Features/Home/HomePage.cs).
7
+ export default definePage(pages['/'], function Home({ message, renderedAt }) {
8
+ return (
9
+ <div className="space-y-12">
10
+ <Greeting message={message} renderedAt={renderedAt} />
11
+ <HelloForm />
12
+ </div>
13
+ );
14
+ });
@@ -0,0 +1,3 @@
1
+ {
2
+ "AllowedHosts": "localhost;127.0.0.1;[::1]"
3
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "Logging": {
3
+ "LogLevel": {
4
+ "Default": "Information",
5
+ "Microsoft.AspNetCore": "Warning"
6
+ }
7
+ },
8
+ "AllowedHosts": "*"
9
+ }
@@ -0,0 +1,25 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { createTestApp } from 'netjes/testing';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { Greeting } from './Greeting.tsx';
6
+
7
+ describe('Greeting', () => {
8
+ it('shows the message from the loader and counts clicks', async () => {
9
+ render(<Greeting message="Hello" renderedAt="2026-10-28T12:00:00Z" />, { wrapper: createTestApp().Wrapper });
10
+
11
+ await userEvent.click(screen.getByRole('button', { name: 'Clicked 0 times' }));
12
+
13
+ expect(screen.getByRole('heading', { name: 'Hello' })).toBeDefined();
14
+ expect(screen.getByRole('button', { name: 'Clicked 1 time' })).toBeDefined();
15
+ });
16
+
17
+ it('links to the about page without a full page load', async () => {
18
+ const app = createTestApp();
19
+ render(<Greeting message="Hello" renderedAt="2026-10-28T12:00:00Z" />, { wrapper: app.Wrapper });
20
+
21
+ await userEvent.click(screen.getByRole('link', { name: 'About this app' }));
22
+
23
+ expect(app.navigations).toEqual([{ type: 'push', href: '/about' }]);
24
+ });
25
+ });
@@ -0,0 +1,26 @@
1
+ import { Link } from 'netjes';
2
+ import { useState } from 'react';
3
+
4
+ export function Greeting({ message, renderedAt }: { message: string; renderedAt: string }) {
5
+ const [clicks, setClicks] = useState(0);
6
+ return (
7
+ <section className="space-y-5">
8
+ <h1 className="text-4xl font-bold tracking-tight">{message}</h1>
9
+ <p className="text-slate-600">
10
+ Rendered by ASP.NET Core at <time dateTime={renderedAt}>{renderedAt}</time>.
11
+ </p>
12
+ <button
13
+ type="button"
14
+ onClick={() => setClicks((count) => count + 1)}
15
+ className="cursor-pointer rounded-full bg-slate-900 px-4 py-2 font-medium text-white hover:bg-slate-700"
16
+ >
17
+ Clicked {clicks} {clicks === 1 ? 'time' : 'times'}
18
+ </button>
19
+ <p>
20
+ <Link href="/about" className="underline">
21
+ About this app
22
+ </Link>
23
+ </p>
24
+ </section>
25
+ );
26
+ }
@@ -0,0 +1,27 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { createTestApp } from 'netjes/testing';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { HelloForm } from './HelloForm.tsx';
6
+
7
+ describe('HelloForm', () => {
8
+ it('shows the greeting the server action returns', async () => {
9
+ const app = createTestApp({ actions: { 'home.greet': () => ({ ok: true, result: { text: 'Hello, Ada!' } }) } });
10
+ render(<HelloForm />, { wrapper: app.Wrapper });
11
+
12
+ await userEvent.type(screen.getByLabelText('Your name'), 'Ada');
13
+ await userEvent.click(screen.getByRole('button', { name: 'Say hello' }));
14
+
15
+ expect((await screen.findByRole('status')).textContent).toBe('Hello, Ada!');
16
+ });
17
+
18
+ it('shows the validation error from C# next to the field', async () => {
19
+ const app = createTestApp({ actions: { 'home.greet': () => ({ ok: false, status: 422, errors: { name: ['Tell us your name.'] } }) } });
20
+ render(<HelloForm />, { wrapper: app.Wrapper });
21
+
22
+ await userEvent.click(screen.getByRole('button', { name: 'Say hello' }));
23
+
24
+ expect(await screen.findByText('Tell us your name.')).toBeDefined();
25
+ expect(screen.getByLabelText('Your name').getAttribute('aria-invalid')).toBe('true');
26
+ });
27
+ });