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.
- package/LICENSE +21 -0
- package/README.md +54 -2
- package/bin/netjes.mjs +13 -0
- package/cli/agents.mjs +66 -0
- package/cli/commands.mjs +467 -0
- package/cli/help.mjs +89 -0
- package/cli/info.mjs +454 -0
- package/cli/main.mjs +167 -0
- package/cli/packages.mjs +19 -0
- package/cli/process-io.mjs +163 -0
- package/cli/routes.mjs +31 -0
- package/cli/scaffold.mjs +193 -0
- package/cli/suggest.mjs +50 -0
- package/cli/terminal.mjs +40 -0
- package/dist/actions.d.ts +98 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +176 -0
- package/dist/actions.js.map +1 -0
- package/dist/client-entry.d.ts +2 -0
- package/dist/client-entry.d.ts.map +1 -0
- package/dist/client-entry.js +126 -0
- package/dist/client-entry.js.map +1 -0
- package/dist/deferred.d.ts +39 -0
- package/dist/deferred.d.ts.map +1 -0
- package/dist/deferred.js +123 -0
- package/dist/deferred.js.map +1 -0
- package/dist/define-page.d.ts +15 -0
- package/dist/define-page.d.ts.map +1 -0
- package/dist/define-page.js +27 -0
- package/dist/define-page.js.map +1 -0
- package/dist/document.d.ts +19 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +17 -0
- package/dist/document.js.map +1 -0
- package/dist/draft-mode.d.ts +5 -0
- package/dist/draft-mode.d.ts.map +1 -0
- package/dist/draft-mode.js +6 -0
- package/dist/draft-mode.js.map +1 -0
- package/dist/dynamic.d.ts +20 -0
- package/dist/dynamic.d.ts.map +1 -0
- package/dist/dynamic.js +37 -0
- package/dist/dynamic.js.map +1 -0
- package/dist/form-data.d.ts +10 -0
- package/dist/form-data.d.ts.map +1 -0
- package/dist/form-data.js +10 -0
- package/dist/form-data.js.map +1 -0
- package/dist/form.d.ts +35 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +80 -0
- package/dist/form.js.map +1 -0
- package/dist/href.d.ts +25 -0
- package/dist/href.d.ts.map +1 -0
- package/dist/href.js +71 -0
- package/dist/href.js.map +1 -0
- package/dist/image.d.ts +36 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +60 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/link.d.ts +17 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +46 -0
- package/dist/link.js.map +1 -0
- package/dist/locale.d.ts +3 -0
- package/dist/locale.d.ts.map +1 -0
- package/dist/locale.js +6 -0
- package/dist/locale.js.map +1 -0
- package/dist/metadata.d.ts +83 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +100 -0
- package/dist/metadata.js.map +1 -0
- package/dist/modules.d.ts +74 -0
- package/dist/modules.d.ts.map +1 -0
- package/dist/modules.js +85 -0
- package/dist/modules.js.map +1 -0
- package/dist/nonce.d.ts +8 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/nonce.js +12 -0
- package/dist/nonce.js.map +1 -0
- package/dist/payload.d.ts +171 -0
- package/dist/payload.d.ts.map +1 -0
- package/dist/payload.js +273 -0
- package/dist/payload.js.map +1 -0
- package/dist/public-config.d.ts +11 -0
- package/dist/public-config.d.ts.map +1 -0
- package/dist/public-config.js +10 -0
- package/dist/public-config.js.map +1 -0
- package/dist/query.d.ts +22 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +68 -0
- package/dist/query.js.map +1 -0
- package/dist/render-cleanup.d.ts +6 -0
- package/dist/render-cleanup.d.ts.map +1 -0
- package/dist/render-cleanup.js +7 -0
- package/dist/render-cleanup.js.map +1 -0
- package/dist/root.d.ts +12 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +26 -0
- package/dist/root.js.map +1 -0
- package/dist/route-tree.d.ts +56 -0
- package/dist/route-tree.d.ts.map +1 -0
- package/dist/route-tree.js +86 -0
- package/dist/route-tree.js.map +1 -0
- package/dist/router.d.ts +118 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +938 -0
- package/dist/router.js.map +1 -0
- package/dist/schema.d.ts +35 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +74 -0
- package/dist/schema.js.map +1 -0
- package/dist/script.d.ts +23 -0
- package/dist/script.d.ts.map +1 -0
- package/dist/script.js +89 -0
- package/dist/script.js.map +1 -0
- package/dist/server-entry.d.ts +2 -0
- package/dist/server-entry.d.ts.map +1 -0
- package/dist/server-entry.js +4 -0
- package/dist/server-entry.js.map +1 -0
- package/dist/server-render.d.ts +19 -0
- package/dist/server-render.d.ts.map +1 -0
- package/dist/server-render.js +193 -0
- package/dist/server-render.js.map +1 -0
- package/dist/testing-setup.d.ts +2 -0
- package/dist/testing-setup.d.ts.map +1 -0
- package/dist/testing-setup.js +8 -0
- package/dist/testing-setup.js.map +1 -0
- package/dist/testing.d.ts +76 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +106 -0
- package/dist/testing.js.map +1 -0
- package/dist/vitals-config.d.ts +4 -0
- package/dist/vitals-config.d.ts.map +1 -0
- package/dist/vitals-config.js +5 -0
- package/dist/vitals-config.js.map +1 -0
- package/dist/vitals.d.ts +13 -0
- package/dist/vitals.d.ts.map +1 -0
- package/dist/vitals.js +60 -0
- package/dist/vitals.js.map +1 -0
- package/dist/zod.d.ts +19 -0
- package/dist/zod.d.ts.map +1 -0
- package/dist/zod.js +65 -0
- package/dist/zod.js.map +1 -0
- package/package.json +147 -4
- package/sidecar/server.mjs +325 -0
- package/skills/netjes/SKILL.md +93 -0
- package/skills/netjes/references/caching-and-streaming.md +68 -0
- package/skills/netjes/references/data-and-actions.md +98 -0
- package/skills/netjes/references/deployment.md +57 -0
- package/skills/netjes/references/routing.md +65 -0
- package/skills/netjes/references/testing.md +57 -0
- package/src/actions.tsx +241 -0
- package/src/client-entry.tsx +126 -0
- package/src/deferred.ts +134 -0
- package/src/define-page.tsx +41 -0
- package/src/document.tsx +49 -0
- package/src/draft-mode.ts +6 -0
- package/src/dynamic.tsx +64 -0
- package/src/form-data.ts +10 -0
- package/src/form.tsx +122 -0
- package/src/href.ts +79 -0
- package/src/image.tsx +91 -0
- package/src/index.ts +40 -0
- package/src/link.tsx +73 -0
- package/src/locale.ts +6 -0
- package/src/metadata.tsx +181 -0
- package/src/modules.ts +133 -0
- package/src/nonce.tsx +14 -0
- package/src/payload.ts +405 -0
- package/src/public-config.tsx +19 -0
- package/src/query.tsx +82 -0
- package/src/render-cleanup.ts +7 -0
- package/src/root.tsx +39 -0
- package/src/route-tree.tsx +139 -0
- package/src/router.tsx +1188 -0
- package/src/schema.ts +98 -0
- package/src/script.tsx +103 -0
- package/src/server-entry.tsx +4 -0
- package/src/server-render.tsx +223 -0
- package/src/testing-setup.ts +8 -0
- package/src/testing.tsx +177 -0
- package/src/virtual.d.ts +12 -0
- package/src/vitals-config.ts +6 -0
- package/src/vitals.tsx +68 -0
- package/src/zod.ts +72 -0
- package/template/.dockerignore +11 -0
- package/template/Dockerfile +58 -0
- package/template/Features/Home/HomePage.cs +32 -0
- package/template/Program.cs +28 -0
- package/template/Properties/launchSettings.json +13 -0
- package/template/README.md +24 -0
- package/template/SiteProps.cs +4 -0
- package/template/_AGENTS.md +64 -0
- package/template/_CLAUDE.md +1 -0
- package/template/__NAME__.csproj +11 -0
- package/template/_gitattributes +2 -0
- package/template/_gitignore +11 -0
- package/template/app/about/page.tsx +17 -0
- package/template/app/error.tsx +15 -0
- package/template/app/layout.tsx +33 -0
- package/template/app/not-found.tsx +14 -0
- package/template/app/page.tsx +14 -0
- package/template/appsettings.Development.json +3 -0
- package/template/appsettings.json +9 -0
- package/template/components/Greeting.test.tsx +25 -0
- package/template/components/Greeting.tsx +26 -0
- package/template/components/HelloForm.test.tsx +27 -0
- package/template/components/HelloForm.tsx +34 -0
- package/template/docker-compose.override.yml +15 -0
- package/template/docker-compose.prod.yml +26 -0
- package/template/docker-compose.yml +16 -0
- package/template/generated/netjes.ts +73 -0
- package/template/package.json +36 -0
- package/template/public/favicon.svg +6 -0
- package/template/styles/global.css +7 -0
- package/template/tsconfig.json +19 -0
- package/template/vite.config.ts +7 -0
- package/vite/plugin.d.ts +29 -0
- package/vite/plugin.mjs +291 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The Netjes sidecar: renders pages for the .NET server over loopback HTTP. Started and supervised by NodeSidecar.cs.
|
|
3
|
+
// development: hosts the Vite dev server (HMR for the browser) and renders with fresh modules on every request.
|
|
4
|
+
// production: renders with the prebuilt dist/server/server-entry.js.
|
|
5
|
+
// Protocol: POST /render with the HTML-safe page payload JSON as body -> 200 text/html, streamed from the moment the
|
|
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. Every request must carry the X-Netjes-Token the parent passed in
|
|
8
|
+
// NETJES_TOKEN. Renders are aborted when the caller disconnects, when the shell is not ready after
|
|
9
|
+
// NETJES_RENDER_TIMEOUT_MS, or when the whole stream takes longer than NETJES_STREAM_TIMEOUT_MS. Exits when stdin
|
|
10
|
+
// closes.
|
|
11
|
+
// Startup: prints NETJES_READY {"port","viteOrigin"} on stdout once it listens. When the Vite dev server does not start,
|
|
12
|
+
// it prints NETJES_FAILED and exits with code 1: {"reason":"vite-port-in-use","port"} when its port is taken, and
|
|
13
|
+
// {"reason":"vite-failed","message","code"} for anything else, such as an error in vite.config or a package it imports
|
|
14
|
+
// that is not installed (message in plain text; code is the error's when it has one, such as ERR_MODULE_NOT_FOUND, or
|
|
15
|
+
// MODULE_NOT_FOUND from a CommonJS config).
|
|
16
|
+
import http from 'node:http';
|
|
17
|
+
import { pipeline } from 'node:stream/promises';
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const mode = process.env.NETJES_MODE === 'development' ? 'development' : 'production';
|
|
21
|
+
const root = process.env.NETJES_ROOT ?? process.cwd();
|
|
22
|
+
const token = process.env.NETJES_TOKEN ?? '';
|
|
23
|
+
// Without a token the guard below would accept a request that sends an empty X-Netjes-Token header.
|
|
24
|
+
if (!token) throw new Error('NETJES_TOKEN is required: the sidecar is started by Netjes, which sets it');
|
|
25
|
+
// Page code runs in this process; it has no business knowing the token.
|
|
26
|
+
delete process.env.NETJES_TOKEN;
|
|
27
|
+
const renderTimeoutMs = Number(process.env.NETJES_RENDER_TIMEOUT_MS ?? 15_000);
|
|
28
|
+
const streamTimeoutMs = Number(process.env.NETJES_STREAM_TIMEOUT_MS ?? 60_000);
|
|
29
|
+
const vitePort = Number(process.env.NETJES_VITE_PORT ?? 5173);
|
|
30
|
+
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
31
|
+
const CSS_FILE = /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)$/;
|
|
32
|
+
// Terminal escape sequences (ECMA-48): CSI, as ESC [ or its one-character form (colours, as in Vite's code frames, which
|
|
33
|
+
// it colours in a pipe too), strings such as OSC (links), and the other escapes (a cursor save, say). An error page
|
|
34
|
+
// shows them as text, and a log file keeps them as such. Not Node's stripVTControlCharacters, which after ESC 7 (that
|
|
35
|
+
// cursor save) can take the next letter too. NodeSidecar.cs strips this process's output with the same pattern.
|
|
36
|
+
const TERMINAL_ESCAPES = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]|\u001b[\]PX^_][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[ -/]*[0-~]/g;
|
|
37
|
+
|
|
38
|
+
/** @param {string} text */
|
|
39
|
+
const plainText = (text) => text.replace(TERMINAL_ESCAPES, '');
|
|
40
|
+
|
|
41
|
+
/** @typedef {{ signal: AbortSignal, waitForAll?: boolean, deferred?: (id: string) => PromiseLike<unknown> }} RenderOptions */
|
|
42
|
+
/** @type {(data: string, options: RenderOptions) => Promise<string>} */
|
|
43
|
+
let render;
|
|
44
|
+
/** @type {((data: string, options: RenderOptions) => Promise<import('node:stream').Readable>) | undefined} */
|
|
45
|
+
let renderToStream;
|
|
46
|
+
/** @type {import('vite').ViteDevServer | undefined} */
|
|
47
|
+
let vite;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Says why the Vite dev server did not start, for .NET to report in a line of its own (Node's report of an uncaught
|
|
51
|
+
* error would bury it under its stack trace), and exits.
|
|
52
|
+
* @param {Record<string, unknown>} failed
|
|
53
|
+
* @returns {Promise<never>}
|
|
54
|
+
*/
|
|
55
|
+
async function failStartup(failed) {
|
|
56
|
+
await new Promise((resolve) => process.stdout.write(`NETJES_FAILED ${JSON.stringify(failed)}\n`, resolve));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @param {unknown} error */
|
|
61
|
+
const viteFailed = (error) =>
|
|
62
|
+
failStartup({
|
|
63
|
+
reason: 'vite-failed',
|
|
64
|
+
message: plainText(error instanceof Error ? error.message : String(error)),
|
|
65
|
+
code: error instanceof Error && 'code' in error && typeof error.code === 'string' ? error.code : undefined,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (mode === 'development') {
|
|
69
|
+
const origin = process.env.NETJES_VITE_ORIGIN ?? `http://localhost:${vitePort}`;
|
|
70
|
+
/** @type {import('vite').ViteDevServer} */
|
|
71
|
+
let dev;
|
|
72
|
+
try {
|
|
73
|
+
// Vite is the app's own dependency; it fails here when it is not installed, and on an error in vite.config.
|
|
74
|
+
const { createServer } = await import('vite');
|
|
75
|
+
dev = await createServer({
|
|
76
|
+
root,
|
|
77
|
+
appType: 'custom',
|
|
78
|
+
clearScreen: false,
|
|
79
|
+
server: { port: vitePort, strictPort: true, origin, host: process.env.NETJES_VITE_HOST || undefined },
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// It exits, so dev is set from here on.
|
|
83
|
+
dev = await viteFailed(error);
|
|
84
|
+
}
|
|
85
|
+
vite = dev;
|
|
86
|
+
try {
|
|
87
|
+
await dev.listen();
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// Most often another app's Vite dev server: .NET names the port and how to move it.
|
|
90
|
+
if (error instanceof Error && error.message === `Port ${vitePort} is already in use`) {
|
|
91
|
+
await failStartup({ reason: 'vite-port-in-use', port: vitePort });
|
|
92
|
+
}
|
|
93
|
+
await viteFailed(error);
|
|
94
|
+
}
|
|
95
|
+
/** The netjes plugin's development API (vite/plugin.mjs). */
|
|
96
|
+
const netjes = /** @type {{ rescanPages(): void } | undefined} */ (dev.config.plugins.find((plugin) => plugin.name === 'netjes')?.api);
|
|
97
|
+
/**
|
|
98
|
+
* Runs a render with the server entry. .NET routes a page file as soon as its own watcher sees it, but the page
|
|
99
|
+
* module map changes only when Vite's watcher reports a page file, which for this one can be much later (a file it
|
|
100
|
+
* missed is reported once something else changes in its folder) or never (a folder Vite does not watch). A render
|
|
101
|
+
* that names a file the map lacks has the plugin build the map again from the files on disk, and runs once more.
|
|
102
|
+
* @template T
|
|
103
|
+
* @param {(entry: Awaited<ReturnType<typeof dev.ssrLoadModule>>) => Promise<T>} run
|
|
104
|
+
*/
|
|
105
|
+
const withServerEntry = async (run) => {
|
|
106
|
+
try {
|
|
107
|
+
return await run(await dev.ssrLoadModule('netjes/server-entry'));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (!netjes || !(error instanceof Error) || error.name !== 'MissingModuleError') throw error;
|
|
110
|
+
netjes.rescanPages();
|
|
111
|
+
return run(await dev.ssrLoadModule('netjes/server-entry'));
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
render = async (data, options) => {
|
|
115
|
+
const styled = await withDevStyles(dev, data);
|
|
116
|
+
return withServerEntry((entry) => entry.render(styled, options));
|
|
117
|
+
};
|
|
118
|
+
renderToStream = async (data, options) => {
|
|
119
|
+
const styled = await withDevStyles(dev, data);
|
|
120
|
+
return withServerEntry((entry) => entry.renderToStream(styled, options));
|
|
121
|
+
};
|
|
122
|
+
} else {
|
|
123
|
+
const entry = process.env.NETJES_SERVER_ENTRY;
|
|
124
|
+
if (!entry) throw new Error('NETJES_SERVER_ENTRY is not set');
|
|
125
|
+
({ render, renderToStream } = await import(pathToFileURL(entry).href));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 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.
|
|
131
|
+
* @param {import('vite').ViteDevServer} server
|
|
132
|
+
* @param {string} data
|
|
133
|
+
*/
|
|
134
|
+
async function withDevStyles(server, data) {
|
|
135
|
+
const payload = JSON.parse(data);
|
|
136
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
137
|
+
const segments = payload.segments ?? [];
|
|
138
|
+
const files = [...segments.flatMap((s) => [s.layout, s.template, s.loading, s.error]), payload.page].filter(
|
|
139
|
+
(file) => typeof file === 'string',
|
|
140
|
+
);
|
|
141
|
+
const environment = server.environments.ssr;
|
|
142
|
+
/** @type {Map<string, string>} */
|
|
143
|
+
const styles = new Map();
|
|
144
|
+
/** @type {Set<string>} */
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
|
|
147
|
+
/** @param {import('vite').EnvironmentModuleNode | undefined} node */
|
|
148
|
+
async function collect(node) {
|
|
149
|
+
if (!node?.id || seen.has(node.id)) return;
|
|
150
|
+
seen.add(node.id);
|
|
151
|
+
if (node.file && CSS_FILE.test(node.file) && !node.id.includes('?')) {
|
|
152
|
+
const css = await server.ssrLoadModule(`${node.url}?inline`);
|
|
153
|
+
styles.set(node.file, String(css.default));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
for (const imported of node.importedModules) await collect(imported);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const file of files) {
|
|
160
|
+
await server.ssrLoadModule(`/${file}`);
|
|
161
|
+
await collect(await environment.moduleGraph.getModuleByUrl(`/${file}`));
|
|
162
|
+
}
|
|
163
|
+
if (styles.size === 0) return data;
|
|
164
|
+
payload.document.devStyles = [...styles].map(([id, css]) => ({ id, css }));
|
|
165
|
+
return JSON.stringify(payload).replace(/[<>&\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Page.Defer values per render, as .NET posts them to /resolve. Values may arrive before the render asks for them;
|
|
170
|
+
* entries nobody claims are dropped after the stream timeout.
|
|
171
|
+
* @typedef {{ promise: Promise<unknown>, resolve: (value: unknown) => void, reject: (error: Error) => void }} Pending
|
|
172
|
+
* @type {Map<string, { values: Map<string, Pending>, timer: NodeJS.Timeout }>}
|
|
173
|
+
*/
|
|
174
|
+
const renders = new Map();
|
|
175
|
+
|
|
176
|
+
/** @param {string} renderId */
|
|
177
|
+
function renderEntry(renderId) {
|
|
178
|
+
let entry = renders.get(renderId);
|
|
179
|
+
if (!entry) {
|
|
180
|
+
const timer = setTimeout(() => renders.delete(renderId), streamTimeoutMs + 5_000);
|
|
181
|
+
timer.unref();
|
|
182
|
+
entry = { values: new Map(), timer };
|
|
183
|
+
renders.set(renderId, entry);
|
|
184
|
+
}
|
|
185
|
+
return entry;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** @param {string} renderId @param {string} id */
|
|
189
|
+
function pendingValue(renderId, id) {
|
|
190
|
+
const { values } = renderEntry(renderId);
|
|
191
|
+
let pending = values.get(id);
|
|
192
|
+
if (!pending) {
|
|
193
|
+
/** @type {(value: unknown) => void} */
|
|
194
|
+
let resolve = () => {};
|
|
195
|
+
/** @type {(error: Error) => void} */
|
|
196
|
+
let reject = () => {};
|
|
197
|
+
const promise = new Promise((res, rej) => {
|
|
198
|
+
resolve = res;
|
|
199
|
+
reject = rej;
|
|
200
|
+
});
|
|
201
|
+
promise.catch(() => {});
|
|
202
|
+
pending = { promise, resolve, reject };
|
|
203
|
+
values.set(id, pending);
|
|
204
|
+
}
|
|
205
|
+
return pending;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @param {string} renderId */
|
|
209
|
+
function forgetRender(renderId) {
|
|
210
|
+
const entry = renders.get(renderId);
|
|
211
|
+
if (!entry) return;
|
|
212
|
+
clearTimeout(entry.timer);
|
|
213
|
+
renders.delete(renderId);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @param {http.IncomingMessage} req */
|
|
217
|
+
async function readBody(req) {
|
|
218
|
+
/** @type {Buffer[]} */
|
|
219
|
+
const chunks = [];
|
|
220
|
+
let size = 0;
|
|
221
|
+
for await (const chunk of req) {
|
|
222
|
+
size += chunk.length;
|
|
223
|
+
if (size > MAX_BODY_BYTES) throw new Error('payload too large');
|
|
224
|
+
chunks.push(chunk);
|
|
225
|
+
}
|
|
226
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const server = http.createServer(async (req, res) => {
|
|
230
|
+
if (req.headers['x-netjes-token'] !== token) {
|
|
231
|
+
res.writeHead(403).end();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
235
|
+
res.writeHead(200, { 'content-type': 'text/plain' }).end('ok');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (req.method === 'POST' && req.url === '/resolve') {
|
|
239
|
+
try {
|
|
240
|
+
const { renderId, id, value, error } = JSON.parse(await readBody(req));
|
|
241
|
+
if (typeof renderId !== 'string' || typeof id !== 'string') throw new Error('renderId and id are required');
|
|
242
|
+
const pending = pendingValue(renderId, id);
|
|
243
|
+
if (typeof error === 'string') pending.reject(new Error(error));
|
|
244
|
+
else pending.resolve(value);
|
|
245
|
+
res.writeHead(204).end();
|
|
246
|
+
} catch (error) {
|
|
247
|
+
res.writeHead(400, { 'content-type': 'text/plain' }).end(error instanceof Error ? error.message : String(error));
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (req.method !== 'POST' || req.url !== '/render') {
|
|
252
|
+
res.writeHead(404).end();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// Stop rendering once nobody is waiting for the result: the caller went away or the time is up.
|
|
256
|
+
const controller = new AbortController();
|
|
257
|
+
// Waiting for everything (crawlers) includes the slow Suspense boundaries, so it gets the stream's time limit.
|
|
258
|
+
const waitForAll = req.headers['x-netjes-wait-all'] === '1';
|
|
259
|
+
const shellTimeoutMs = waitForAll ? streamTimeoutMs : renderTimeoutMs;
|
|
260
|
+
let timer = setTimeout(() => controller.abort(new Error(`Rendering timed out after ${shellTimeoutMs}ms`)), shellTimeoutMs);
|
|
261
|
+
res.on('close', () => {
|
|
262
|
+
if (!res.writableFinished) controller.abort(new Error('The caller stopped waiting for the render'));
|
|
263
|
+
});
|
|
264
|
+
const renderId = typeof req.headers['x-netjes-render-id'] === 'string' ? req.headers['x-netjes-render-id'] : undefined;
|
|
265
|
+
/** @type {RenderOptions} */
|
|
266
|
+
const options = { signal: controller.signal, waitForAll };
|
|
267
|
+
if (renderId) {
|
|
268
|
+
options.deferred = (id) => pendingValue(renderId, id).promise;
|
|
269
|
+
res.on('close', () => forgetRender(renderId));
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
const data = await readBody(req);
|
|
273
|
+
if (!renderToStream) {
|
|
274
|
+
const html = await render(data, options);
|
|
275
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(html);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const stream = await renderToStream(data, options);
|
|
279
|
+
// The shell is out; what is left may take longer (Suspense boundaries), up to the stream timeout.
|
|
280
|
+
clearTimeout(timer);
|
|
281
|
+
timer = setTimeout(() => controller.abort(new Error(`Streaming timed out after ${streamTimeoutMs}ms`)), streamTimeoutMs);
|
|
282
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'x-accel-buffering': 'no' });
|
|
283
|
+
try {
|
|
284
|
+
await pipeline(stream, res);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (!controller.signal.aborted) console.error('netjes: streaming failed', error);
|
|
287
|
+
res.destroy();
|
|
288
|
+
}
|
|
289
|
+
} catch (error) {
|
|
290
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
291
|
+
vite?.ssrFixStacktrace(err);
|
|
292
|
+
const message = plainText(err.message);
|
|
293
|
+
const stack = err.stack === undefined ? undefined : plainText(err.stack);
|
|
294
|
+
// The .NET span that asked for this render (W3C trace context), to find it next to the request's trace.
|
|
295
|
+
const trace = req.headers.traceparent ? ` traceparent=${req.headers.traceparent}` : '';
|
|
296
|
+
console.error(`netjes: render failed${trace}\n${stack ?? message}`);
|
|
297
|
+
if (!res.destroyed) {
|
|
298
|
+
res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: { message, stack } }));
|
|
299
|
+
}
|
|
300
|
+
} finally {
|
|
301
|
+
clearTimeout(timer);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
server.listen(0, '127.0.0.1', () => {
|
|
306
|
+
const address = server.address();
|
|
307
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
308
|
+
const viteOrigin = vite ? (vite.config.server.origin ?? `http://localhost:${vitePort}`) : null;
|
|
309
|
+
process.stdout.write(`NETJES_READY ${JSON.stringify({ port, viteOrigin })}\n`);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
async function shutdown() {
|
|
313
|
+
server.close();
|
|
314
|
+
await vite?.close();
|
|
315
|
+
process.exit(0);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// A promise rejected by page code without a handler would otherwise kill every render in flight.
|
|
319
|
+
process.on('unhandledRejection', (reason) => console.error('netjes: unhandled promise rejection', reason));
|
|
320
|
+
|
|
321
|
+
// The parent keeps our stdin open for as long as it lives; EOF means it is gone.
|
|
322
|
+
process.stdin.on('end', shutdown);
|
|
323
|
+
process.stdin.resume();
|
|
324
|
+
process.on('SIGTERM', shutdown);
|
|
325
|
+
process.on('SIGINT', shutdown);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: netjes
|
|
3
|
+
description: Use when building or changing a Netjes app (React pages in app/ served by ASP.NET Core): adding pages, layouts, C# loaders, server actions, JSON endpoints, caching, streaming, tests or deployment. Covers the app/ and Features/ conventions, generated/netjes.ts, and the commands that check the work.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Building a Netjes app
|
|
7
|
+
|
|
8
|
+
Netjes renders React pages from ASP.NET Core. C# receives every request and owns routing, loaders, server actions, authorization and caching. A Node sidecar renders the React tree to HTML; the browser hydrates it, and later navigations fetch the next page's props as JSON from the same C# endpoints. The file conventions in `app/` are the Next.js App Router's, but there are no React Server Components and no `route.ts`: data comes from C# loaders, and APIs are minimal API endpoints.
|
|
9
|
+
|
|
10
|
+
## Where things go
|
|
11
|
+
|
|
12
|
+
- `app/**/page.tsx`: a page. `app/blog/[slug]/page.tsx` serves `/blog/{slug}`. Layouts, boundaries and folder rules: [references/routing.md](references/routing.md).
|
|
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
|
+
- `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
|
+
- `components/`: React components and their tests. Testing: [references/testing.md](references/testing.md).
|
|
16
|
+
|
|
17
|
+
## The loop for any change
|
|
18
|
+
|
|
19
|
+
1. Write the failing test first: a Vitest component test, or a C# test for loader and action logic.
|
|
20
|
+
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.
|
|
23
|
+
|
|
24
|
+
## A page with data, end to end
|
|
25
|
+
|
|
26
|
+
```csharp
|
|
27
|
+
// Features/Posts/PostPages.cs
|
|
28
|
+
namespace MyApp.Features.Posts;
|
|
29
|
+
|
|
30
|
+
public static class PostPages
|
|
31
|
+
{
|
|
32
|
+
public sealed record PostProps(string Slug, string Title, string Body);
|
|
33
|
+
|
|
34
|
+
public static void MapPostPages(this IEndpointRouteBuilder app)
|
|
35
|
+
{
|
|
36
|
+
// The parameter name matches [slug]; Page.Props names the props type for the generated schema.
|
|
37
|
+
app.MapPageLoader("/blog/[slug]", (string slug, PostStore posts) =>
|
|
38
|
+
posts.Find(slug) is { } post
|
|
39
|
+
? Page.Props(new PostProps(post.Slug, post.Title, post.Body))
|
|
40
|
+
.WithMetadata(new PageMetadata { Title = post.Title })
|
|
41
|
+
: Page.NotFound());
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
// app/blog/[slug]/page.tsx
|
|
48
|
+
import { Link, definePage } from 'netjes';
|
|
49
|
+
import { pages, route } from '../../../generated/netjes.ts';
|
|
50
|
+
|
|
51
|
+
export default definePage(pages['/blog/[slug]'], function Post({ title, body }) {
|
|
52
|
+
return (
|
|
53
|
+
<article>
|
|
54
|
+
<Link href={route('/blog')}>All posts</Link>
|
|
55
|
+
<h1>{title}</h1>
|
|
56
|
+
<p>{body}</p>
|
|
57
|
+
</article>
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`Program.cs` calls `app.MapPostPages();` before `app.MapPages();`. A page without a loader is a plain default export with no props.
|
|
63
|
+
|
|
64
|
+
## Rules
|
|
65
|
+
|
|
66
|
+
- Decisions live in C#: data access, validation, authorization (`.RequireAuthorization()`, `Page.Forbidden()`), redirects, caching. React renders what it is sent.
|
|
67
|
+
- 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.
|
|
69
|
+
- Server state goes through TanStack Query, seeded from the loader with `.WithQuery(key, data)`; no `useEffect` fetching.
|
|
70
|
+
- Internal links use `<Link>` and `route()`; forms use `<Form>` with `useAction`, and must work without JavaScript.
|
|
71
|
+
- 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.
|
|
73
|
+
- 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
|
+
- Shipping: [references/deployment.md](references/deployment.md).
|
|
75
|
+
|
|
76
|
+
## Commands
|
|
77
|
+
|
|
78
|
+
| Command | What it does |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `npm run dev` | `dotnet watch` with Vite HMR on http://localhost:5080 |
|
|
81
|
+
| `npm test`, `npm run typecheck` | Vitest; strict TypeScript |
|
|
82
|
+
| `npx netjes generate [--check]` | Rewrite `generated/netjes.ts`; `--check` fails when it is stale |
|
|
83
|
+
| `npm run routes` (`netjes routes [--json]`) | The route table and route warnings, as ASP.NET Core sees them |
|
|
84
|
+
| `npx netjes build` | Types, then the client and server bundles |
|
|
85
|
+
| `npx netjes start` | Run in Production |
|
|
86
|
+
| `npx netjes export [dir]` | Write the site as static HTML |
|
|
87
|
+
| `npx netjes info` | Versions and a check of the project setup |
|
|
88
|
+
|
|
89
|
+
## More
|
|
90
|
+
|
|
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), [metadata](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/metadata.md), [images](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/images.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), [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
|
+
|
|
93
|
+
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/`.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Caching, streaming and static export
|
|
2
|
+
|
|
3
|
+
## Output-cached pages
|
|
4
|
+
|
|
5
|
+
```csharp
|
|
6
|
+
builder.Services.AddOutputCache();
|
|
7
|
+
// ...
|
|
8
|
+
app.UseNetjes();
|
|
9
|
+
app.UseAuthentication();
|
|
10
|
+
app.UseAuthorization();
|
|
11
|
+
app.UseAntiforgery();
|
|
12
|
+
app.UseOutputCache(); // after UseNetjes and after UseAuthentication, so signed-in users bypass it
|
|
13
|
+
|
|
14
|
+
app.MapPageLoader("/blog/[slug]", (string slug, PostStore posts) => ...)
|
|
15
|
+
.CachePage(TimeSpan.FromMinutes(5), "posts");
|
|
16
|
+
```
|
|
17
|
+
|
|
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
|
+
- 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
|
+
- 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
|
+
- Keys include the host and the full URL, case included. In production pin `AllowedHosts` and set the output cache's `SizeLimit`.
|
|
23
|
+
- 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.
|
|
24
|
+
- 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
|
+
|
|
26
|
+
## TanStack Query
|
|
27
|
+
|
|
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`.
|
|
29
|
+
|
|
30
|
+
## Streaming slow data
|
|
31
|
+
|
|
32
|
+
```csharp
|
|
33
|
+
public sealed record TaskPageProps(TaskDetail Task, Deferred<IReadOnlyList<ActivityItem>> Activity);
|
|
34
|
+
|
|
35
|
+
app.MapPageLoader("/tasks/[id]", (string id, IDbContextFactory<AppDb> contexts, HttpContext http) =>
|
|
36
|
+
Page.Props(new TaskPageProps(LoadTask(id), Page.Defer(ActivityAsync(contexts, id, http.RequestAborted)))));
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
// The generated schema types `activity` as a promise, validated when it arrives (deferred() from netjes/zod).
|
|
41
|
+
<Suspense fallback={<p>Loading activity…</p>}>
|
|
42
|
+
<ActivityFeed activity={activity} />
|
|
43
|
+
</Suspense>
|
|
44
|
+
|
|
45
|
+
function ActivityFeed({ activity }: { activity: PromiseLike<ActivityItem[]> }) {
|
|
46
|
+
const items = use(activity);
|
|
47
|
+
return <ol>{items.map((item) => <li key={item.id}>{item.summary}</li>)}</ol>;
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- The shell goes out first, with `loading.tsx` or the Suspense fallback where a value is pending, and each value streams in when its task completes. Client navigations stream the same way.
|
|
52
|
+
- 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
|
+
- Without JavaScript, a fallback that is still pending stays. Crawlers (`CrawlerUserAgents`), apps with `StreamDocuments = false` and `netjes export` get the complete document.
|
|
54
|
+
- A boundary that is ready when the shell goes out is written in place, so it shows without JavaScript too.
|
|
55
|
+
|
|
56
|
+
## Static export
|
|
57
|
+
|
|
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.
|
|
59
|
+
|
|
60
|
+
## Sitemap and robots
|
|
61
|
+
|
|
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`.
|
|
63
|
+
|
|
64
|
+
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
|
+
|
|
66
|
+
## More
|
|
67
|
+
|
|
68
|
+
[Caching](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/caching.md), [Rendering and streaming](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/rendering-and-streaming.md) and [Static export](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/static-export.md) in the guides.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Data and actions
|
|
2
|
+
|
|
3
|
+
Everything a page shows comes from C#: page loaders, layout loaders, shared props and JSON endpoints. Everything a page changes goes through C#: server actions. Their C# types become zod schemas and typed references in `generated/netjes.ts`.
|
|
4
|
+
|
|
5
|
+
## Page loaders
|
|
6
|
+
|
|
7
|
+
```csharp
|
|
8
|
+
app.MapPageLoader("/projects/[id]", async (string id, ClaimsPrincipal user, AppDb db, CancellationToken ct) =>
|
|
9
|
+
{
|
|
10
|
+
var project = await db.FindProjectAsync(id, ct);
|
|
11
|
+
if (project is null) return Page.NotFound();
|
|
12
|
+
if (!project.HasMember(user)) return Page.Forbidden();
|
|
13
|
+
return Page.Props(new ProjectProps(project.Name, project.Tasks))
|
|
14
|
+
.WithQuery(["projects", id, "stats"], await db.StatsAsync(id, ct))
|
|
15
|
+
.WithMetadata(new PageMetadata { Title = project.Name });
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- It binds like a minimal API: route values by name, services, `ClaimsPrincipal`, `HttpContext`, `CancellationToken`, `[FromQuery]`.
|
|
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
|
+
- `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
|
+
- It returns a `RouteHandlerBuilder`: `.RequireAuthorization()`, `.CachePage(...)` and filters apply.
|
|
23
|
+
- A page's loader runs before its layouts' loaders, so a page under a layout that checks access checks it too.
|
|
24
|
+
|
|
25
|
+
## Layout loaders
|
|
26
|
+
|
|
27
|
+
`app.MapLayoutLoader("/projects/[id]", ...)` loads the data of `app/.../projects/[id]/layout.tsx` (by URL, or by folder: `app/(shop)`). The layout reads it with `defineLayout(layouts['app/(app)/projects/[id]'], function Layout({ project, children }) { ... })`. Its conventions (`.RequireAuthorization()`) apply to every page below. `NotFound`/`Forbidden` from it render the nearest boundary above the layout.
|
|
28
|
+
|
|
29
|
+
- It binds only the route values of its own URL (`id` here, never a deeper `taskId`; a `[FromRoute]` naming one stops the app at startup), the query string and services. Nothing binds from a body: a complex type needs to be a service, `[AsParameters]` or bind itself, and `[FromBody]`, `[FromForm]` or a file stops the app at startup.
|
|
30
|
+
- On client navigations within the section it is skipped while its route values, user and roles are unchanged, so checks that must run on every request belong in conventions or page loaders, and it must not depend on the query string or cookies.
|
|
31
|
+
|
|
32
|
+
## Shared props
|
|
33
|
+
|
|
34
|
+
`options.UseSharedProps(http => ValueTask.FromResult(new SiteProps(...)))` in `AddNetjes` gives every page and layout the same props: `useSharedProps(sharedProps)`. Typical: the signed-in user, from `http.User`. They are sent to the browser.
|
|
35
|
+
|
|
36
|
+
## Server actions
|
|
37
|
+
|
|
38
|
+
```csharp
|
|
39
|
+
using System.ComponentModel.DataAnnotations;
|
|
40
|
+
using Microsoft.AspNetCore.Mvc; // [FromForm]: not in the implicit usings
|
|
41
|
+
|
|
42
|
+
public sealed record Renamed(string Name);
|
|
43
|
+
|
|
44
|
+
public sealed record RenameInput
|
|
45
|
+
{
|
|
46
|
+
[Required] public string Id { get; init; } = "";
|
|
47
|
+
[Required(ErrorMessage = "Give it a name."), StringLength(100)] public string Name { get; init; } = "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
app.MapAction("projects.rename", async ([FromForm] RenameInput input, AppDb db, CancellationToken ct) =>
|
|
51
|
+
await db.TryRenameAsync(input.Id, input.Name, ct)
|
|
52
|
+
? ServerAction.Ok(new Renamed(input.Name)).RevalidatePath($"/projects/{input.Id}")
|
|
53
|
+
: ServerAction.Invalid("name", "That name is taken."));
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
const [state, formAction, pending] = useAction(serverActions['projects.rename']);
|
|
58
|
+
const nameError = state?.ok === false ? state.errors.name?.[0] : undefined;
|
|
59
|
+
|
|
60
|
+
<Form action={formAction}>
|
|
61
|
+
<input type="hidden" name="id" value={id} />
|
|
62
|
+
<input name="name" defaultValue={state?.ok === false ? String(state.values?.name ?? '') : name} aria-invalid={nameError ? true : undefined} />
|
|
63
|
+
{nameError && <p>{nameError}</p>}
|
|
64
|
+
<button disabled={pending}>Rename</button>
|
|
65
|
+
</Form>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- 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
|
+
- `[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
|
+
- 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
|
+
- Optimistic UI: `useAction(action, { onSubmit(formData) { setOptimistic(...) } })` with React's `useOptimistic`.
|
|
73
|
+
- Every action needs `app.UseAntiforgery()` (after authentication) and checks the origin; `<Form>` and `serverActions` send the token. `MapAction` bodies are limited to 1 MB (`ActionBodySizeLimit`); an upload action sets `[RequestSizeLimit(bytes)]`. Page actions keep the server's limit.
|
|
74
|
+
- Name actions `feature.verb`. Check authorization inside the action, or with `.RequireAuthorization()`, as for any endpoint.
|
|
75
|
+
- `app.MapPageAction("/guestbook", ...)` is the other kind: a POST to the page's own URL from `<Form method="post">`, fields typed in `forms['/guestbook']`. Its input's DataAnnotations and `IValidatableObject` are checked as for `MapAction`: on a failure the delegate does not run, the page renders again from its loader (422), and `const failed = usePageActionState()` gives `failed.errors` and `failed.values` (null otherwise). Checks that need data stay in the delegate: return `Page.Props(...)` with your own errors and the typed values (`.WithStatus(422)`), or `Page.Redirect(...)` after a change.
|
|
76
|
+
- An action or post that sets or deletes a cookie (signing in or out) makes the client router forget the pages it keeps for back and forward, reload the page on screen and clear the query cache, in every tab of the app in that browser. Nothing to do in the action; a proxy must pass `X-Netjes-Cookies` through.
|
|
77
|
+
|
|
78
|
+
## JSON endpoints and TanStack Query
|
|
79
|
+
|
|
80
|
+
```csharp
|
|
81
|
+
app.MapGet("/api/projects/{slug}/stats", async Task<Results<Ok<ProjectStats>, NotFound>> (string slug, AppDb db) => ...)
|
|
82
|
+
.RequireAuthorization();
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
async function fetchStats(slug: string) {
|
|
87
|
+
const response = await fetch(`/api/projects/${encodeURIComponent(slug)}/stats`);
|
|
88
|
+
if (!response.ok) throw new Error(`Stats request failed with ${response.status}`);
|
|
89
|
+
return api['GET /api/projects/{slug}/stats'].response.parse(await response.json());
|
|
90
|
+
}
|
|
91
|
+
const stats = useQuery({ queryKey: ['projects', slug, 'stats'], queryFn: () => fetchStats(slug) });
|
|
92
|
+
```
|
|
93
|
+
|
|
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).
|
|
95
|
+
|
|
96
|
+
## More
|
|
97
|
+
|
|
98
|
+
[Data loading](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/data-loading.md) and [Actions and forms](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/actions-and-forms.md) in the guides.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Deployment
|
|
2
|
+
|
|
3
|
+
A Netjes app is an ASP.NET Core app that also needs Node at runtime: the .NET process starts and supervises the Node sidecar that renders pages (`NetjesOptions.NodeExecutable`, default `node` on the `PATH`). Only `netjes export` output runs without either.
|
|
4
|
+
|
|
5
|
+
## Build and publish
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx netjes generate --check # in CI: the generated types match the C# types
|
|
9
|
+
npm test && npm run typecheck && dotnet build
|
|
10
|
+
dotnet publish -c Release -o bin/publish # runs `npm run build`, ships dist/ (client, server bundle, sidecar)
|
|
11
|
+
cd bin/publish && ASPNETCORE_ENVIRONMENT=Production dotnet MyApp.dll
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`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
|
+
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
|
+
|
|
18
|
+
## Docker
|
|
19
|
+
|
|
20
|
+
`netjes new` writes a `Dockerfile` and Compose files:
|
|
21
|
+
|
|
22
|
+
- `docker compose up`: development, with `dotnet watch` and Vite HMR, ports bound to localhost.
|
|
23
|
+
- `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.
|
|
24
|
+
|
|
25
|
+
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
|
+
|
|
27
|
+
## Production settings
|
|
28
|
+
|
|
29
|
+
- `AllowedHosts`: pin it to the site's host names. `appsettings.Development.json` already limits Development to loopback names.
|
|
30
|
+
- HTTPS: outside Development the template calls `UseHsts()` and `UseHttpsRedirection()`. Behind a proxy that terminates TLS, call `UseForwardedHeaders()` first, trusting only that proxy's network (`KnownIPNetworks`), so the app sees https and the visitor's address.
|
|
31
|
+
- 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
|
+
- Several instances behind a load balancer also share the output-cache store and need the same `Netjes:SegmentKeySecret` (32 bytes or more).
|
|
33
|
+
- `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")`.
|
|
35
|
+
- Output caching: set `SizeLimit`, and see [caching-and-streaming.md](caching-and-streaming.md).
|
|
36
|
+
- Security headers: every document gets `X-Content-Type-Options`, `Referrer-Policy` and a same-origin framing policy unless the app sets them (`Netjes:SecurityHeaders`). A Content-Security-Policy is off until you set `NetjesOptions.ContentSecurityPolicy`, with `{nonce}` for a nonce-based policy.
|
|
37
|
+
- `Netjes:PublicConfig` is sent to the browser as is; secrets go in other configuration.
|
|
38
|
+
- Telemetry: add the `Netjes` ActivitySource and Meter (`NetjesTelemetry.Name`) to OpenTelemetry for loader, action and render spans and render durations.
|
|
39
|
+
|
|
40
|
+
## Startup work and CLI commands
|
|
41
|
+
|
|
42
|
+
`netjes generate` and `netjes routes` run the app once in command mode, without serving or starting Node. Skip startup work they do not need, such as migrations or seeding:
|
|
43
|
+
|
|
44
|
+
```csharp
|
|
45
|
+
if (app.GetNetjesCommand() is null)
|
|
46
|
+
{
|
|
47
|
+
await SeedAsync(app.Services);
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Static hosting
|
|
52
|
+
|
|
53
|
+
`npx netjes export` writes the site as static HTML to `out/` for any static host; see [caching-and-streaming.md](caching-and-streaming.md#static-export) for what it needs and refuses.
|
|
54
|
+
|
|
55
|
+
## More
|
|
56
|
+
|
|
57
|
+
[Deployment](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/deployment.md) and [Security](https://github.com/DennisVerhoeven/netjes/blob/main/docs/guides/security.md) in the guides, and every option in [Options](https://github.com/DennisVerhoeven/netjes/blob/main/docs/reference/options.md).
|