netjes 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -11
- package/cli/agents.mjs +25 -2
- package/cli/commands.mjs +56 -6
- package/cli/help.mjs +7 -2
- package/cli/info.mjs +275 -33
- package/cli/routes.mjs +35 -8
- package/cli/scaffold.mjs +31 -4
- package/dist/actions.d.ts +7 -2
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +9 -3
- package/dist/actions.js.map +1 -1
- package/dist/client-entry.d.ts +1 -0
- package/dist/client-entry.d.ts.map +1 -1
- package/dist/client-entry.js +19 -2
- package/dist/client-entry.js.map +1 -1
- package/dist/deferred.d.ts +11 -0
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +15 -1
- package/dist/deferred.js.map +1 -1
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +10 -1
- package/dist/document.js.map +1 -1
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +11 -0
- package/dist/errors.js.map +1 -0
- package/dist/form.d.ts +5 -5
- package/dist/form.d.ts.map +1 -1
- package/dist/form.js +5 -2
- package/dist/form.js.map +1 -1
- package/dist/href.d.ts +31 -3
- package/dist/href.d.ts.map +1 -1
- package/dist/href.js +131 -10
- package/dist/href.js.map +1 -1
- package/dist/image.d.ts +3 -3
- package/dist/image.d.ts.map +1 -1
- package/dist/image.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/link.d.ts +15 -7
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +38 -7
- package/dist/link.js.map +1 -1
- package/dist/metadata.d.ts +8 -2
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +14 -5
- package/dist/metadata.js.map +1 -1
- package/dist/modules.d.ts +15 -1
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js.map +1 -1
- package/dist/mounted.d.ts +55 -0
- package/dist/mounted.d.ts.map +1 -0
- package/dist/mounted.js +110 -0
- package/dist/mounted.js.map +1 -0
- package/dist/navigation-focus.d.ts +38 -0
- package/dist/navigation-focus.d.ts.map +1 -0
- package/dist/navigation-focus.js +71 -0
- package/dist/navigation-focus.js.map +1 -0
- package/dist/payload.d.ts +8 -0
- package/dist/payload.d.ts.map +1 -1
- package/dist/payload.js +28 -0
- package/dist/payload.js.map +1 -1
- package/dist/query.d.ts +5 -2
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +11 -8
- package/dist/query.js.map +1 -1
- package/dist/render-document.d.ts +74 -0
- package/dist/render-document.d.ts.map +1 -0
- package/dist/render-document.js +393 -0
- package/dist/render-document.js.map +1 -0
- package/dist/route-announcer.d.ts +18 -0
- package/dist/route-announcer.d.ts.map +1 -0
- package/dist/route-announcer.js +58 -0
- package/dist/route-announcer.js.map +1 -0
- package/dist/route-tree.d.ts +6 -2
- package/dist/route-tree.d.ts.map +1 -1
- package/dist/route-tree.js +17 -7
- package/dist/route-tree.js.map +1 -1
- package/dist/router.d.ts +28 -5
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +71 -26
- package/dist/router.js.map +1 -1
- package/dist/script.d.ts +28 -5
- package/dist/script.d.ts.map +1 -1
- package/dist/script.js +192 -21
- package/dist/script.js.map +1 -1
- package/dist/server-render.d.ts.map +1 -1
- package/dist/server-render.js +4 -1
- package/dist/server-render.js.map +1 -1
- package/dist/testing-vitest.d.ts +8 -0
- package/dist/testing-vitest.d.ts.map +1 -0
- package/dist/testing-vitest.js +26 -0
- package/dist/testing-vitest.js.map +1 -0
- package/dist/testing.d.ts +17 -1
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +18 -3
- package/dist/testing.js.map +1 -1
- package/dist/zod-jitless.d.ts +2 -0
- package/dist/zod-jitless.d.ts.map +1 -0
- package/dist/zod-jitless.js +4 -0
- package/dist/zod-jitless.js.map +1 -0
- package/package.json +1 -1
- package/sidecar/server.mjs +109 -15
- package/skills/netjes/SKILL.md +15 -7
- package/skills/netjes/references/caching-and-streaming.md +37 -4
- package/skills/netjes/references/data-and-actions.md +15 -7
- package/skills/netjes/references/deployment.md +7 -3
- package/skills/netjes/references/routing.md +32 -9
- package/skills/netjes/references/testing.md +23 -7
- package/src/actions.tsx +9 -3
- package/src/client-entry.tsx +20 -2
- package/src/deferred.ts +14 -1
- package/src/document.tsx +22 -3
- package/src/errors.ts +12 -0
- package/src/form.tsx +10 -7
- package/src/href.ts +137 -11
- package/src/image.tsx +3 -3
- package/src/index.ts +19 -1
- package/src/link.tsx +47 -13
- package/src/metadata.tsx +19 -6
- package/src/modules.ts +15 -2
- package/src/mounted.tsx +160 -0
- package/src/navigation-focus.ts +75 -0
- package/src/payload.ts +32 -0
- package/src/query.tsx +14 -8
- package/src/render-document.tsx +483 -0
- package/src/route-announcer.tsx +69 -0
- package/src/route-tree.tsx +17 -6
- package/src/router.tsx +116 -31
- package/src/script.tsx +214 -23
- package/src/server-render.tsx +4 -1
- package/src/testing-vitest.ts +37 -0
- package/src/testing.tsx +38 -5
- package/src/virtual.d.ts +8 -1
- package/src/zod-jitless.ts +15 -0
- package/template/.dockerignore +3 -0
- package/template/Dockerfile +11 -1
- package/template/README.md +11 -7
- package/template/_AGENTS.md +38 -9
- package/template/__NAME__.csproj +2 -0
- package/template/app/document.test.ts +29 -0
- package/template/app/error.tsx +3 -2
- package/template/app/layout.tsx +10 -1
- package/template/docker-compose.override.yml +4 -3
- package/template/docker-compose.prod.yml +2 -1
- package/template/e2e/home.spec.ts +32 -0
- package/template/generated/netjes.ts +43 -14
- package/template/package.json +4 -1
- package/template/playwright.config.ts +36 -0
- package/template/tests/__NAME__.Tests/AppFactory.cs +31 -0
- package/template/tests/__NAME__.Tests/HomePageTests.cs +55 -0
- package/template/tests/__NAME__.Tests/__NAME__.Tests.csproj +30 -0
- package/template/tsconfig.app.json +20 -0
- package/template/tsconfig.json +2 -17
- package/template/tsconfig.node.json +8 -0
- package/vite/packages.mjs +345 -0
- package/vite/plugin.d.ts +16 -1
- package/vite/plugin.mjs +224 -34
package/src/script.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type ScriptHTMLAttributes, useEffect, useRef } from 'react';
|
|
2
|
-
import {
|
|
1
|
+
import { type ScriptHTMLAttributes, useEffect, useRef, useSyncExternalStore } from 'react';
|
|
2
|
+
import { preload } from 'react-dom';
|
|
3
|
+
import { htmlSafeJson } from './deferred.ts';
|
|
3
4
|
import { useNonce } from './nonce.tsx';
|
|
4
5
|
|
|
5
6
|
/** When `<Script>` loads its script: before or after the page is interactive, or when the browser is idle. */
|
|
@@ -7,11 +8,17 @@ export type ScriptStrategy = 'beforeInteractive' | 'afterInteractive' | 'lazyOnl
|
|
|
7
8
|
|
|
8
9
|
/** The props of `<Script>`: a script's own, with a loading strategy and load callbacks. */
|
|
9
10
|
export type ScriptProps = Omit<ScriptHTMLAttributes<HTMLScriptElement>, 'onLoad' | 'onError' | 'children' | 'dangerouslySetInnerHTML'> & {
|
|
10
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Identifies the script (required for inline scripts): it is added once, however often it is rendered, except inline
|
|
13
|
+
* `beforeInteractive` code, which the server writes wherever a component renders it.
|
|
14
|
+
*/
|
|
11
15
|
id?: string;
|
|
12
16
|
/**
|
|
13
17
|
* `afterInteractive` (default): once the page is interactive. `lazyOnload`: when the browser is idle.
|
|
14
|
-
* `beforeInteractive`: in
|
|
18
|
+
* `beforeInteractive`: in a server-rendered document, inline code runs where it stands, as the parser reaches it,
|
|
19
|
+
* and an external script in the shell runs once the shell is parsed, in order with the others, before the page's
|
|
20
|
+
* code and hydration; one in a Suspense boundary that streams in later runs when that part of the page hydrates.
|
|
21
|
+
* A component that mounts in the browser, after a client navigation for one, adds it as `afterInteractive` does.
|
|
15
22
|
*/
|
|
16
23
|
strategy?: ScriptStrategy;
|
|
17
24
|
onLoad?: (event: Event) => void;
|
|
@@ -20,9 +27,18 @@ export type ScriptProps = Omit<ScriptHTMLAttributes<HTMLScriptElement>, 'onLoad'
|
|
|
20
27
|
children?: string;
|
|
21
28
|
};
|
|
22
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The name of the `<meta>` a server-rendered `beforeInteractive` script puts in the head, which tells the client entry
|
|
32
|
+
* to run the external ones before it imports the page's modules.
|
|
33
|
+
*/
|
|
34
|
+
export const BEFORE_INTERACTIVE_MARKER = 'netjes-before-interactive';
|
|
35
|
+
|
|
36
|
+
/** The attribute that marks a server-rendered `beforeInteractive` script, inline or inert, with its id or src. */
|
|
37
|
+
const ENTRY = 'data-netjes-script';
|
|
38
|
+
|
|
23
39
|
type Entry = { element: HTMLScriptElement; state: 'loading' | 'loaded' | 'failed'; event?: Event };
|
|
24
40
|
|
|
25
|
-
/** Scripts already added to this document, by id or src. */
|
|
41
|
+
/** Scripts already added to this document, or run by its parser, by id or src. */
|
|
26
42
|
const added = new Map<string, Entry>();
|
|
27
43
|
|
|
28
44
|
function whenIdle(run: () => void) {
|
|
@@ -32,52 +48,200 @@ function whenIdle(run: () => void) {
|
|
|
32
48
|
else window.addEventListener('load', schedule, { once: true });
|
|
33
49
|
}
|
|
34
50
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
/** A script's own attributes by their HTML names: strings, or true for one without a value. */
|
|
52
|
+
type Attributes = Record<string, string | true>;
|
|
53
|
+
|
|
54
|
+
/** A name `setAttribute` takes, other than an event handler's, which React never renders from a string either. */
|
|
55
|
+
const isAttributeName = (name: string) => /^[A-Za-z_:][-\w.:]*$/.test(name) && !/^on/i.test(name);
|
|
56
|
+
|
|
57
|
+
function attributesOf(props: Record<string, unknown>): Attributes {
|
|
58
|
+
const attributes: Attributes = {};
|
|
59
|
+
for (const [name, value] of Object.entries(props)) {
|
|
60
|
+
if (value === undefined || value === null || value === false || typeof value === 'function' || typeof value === 'object') continue;
|
|
61
|
+
const html = name === 'className' ? 'class' : name === 'crossOrigin' ? 'crossorigin' : name;
|
|
62
|
+
if (isAttributeName(html)) attributes[html] = value === true ? true : String(value);
|
|
40
63
|
}
|
|
64
|
+
return attributes;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Insertion = { src?: string; code?: string; id?: string; nonce?: string; attributes: Attributes; async: boolean; parent: HTMLElement };
|
|
68
|
+
|
|
69
|
+
/** Adds a script element to the document and records it, with its outcome once it has one. */
|
|
70
|
+
function insert(key: string, { src, code, id, nonce, attributes, async, parent }: Insertion): Entry {
|
|
71
|
+
const element = document.createElement('script');
|
|
72
|
+
for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value === true ? '' : value);
|
|
41
73
|
if (id) element.id = id;
|
|
42
74
|
// The property, not the attribute: browsers hide nonce attributes once a policy applies.
|
|
43
|
-
if (
|
|
75
|
+
if (nonce) element.nonce = nonce;
|
|
44
76
|
const entry: Entry = { element, state: 'loading' };
|
|
45
77
|
element.addEventListener('load', (event) => Object.assign(entry, { state: 'loaded', event }));
|
|
46
78
|
element.addEventListener('error', (event) => Object.assign(entry, { state: 'failed', event }));
|
|
47
79
|
if (src) {
|
|
48
80
|
element.src = src;
|
|
49
|
-
element.async =
|
|
81
|
+
element.async = async;
|
|
50
82
|
} else {
|
|
51
|
-
element.textContent =
|
|
83
|
+
element.textContent = code ?? '';
|
|
52
84
|
// Inline scripts run as they are appended; there is no load event to wait for.
|
|
53
85
|
entry.state = 'loaded';
|
|
54
86
|
}
|
|
55
87
|
added.set(key, entry);
|
|
56
|
-
|
|
88
|
+
parent.append(element);
|
|
57
89
|
return entry;
|
|
58
90
|
}
|
|
59
91
|
|
|
92
|
+
function add(key: string, { src, children, strategy, id, onLoad: _onLoad, onError: _onError, ...rest }: ScriptProps): Entry {
|
|
93
|
+
// Inline beforeInteractive code as the server's document carries it, so that one hash allows it on either path.
|
|
94
|
+
const code = strategy === 'beforeInteractive' ? escapeScriptText(children ?? '') : (children ?? '');
|
|
95
|
+
return insert(key, {
|
|
96
|
+
...(src ? { src } : { code }),
|
|
97
|
+
...(id ? { id } : {}),
|
|
98
|
+
...(rest.nonce ? { nonce: rest.nonce } : {}),
|
|
99
|
+
attributes: attributesOf(rest),
|
|
100
|
+
async: true,
|
|
101
|
+
parent: document.body,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** What an inert entry holds, the script's src and its attributes, or null when it holds anything else. */
|
|
106
|
+
function externalEntry(text: string | null): { src: string; attributes: Attributes } | null {
|
|
107
|
+
let value: unknown;
|
|
108
|
+
try {
|
|
109
|
+
value = JSON.parse(text ?? '');
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (typeof value !== 'object' || value === null) return null;
|
|
114
|
+
const { src, attributes } = value as { src?: unknown; attributes?: unknown };
|
|
115
|
+
if (typeof src !== 'string' || src === '') return null;
|
|
116
|
+
const checked: Attributes = {};
|
|
117
|
+
if (typeof attributes === 'object' && attributes !== null) {
|
|
118
|
+
for (const [name, attribute] of Object.entries(attributes)) {
|
|
119
|
+
if ((typeof attribute === 'string' || attribute === true) && isAttributeName(name) && name.toLowerCase() !== 'nonce') checked[name] = attribute;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { src, attributes: checked };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The types besides none that browsers run as JavaScript: the HTML standard's JavaScript MIME types. */
|
|
126
|
+
const JAVASCRIPT_TYPES = new Set([
|
|
127
|
+
'application/ecmascript',
|
|
128
|
+
'application/javascript',
|
|
129
|
+
'application/x-ecmascript',
|
|
130
|
+
'application/x-javascript',
|
|
131
|
+
'text/ecmascript',
|
|
132
|
+
'text/javascript',
|
|
133
|
+
'text/javascript1.0',
|
|
134
|
+
'text/javascript1.1',
|
|
135
|
+
'text/javascript1.2',
|
|
136
|
+
'text/javascript1.3',
|
|
137
|
+
'text/javascript1.4',
|
|
138
|
+
'text/javascript1.5',
|
|
139
|
+
'text/jscript',
|
|
140
|
+
'text/livescript',
|
|
141
|
+
'text/x-ecmascript',
|
|
142
|
+
'text/x-javascript',
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Whether a browser runs a script with these attributes, by the HTML standard's rules, and so fires its load or error
|
|
147
|
+
* event. One that runs the client entry, a module, skips a script with `nomodule` and one whose type is neither
|
|
148
|
+
* JavaScript nor `module`, such as the `text/plain` of a script a consent manager turns on once the visitor agrees, and
|
|
149
|
+
* fires neither event for it.
|
|
150
|
+
*/
|
|
151
|
+
function runs(attributes: Attributes): boolean {
|
|
152
|
+
const named: Partial<Record<string, string>> = {};
|
|
153
|
+
for (const [name, value] of Object.entries(attributes)) named[name.toLowerCase()] = value === true ? '' : value;
|
|
154
|
+
const type = named.type ?? (named.language ? `text/${named.language}` : '');
|
|
155
|
+
if (type === '') return named.nomodule === undefined;
|
|
156
|
+
const essence = type.replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, '').toLowerCase();
|
|
157
|
+
return essence === 'module' || (JAVASCRIPT_TYPES.has(essence) && named.nomodule === undefined);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Resolves once the script has loaded or failed. */
|
|
161
|
+
function outcome(entry: Entry): Promise<void> {
|
|
162
|
+
if (entry.state !== 'loading') return Promise.resolve();
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
entry.element.addEventListener('load', () => resolve(), { once: true });
|
|
165
|
+
entry.element.addEventListener('error', () => resolve(), { once: true });
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
60
169
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
170
|
+
* Takes over the `beforeInteractive` scripts the server rendered into the document, in document order, those not yet
|
|
171
|
+
* taken over. Inline code has run where it stands: it is recorded, so that a component that mounts again (after a
|
|
172
|
+
* hydration error) does not add it once more. Each external script's inert entry becomes a script at the end of the
|
|
173
|
+
* head, not async, so the browser runs them in this order as they arrive, with the page's nonce. Under a nonce, only
|
|
174
|
+
* entries with that nonce count: markup that reached the page some other way does not know it. Resolves once every
|
|
175
|
+
* external script it added has loaded or failed.
|
|
176
|
+
*/
|
|
177
|
+
export function adoptServerScripts(nonce: string | undefined): Promise<void> {
|
|
178
|
+
const waits: Promise<void>[] = [];
|
|
179
|
+
for (const element of document.querySelectorAll<HTMLScriptElement>(`script[${ENTRY}]`)) {
|
|
180
|
+
const key = element.getAttribute(ENTRY);
|
|
181
|
+
if (!key || added.has(key) || (nonce && element.nonce !== nonce)) continue;
|
|
182
|
+
const external = element.type === 'application/json' ? externalEntry(element.textContent) : null;
|
|
183
|
+
if (external) {
|
|
184
|
+
const entry = insert(key, { ...external, ...(nonce ? { nonce } : {}), async: false, parent: document.head });
|
|
185
|
+
if (runs(external.attributes)) waits.push(outcome(entry));
|
|
186
|
+
} else {
|
|
187
|
+
added.set(key, { element, state: 'loaded' });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return Promise.all(waits).then(() => undefined);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* React's own rule for the text of an inline script: `</script` would close the element and `<script` would make the
|
|
195
|
+
* parser look for a second closing tag, so their `s` is written `\u0073` (or `S` as `\u0053`), which JavaScript reads as the
|
|
196
|
+
* same letter in strings, template literals, regular expressions and names.
|
|
197
|
+
*/
|
|
198
|
+
const SCRIPT_TAG = /(<\/|<)(s)(cript)/gi;
|
|
199
|
+
const escapeScriptText = (code: string) =>
|
|
200
|
+
code.replace(SCRIPT_TAG, (_match, open: string, s: string, rest: string) => `${open}${s === 's' ? '\\u0073' : '\\u0053'}${rest}`);
|
|
201
|
+
|
|
202
|
+
const subscribeToNothing = () => () => {};
|
|
203
|
+
|
|
204
|
+
/** Scripts warned about already: each is warned about once. */
|
|
205
|
+
const warned = new Set<string>();
|
|
206
|
+
|
|
207
|
+
function warnAboutTiming(key: string, { async, defer, type }: ScriptProps) {
|
|
208
|
+
if (process.env.NODE_ENV === 'production' || warned.has(key)) return;
|
|
209
|
+
const found = [async && 'async', defer && 'defer', type === 'module' && 'type="module"'].filter(Boolean);
|
|
210
|
+
if (found.length === 0) return;
|
|
211
|
+
warned.add(key);
|
|
212
|
+
console.warn(
|
|
213
|
+
`netjes: <Script strategy="beforeInteractive"> "${key}" has ${found.join(', ')}. A beforeInteractive script runs ` +
|
|
214
|
+
"in order, before the page's code, whatever async and defer say, and type=\"module\" makes inline code wait until " +
|
|
215
|
+
'the whole document is parsed. Leave them out, or use strategy="afterInteractive".',
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A script loaded by strategy, like `next/script`: `afterInteractive` and `lazyOnload` do not block the page. Scripts
|
|
221
|
+
* are added to the document once, whichever component renders them first, except inline `beforeInteractive` code, which
|
|
222
|
+
* the server writes wherever a component renders it. Only `beforeInteractive` renders anything where it stands, and
|
|
223
|
+
* only into a server-rendered document: the inline script, or an inert entry for an external one, and a marker in the
|
|
224
|
+
* head.
|
|
63
225
|
*/
|
|
64
226
|
export function Script(given: ScriptProps) {
|
|
65
227
|
const pageNonce = useNonce();
|
|
66
228
|
const props = given.nonce || !pageNonce ? given : { ...given, nonce: pageNonce };
|
|
67
|
-
const { src, id, strategy = 'afterInteractive', onLoad, onError, nonce, crossOrigin, integrity } = props;
|
|
229
|
+
const { src, id, strategy = 'afterInteractive', onLoad, onError, nonce, crossOrigin, integrity, children } = props;
|
|
68
230
|
const callbacks = useRef({ onLoad, onError });
|
|
69
231
|
callbacks.current = { onLoad, onError };
|
|
70
232
|
const key = id ?? src;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
233
|
+
// True on the server and while this component hydrates, and for as long as it stays mounted after that, so that it
|
|
234
|
+
// keeps the markup it hydrated. False when it mounts in the browser.
|
|
235
|
+
const fromServer = useRef(useSyncExternalStore(subscribeToNothing, () => false, () => true)).current;
|
|
236
|
+
const inDocument = strategy === 'beforeInteractive' && fromServer && key !== undefined;
|
|
75
237
|
|
|
76
238
|
useEffect(() => {
|
|
77
|
-
if (!key
|
|
239
|
+
if (!key) return;
|
|
78
240
|
let cancelled = false;
|
|
79
241
|
const attach = () => {
|
|
80
242
|
if (cancelled) return;
|
|
243
|
+
// An entry that streamed in after the client entry took the others over is taken over now.
|
|
244
|
+
if (inDocument && !added.has(key)) void adoptServerScripts(pageNonce);
|
|
81
245
|
const entry = added.get(key) ?? add(key, props);
|
|
82
246
|
const notify = () => {
|
|
83
247
|
if (cancelled) return;
|
|
@@ -99,5 +263,32 @@ export function Script(given: ScriptProps) {
|
|
|
99
263
|
// The script is identified by its key; changing other props does not add it again.
|
|
100
264
|
}, [key, strategy]);
|
|
101
265
|
|
|
102
|
-
return null;
|
|
266
|
+
if (!inDocument) return null;
|
|
267
|
+
warnAboutTiming(key, props);
|
|
268
|
+
const { strategy: _strategy, onLoad: _onLoad, onError: _onError, children: _children, src: _src, id: _id, nonce: _nonce, ...rest } = props;
|
|
269
|
+
// In the head, where the client entry looks for it once it has the payload.
|
|
270
|
+
const marker = <meta name={BEFORE_INTERACTIVE_MARKER} />;
|
|
271
|
+
if (!src) {
|
|
272
|
+
// Written as it is (React would escape text children, and they would not match the server's); escaped as React
|
|
273
|
+
// escapes its own scripts, so the text cannot end the element. A hash-based policy lists the hash of that text.
|
|
274
|
+
return (
|
|
275
|
+
<>
|
|
276
|
+
{marker}
|
|
277
|
+
<script {...rest} id={id} nonce={nonce} data-netjes-script={key} dangerouslySetInnerHTML={{ __html: escapeScriptText(children ?? '') }} />
|
|
278
|
+
</>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
// The id goes to the script the entry becomes, not to the entry.
|
|
282
|
+
const attributes = attributesOf(id ? { ...rest, id } : rest);
|
|
283
|
+
// The browser fetches it while it parses; adoptServerScripts runs it from the entry. A script the browser will not run
|
|
284
|
+
// is not fetched: a consent manager's would reach its host before the visitor agreed.
|
|
285
|
+
if (runs(attributes)) {
|
|
286
|
+
preload(src, { as: 'script', ...(nonce ? { nonce } : {}), ...(integrity ? { integrity } : {}), ...(crossOrigin !== undefined ? { crossOrigin } : {}) });
|
|
287
|
+
}
|
|
288
|
+
return (
|
|
289
|
+
<>
|
|
290
|
+
{marker}
|
|
291
|
+
<script type="application/json" nonce={nonce} data-netjes-script={key} dangerouslySetInnerHTML={{ __html: htmlSafeJson({ src, attributes }) }} />
|
|
292
|
+
</>
|
|
293
|
+
);
|
|
103
294
|
}
|
package/src/server-render.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { type Readable, Transform, Writable } from 'node:stream';
|
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
3
|
import { renderToPipeableStream } from 'react-dom/server';
|
|
4
4
|
import { type DeferredSource, deferredScript, revivePayload } from './deferred.ts';
|
|
5
|
+
import { isWithheld } from './errors.ts';
|
|
5
6
|
import { type ModuleMap, createModuleLoader } from './modules.ts';
|
|
6
7
|
import { type InitialPayload, parseInitialPayload } from './payload.ts';
|
|
7
8
|
import { RenderCleanupContext } from './render-cleanup.ts';
|
|
@@ -75,7 +76,9 @@ function renderToStream(
|
|
|
75
76
|
for (const id of deferredIds) {
|
|
76
77
|
source(id).then(
|
|
77
78
|
(value) => send(deferredScript(id, value, undefined, nonce)),
|
|
78
|
-
|
|
79
|
+
// A withheld message goes on as an empty one, which the browser replaces with its own stand-in.
|
|
80
|
+
(error: unknown) =>
|
|
81
|
+
send(deferredScript(id, null, isWithheld(error) ? '' : error instanceof Error ? error.message : String(error), nonce)),
|
|
79
82
|
);
|
|
80
83
|
}
|
|
81
84
|
let started = false;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Registered by netjes() as a Vitest setup file in every app, after the app's own: what the test harness needs from the
|
|
2
|
+
// runner. Named by path, not exported.
|
|
3
|
+
import type { DefaultOptions } from '@tanstack/react-query';
|
|
4
|
+
import { inject, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
declare module 'vitest' {
|
|
7
|
+
interface ProvidedContext {
|
|
8
|
+
/** The app's `netjes({ query: { defaultOptions } })`, which netjes() provides to its tests. */
|
|
9
|
+
'netjes:query': DefaultOptions;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The app's query defaults, for createTestApp. They wait under a global symbol rather than in a module: the app's
|
|
14
|
+
// imports of netjes/testing need not share module instances with this file, which Vitest loads by its path.
|
|
15
|
+
const appQueryDefaults: unknown = inject('netjes:query');
|
|
16
|
+
if (typeof appQueryDefaults === 'object' && appQueryDefaults !== null) {
|
|
17
|
+
(globalThis as Record<symbol, unknown>)[Symbol.for('netjes.testing.queryDefaults')] = appQueryDefaults;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Testing Library advances fake timers only through Jest's global: without one, waitFor, findBy and every user-event
|
|
21
|
+
// call wait on a faked setTimeout that nothing advances, so they never return. While Vitest's timers are fake, `jest`
|
|
22
|
+
// is a bridge to them; otherwise it is undefined, so code that looks for Jest finds none. A jest global that was
|
|
23
|
+
// already there is left alone, and one a test assigns replaces the bridge.
|
|
24
|
+
if (!('jest' in globalThis)) {
|
|
25
|
+
const bridge = {
|
|
26
|
+
advanceTimersByTime(ms: number) {
|
|
27
|
+
vi.advanceTimersByTime(ms);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
Object.defineProperty(globalThis, 'jest', {
|
|
31
|
+
configurable: true,
|
|
32
|
+
get: () => (vi.isFakeTimers() ? bridge : undefined),
|
|
33
|
+
set(value: unknown) {
|
|
34
|
+
Object.defineProperty(globalThis, 'jest', { configurable: true, enumerable: true, writable: true, value });
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
package/src/testing.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Test harness for components and pages that use Netjes: `render(<Page />, { wrapper: createTestApp().Wrapper })`.
|
|
2
2
|
// The router is a fake: it records what the component asked for and moves to the new URL, but loads nothing.
|
|
3
|
+
// renderDocument renders a page's whole document instead, as the server would send it.
|
|
3
4
|
import { type DefaultOptions, QueryClient, QueryClientProvider, hydrate } from '@tanstack/react-query';
|
|
4
5
|
import { type ReactNode, useLayoutEffect, useMemo, useState } from 'react';
|
|
5
6
|
import { type ActionClient, type ActionState, setActionClient } from './actions.tsx';
|
|
@@ -8,6 +9,8 @@ import { PublicConfigProvider } from './public-config.tsx';
|
|
|
8
9
|
import { dehydratedStateOf, mergeOptions } from './query.tsx';
|
|
9
10
|
import { type InternalRouter, type NavigationState, RouterContexts, type SubmitBody } from './router.tsx';
|
|
10
11
|
|
|
12
|
+
export { type RenderDocumentOptions, inlineScriptHashes, renderDocument } from './render-document.tsx';
|
|
13
|
+
|
|
11
14
|
/** What the fake app of `createTestApp` reports to the components under test. */
|
|
12
15
|
export type TestAppOptions = {
|
|
13
16
|
/** The current URL. Default `/`. */
|
|
@@ -23,9 +26,18 @@ export type TestAppOptions = {
|
|
|
23
26
|
params?: Params;
|
|
24
27
|
/** What the server would send as `SharedProps`. */
|
|
25
28
|
shared?: unknown;
|
|
29
|
+
/**
|
|
30
|
+
* Each mounted page package's URL for the page, by npm name, as the server sends it: `{ '@acme/help-center': '/help' }`,
|
|
31
|
+
* without the base path, or `null` for a prefix whose value the page has none for. The links, forms and router hooks
|
|
32
|
+
* of the package's generated file go below it.
|
|
33
|
+
*/
|
|
34
|
+
mounts?: Record<string, string | null>;
|
|
26
35
|
/** Seeded into the QueryClient the way `PageResult.WithQuery` seeds it, as of now. */
|
|
27
36
|
queries?: ReadonlyArray<{ key: readonly unknown[]; data: unknown }>;
|
|
28
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Like `netjes({ query: { defaultOptions } })`, merged over the harness's own: failed queries and mutations are tried
|
|
39
|
+
* as often as in the app, with no wait between the tries, and no query's data is garbage-collected.
|
|
40
|
+
*/
|
|
29
41
|
defaultOptions?: DefaultOptions;
|
|
30
42
|
/** What `useNavigation()` returns. Default idle. */
|
|
31
43
|
navigation?: NavigationState;
|
|
@@ -45,6 +57,12 @@ export type TestActionAnswer = ActionState<unknown> & {
|
|
|
45
57
|
invalidate?: ReadonlyArray<readonly unknown[]>;
|
|
46
58
|
/** After a failure: the submitted fields the state's `values` keep, as the server names them (the action's declared, non-private fields). Default: all. */
|
|
47
59
|
fields?: readonly string[];
|
|
60
|
+
/**
|
|
61
|
+
* After a failure other than a 422: the message the server would send, which the `ServerActionError` then carries, as
|
|
62
|
+
* the server does for a 4xx (`Forbidden`) and for a 5xx with error details on. Without one, the call fails as a 5xx
|
|
63
|
+
* does with error details off: the error's message is Netjes's own and `withheld` is true.
|
|
64
|
+
*/
|
|
65
|
+
message?: string;
|
|
48
66
|
};
|
|
49
67
|
|
|
50
68
|
/** One thing the component under test did: a navigation, a router call, a form post or a server action call. */
|
|
@@ -76,6 +94,20 @@ function RegisterActionClient({ client }: { client: ActionClient }) {
|
|
|
76
94
|
|
|
77
95
|
const TEST_TOKEN: AntiforgeryToken = { formFieldName: '__RequestVerificationToken', headerName: 'RequestVerificationToken', requestToken: 'test-token' };
|
|
78
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The harness's own query defaults, over the app's: a failed query or mutation is tried as often as in the app, but
|
|
99
|
+
* without the wait between the tries (TanStack Query's 1, 2 and 4 seconds would outlast a test), and data stays in the
|
|
100
|
+
* client for the whole test.
|
|
101
|
+
*/
|
|
102
|
+
const HARNESS_DEFAULTS: DefaultOptions = { queries: { retryDelay: 0, gcTime: Number.POSITIVE_INFINITY }, mutations: { retryDelay: 0 } };
|
|
103
|
+
|
|
104
|
+
/** The app's `netjes({ query })` defaults, where Netjes's Vitest setup file keeps them (src/testing-vitest.ts). */
|
|
105
|
+
const APP_QUERY_DEFAULTS = Symbol.for('netjes.testing.queryDefaults');
|
|
106
|
+
|
|
107
|
+
function appQueryDefaults(): DefaultOptions | undefined {
|
|
108
|
+
return (globalThis as Record<symbol, DefaultOptions | undefined>)[APP_QUERY_DEFAULTS];
|
|
109
|
+
}
|
|
110
|
+
|
|
79
111
|
function dataOf(body: SubmitBody): URLSearchParams | FormData {
|
|
80
112
|
return body.contentType === null ? body.body : new URLSearchParams(body.body);
|
|
81
113
|
}
|
|
@@ -83,10 +115,9 @@ function dataOf(body: SubmitBody): URLSearchParams | FormData {
|
|
|
83
115
|
/** Creates a fake app around the components under test: pass its `Wrapper` to Testing Library's `render`. The router loads nothing; it records what was asked of it. */
|
|
84
116
|
export function createTestApp(options: TestAppOptions = {}): TestApp {
|
|
85
117
|
const navigations: TestNavigation[] = [];
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
new QueryClient({ defaultOptions: { ...defaults, queries: { retry: false, gcTime: Number.POSITIVE_INFINITY, ...defaults.queries } } });
|
|
118
|
+
// The Netjes defaults, then the app's, then the harness's, then the test's.
|
|
119
|
+
const defaultOptions = mergeOptions(appQueryDefaults(), HARNESS_DEFAULTS, options.defaultOptions);
|
|
120
|
+
const queryClient = options.queryClient ?? new QueryClient({ defaultOptions });
|
|
90
121
|
const now = Date.now();
|
|
91
122
|
const seeded = dehydratedStateOf(options.queries?.map((query) => ({ ...query, updatedAt: now })));
|
|
92
123
|
if (seeded) hydrate(queryClient, seeded);
|
|
@@ -109,6 +140,7 @@ export function createTestApp(options: TestAppOptions = {}): TestApp {
|
|
|
109
140
|
status: answer.status,
|
|
110
141
|
errors: answer.errors,
|
|
111
142
|
...(answer.fields ? { fields: [...answer.fields] } : {}),
|
|
143
|
+
...(answer.message !== undefined ? { message: answer.message } : {}),
|
|
112
144
|
revalidate: false,
|
|
113
145
|
};
|
|
114
146
|
},
|
|
@@ -157,6 +189,7 @@ export function createTestApp(options: TestAppOptions = {}): TestApp {
|
|
|
157
189
|
...(antiforgery ? { antiforgery } : {}),
|
|
158
190
|
...(options.draftMode ? { draftMode: true } : {}),
|
|
159
191
|
...(options.locale ? { locale: options.locale } : {}),
|
|
192
|
+
...(options.mounts ? { mounts: options.mounts } : {}),
|
|
160
193
|
}),
|
|
161
194
|
[url],
|
|
162
195
|
);
|
package/src/virtual.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
declare module 'virtual:netjes/pages' {
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Every page, layout and other convention file, as lazy imports, keyed by `/` and the file's id: `/app/...` for the
|
|
4
|
+
* app's, `/<npm name>/<its pages folder>/...` for a page package's. Generated by the netjes Vite plugin.
|
|
5
|
+
*/
|
|
3
6
|
export const pages: Record<string, () => Promise<unknown>>;
|
|
7
|
+
/** The folder of the page files, as the plugin's `appDir` names it: `app`, or e.g. `src/app`. */
|
|
8
|
+
export const appDir: string;
|
|
9
|
+
/** The page packages `netjes({ packages })` lists, each with the folder of its pages; `[]` when it lists none. */
|
|
10
|
+
export const packages: ReadonlyArray<{ readonly name: string; readonly pages: string }>;
|
|
4
11
|
}
|
|
5
12
|
|
|
6
13
|
declare module 'virtual:netjes/query' {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Turns zod's compiled parsers off in the browser (ADR 0036). zod 4.4.0 and newer read this object whenever they make
|
|
2
|
+
// an object schema, whether zod loaded before this ran or after. Without `jitless`, zod tests whether it may make a
|
|
3
|
+
// function from a string, and a browser reports that test as a violation of any Content-Security-Policy whose
|
|
4
|
+
// script-src (or default-src, without a script-src) leaves out 'unsafe-eval', although zod catches the error.
|
|
5
|
+
//
|
|
6
|
+
// It must stay the client entry's first import, so that it runs ahead of the entry's other imports and of the page
|
|
7
|
+
// modules, which make their schemas as they load. (A production build moves the modules the entry shares with pages,
|
|
8
|
+
// such as React and the router, into chunks that run before the entry's own code; none of them makes a zod schema.)
|
|
9
|
+
// It imports nothing, so the entry still loads no zod.
|
|
10
|
+
type ZodGlobals = { __zod_globalConfig?: { jitless?: boolean } };
|
|
11
|
+
|
|
12
|
+
((globalThis as unknown as ZodGlobals).__zod_globalConfig ??= {}).jitless = true;
|
|
13
|
+
|
|
14
|
+
// A module, not a script, so that the type above stays its own.
|
|
15
|
+
export {};
|
package/template/.dockerignore
CHANGED
package/template/Dockerfile
CHANGED
|
@@ -44,9 +44,15 @@ COPY . .
|
|
|
44
44
|
COPY --from=client /src/dist dist
|
|
45
45
|
RUN dotnet publish -c Release -o /app --no-restore -p:NetjesSkipClientBuild=true
|
|
46
46
|
|
|
47
|
+
# tini, the production image's PID 1 (below): Ubuntu's package, installed in the SDK image the build stage uses. The
|
|
48
|
+
# production stage takes its static binary, which needs no package manager and no library in RUNTIME_IMAGE.
|
|
49
|
+
FROM ${SDK_IMAGE} AS tini
|
|
50
|
+
RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
|
|
51
|
+
|
|
47
52
|
# Production: ASP.NET Core runtime plus the node binary for the SSR sidecar. Runs as the non-root app user.
|
|
48
53
|
FROM ${RUNTIME_IMAGE} AS production
|
|
49
54
|
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
|
55
|
+
COPY --from=tini /usr/bin/tini-static /usr/bin/tini
|
|
50
56
|
# Where ASP.NET Core keeps its Data Protection keys; docker-compose.prod.yml mounts a volume here.
|
|
51
57
|
RUN mkdir -p /home/app/.aspnet/DataProtection-Keys && chown -R app:app /home/app/.aspnet
|
|
52
58
|
WORKDIR /app
|
|
@@ -55,4 +61,8 @@ USER $APP_UID
|
|
|
55
61
|
EXPOSE 8080
|
|
56
62
|
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \
|
|
57
63
|
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/healthz').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
|
|
58
|
-
|
|
64
|
+
# tini is PID 1 and runs the app as its child. The kernel ignores a signal that a container's PID 1 has no handler for,
|
|
65
|
+
# so an app that is PID 1 never dies of its own abort(): after a crash it spins (arm64) or exits with another code than
|
|
66
|
+
# 134. tini passes signals on (docker stop), reaps orphans and exits with the app's code; -s keeps it reaping when
|
|
67
|
+
# another process is PID 1, as under docker run --init.
|
|
68
|
+
ENTRYPOINT ["tini", "-s", "--", "dotnet", "__NAME__.dll"]
|
package/template/README.md
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
# __TITLE__
|
|
2
2
|
|
|
3
|
-
A React app on ASP.NET Core, built with Netjes: Vite, Tailwind CSS, TanStack Query, zod and
|
|
3
|
+
A React app on ASP.NET Core, built with Netjes: Vite, Tailwind CSS, TanStack Query, zod, Vitest, xUnit and Playwright are set up.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm run dev # dotnet watch + Vite HMR, http://localhost:5080
|
|
7
|
-
npm test # Vitest component tests
|
|
8
|
-
|
|
7
|
+
npm test # Vitest: component tests and the home page's document
|
|
8
|
+
dotnet test tests/__NAME__.Tests # C# tests of the loader and the server action, in memory
|
|
9
|
+
npm run test:e2e # Playwright, on a development and a production server (npx playwright install chromium once first)
|
|
10
|
+
npm run typecheck # every .ts and .tsx file
|
|
9
11
|
npm run routes # the route table, from C#
|
|
10
12
|
npx netjes build # types, client and server bundles
|
|
11
13
|
npx netjes info # check Node.js, the .NET SDK, the packages and this setup
|
|
12
14
|
dotnet publish -c Release
|
|
13
15
|
```
|
|
14
16
|
|
|
15
|
-
- `app/` holds the pages, as in the Next.js App Router: `page.tsx` is `/`, `about/page.tsx` is `/about`, `layout.tsx` wraps them all, `not-found.tsx` and `error.tsx` handle the rest.
|
|
17
|
+
- `app/` holds the pages, as in the Next.js App Router: `page.tsx` is `/`, `about/page.tsx` is `/about`, `layout.tsx` wraps them all, `not-found.tsx` and `error.tsx` handle the rest. `app/document.test.ts` renders the home page's whole document, as the server sends it, with `renderDocument` from `netjes/testing`.
|
|
16
18
|
- `public/` is served at the site root. `public/favicon.svg`, which the metadata in `app/layout.tsx` links, is the Netjes mark: replace it with the app's own.
|
|
17
19
|
- `Features/` holds the C# side, one folder per feature: `Features/Home/HomePage.cs` maps the home page's loader and the `home.greet` server action the form calls.
|
|
20
|
+
- `tests/__NAME__.Tests/` holds the C# tests: `AppFactory.cs` runs the real app in memory, with no Node and no client build, and `HomePageTests.cs` tests the home page's loader and form. `e2e/` holds the Playwright tests, which `playwright.config.ts` runs against the app in Development and in Production. `__NAME__.csproj` leaves these two root folders out of the app's build, so keep the app's own C# out of them.
|
|
21
|
+
- `tsconfig.json` points to two configs: `tsconfig.app.json` checks the app's code without Node's types, `tsconfig.node.json` checks `vite.config.ts`, `playwright.config.ts`, `e2e/` and `scripts/` with them.
|
|
18
22
|
- `AGENTS.md` tells coding agents how the app is laid out and what the rules are; `CLAUDE.md` imports it for Claude Code.
|
|
19
23
|
- `Properties/launchSettings.json` holds the `http` launch profile that `dotnet run` and IDEs (F5) use: the app in Development on http://localhost:5080, as `npm run dev` runs it but without `dotnet watch`.
|
|
20
|
-
- `generated/netjes.ts` holds zod schemas for what the loaders and actions return, typed server action references and typed
|
|
24
|
+
- `generated/netjes.ts` holds zod schemas for what the loaders and actions return, typed server action references, typed routes and typed endpoint URLs. The app rewrites it when it starts in Development; commit it, and run `npx netjes generate --check` in CI.
|
|
21
25
|
|
|
22
|
-
Outside Development, `Program.cs` sends HSTS on https responses and redirects http to https once the app knows its https port (`ASPNETCORE_HTTPS_PORT` or `ASPNETCORE_HTTPS_PORTS`). Behind a proxy that terminates TLS, add `UseForwardedHeaders()` (trusting only that proxy) so proxied requests count as https and get HSTS, and let the proxy redirect plain http. Netjes adds `X-Content-Type-Options`, `Referrer-Policy` and `X-Frame-Options
|
|
26
|
+
Outside Development, `Program.cs` sends HSTS on https responses and redirects http to https once the app knows its https port (`ASPNETCORE_HTTPS_PORT` or `ASPNETCORE_HTTPS_PORTS`). Behind a proxy that terminates TLS, add `UseForwardedHeaders()` (trusting only that proxy) so proxied requests count as https and get HSTS, and let the proxy redirect plain http. Netjes adds `X-Content-Type-Options: nosniff` to every response that passes through `UseNetjes()`, static files and API endpoints included, and `Referrer-Policy` and `X-Frame-Options` (`SAMEORIGIN`, or what a Content-Security-Policy's `frame-ancestors` implies) to every document it renders; see `NetjesOptions.SecurityHeaders` to change that.
|
|
23
27
|
|
|
24
|
-
With Docker: `docker compose up` for development, `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d` for production. In Development `appsettings.Development.json` limits `AllowedHosts` to loopback names (a DNS-rebinding guard); add a name there if you reach the dev app by another host. The Docker build restores Netjes from NuGet and npm, which is how any app made by `npx netjes new` references it. Only an app scaffolded from a Netjes checkout references the checkout instead; scaffold it with `netjes new --packages` to build it in Docker.
|
|
28
|
+
With Docker: `docker compose up` for development, `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d` for production. Both publish the app on port 8080 of the host, development on 127.0.0.1 only; set `APP_PORT`, in the shell or a `.env` file, to use another port (`APP_PORT=8090 docker compose up`). The production image runs the app under tini, which passes the signal of `docker stop` on to the app and ends the container when the app crashes, with exit code 134, so `restart: always` starts it again. In Development `appsettings.Development.json` limits `AllowedHosts` to loopback names (a DNS-rebinding guard); add a name there if you reach the dev app by another host. The Docker build restores Netjes from NuGet and npm, which is how any app made by `npx netjes new` references it. Only an app scaffolded from a Netjes checkout references the checkout instead; scaffold it with `netjes new --packages` to build it in Docker.
|