netjes 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -2
  3. package/bin/netjes.mjs +13 -0
  4. package/cli/agents.mjs +66 -0
  5. package/cli/commands.mjs +467 -0
  6. package/cli/help.mjs +89 -0
  7. package/cli/info.mjs +454 -0
  8. package/cli/main.mjs +167 -0
  9. package/cli/packages.mjs +19 -0
  10. package/cli/process-io.mjs +163 -0
  11. package/cli/routes.mjs +31 -0
  12. package/cli/scaffold.mjs +193 -0
  13. package/cli/suggest.mjs +50 -0
  14. package/cli/terminal.mjs +40 -0
  15. package/dist/actions.d.ts +98 -0
  16. package/dist/actions.d.ts.map +1 -0
  17. package/dist/actions.js +176 -0
  18. package/dist/actions.js.map +1 -0
  19. package/dist/client-entry.d.ts +2 -0
  20. package/dist/client-entry.d.ts.map +1 -0
  21. package/dist/client-entry.js +126 -0
  22. package/dist/client-entry.js.map +1 -0
  23. package/dist/deferred.d.ts +39 -0
  24. package/dist/deferred.d.ts.map +1 -0
  25. package/dist/deferred.js +123 -0
  26. package/dist/deferred.js.map +1 -0
  27. package/dist/define-page.d.ts +15 -0
  28. package/dist/define-page.d.ts.map +1 -0
  29. package/dist/define-page.js +27 -0
  30. package/dist/define-page.js.map +1 -0
  31. package/dist/document.d.ts +19 -0
  32. package/dist/document.d.ts.map +1 -0
  33. package/dist/document.js +17 -0
  34. package/dist/document.js.map +1 -0
  35. package/dist/draft-mode.d.ts +5 -0
  36. package/dist/draft-mode.d.ts.map +1 -0
  37. package/dist/draft-mode.js +6 -0
  38. package/dist/draft-mode.js.map +1 -0
  39. package/dist/dynamic.d.ts +20 -0
  40. package/dist/dynamic.d.ts.map +1 -0
  41. package/dist/dynamic.js +37 -0
  42. package/dist/dynamic.js.map +1 -0
  43. package/dist/form-data.d.ts +10 -0
  44. package/dist/form-data.d.ts.map +1 -0
  45. package/dist/form-data.js +10 -0
  46. package/dist/form-data.js.map +1 -0
  47. package/dist/form.d.ts +35 -0
  48. package/dist/form.d.ts.map +1 -0
  49. package/dist/form.js +80 -0
  50. package/dist/form.js.map +1 -0
  51. package/dist/href.d.ts +25 -0
  52. package/dist/href.d.ts.map +1 -0
  53. package/dist/href.js +71 -0
  54. package/dist/href.js.map +1 -0
  55. package/dist/image.d.ts +36 -0
  56. package/dist/image.d.ts.map +1 -0
  57. package/dist/image.js +60 -0
  58. package/dist/image.js.map +1 -0
  59. package/dist/index.d.ts +18 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +14 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/link.d.ts +17 -0
  64. package/dist/link.d.ts.map +1 -0
  65. package/dist/link.js +46 -0
  66. package/dist/link.js.map +1 -0
  67. package/dist/locale.d.ts +3 -0
  68. package/dist/locale.d.ts.map +1 -0
  69. package/dist/locale.js +6 -0
  70. package/dist/locale.js.map +1 -0
  71. package/dist/metadata.d.ts +83 -0
  72. package/dist/metadata.d.ts.map +1 -0
  73. package/dist/metadata.js +100 -0
  74. package/dist/metadata.js.map +1 -0
  75. package/dist/modules.d.ts +74 -0
  76. package/dist/modules.d.ts.map +1 -0
  77. package/dist/modules.js +85 -0
  78. package/dist/modules.js.map +1 -0
  79. package/dist/nonce.d.ts +8 -0
  80. package/dist/nonce.d.ts.map +1 -0
  81. package/dist/nonce.js +12 -0
  82. package/dist/nonce.js.map +1 -0
  83. package/dist/payload.d.ts +171 -0
  84. package/dist/payload.d.ts.map +1 -0
  85. package/dist/payload.js +273 -0
  86. package/dist/payload.js.map +1 -0
  87. package/dist/public-config.d.ts +11 -0
  88. package/dist/public-config.d.ts.map +1 -0
  89. package/dist/public-config.js +10 -0
  90. package/dist/public-config.js.map +1 -0
  91. package/dist/query.d.ts +22 -0
  92. package/dist/query.d.ts.map +1 -0
  93. package/dist/query.js +68 -0
  94. package/dist/query.js.map +1 -0
  95. package/dist/render-cleanup.d.ts +6 -0
  96. package/dist/render-cleanup.d.ts.map +1 -0
  97. package/dist/render-cleanup.js +7 -0
  98. package/dist/render-cleanup.js.map +1 -0
  99. package/dist/root.d.ts +12 -0
  100. package/dist/root.d.ts.map +1 -0
  101. package/dist/root.js +26 -0
  102. package/dist/root.js.map +1 -0
  103. package/dist/route-tree.d.ts +56 -0
  104. package/dist/route-tree.d.ts.map +1 -0
  105. package/dist/route-tree.js +86 -0
  106. package/dist/route-tree.js.map +1 -0
  107. package/dist/router.d.ts +118 -0
  108. package/dist/router.d.ts.map +1 -0
  109. package/dist/router.js +938 -0
  110. package/dist/router.js.map +1 -0
  111. package/dist/schema.d.ts +35 -0
  112. package/dist/schema.d.ts.map +1 -0
  113. package/dist/schema.js +74 -0
  114. package/dist/schema.js.map +1 -0
  115. package/dist/script.d.ts +23 -0
  116. package/dist/script.d.ts.map +1 -0
  117. package/dist/script.js +89 -0
  118. package/dist/script.js.map +1 -0
  119. package/dist/server-entry.d.ts +2 -0
  120. package/dist/server-entry.d.ts.map +1 -0
  121. package/dist/server-entry.js +4 -0
  122. package/dist/server-entry.js.map +1 -0
  123. package/dist/server-render.d.ts +19 -0
  124. package/dist/server-render.d.ts.map +1 -0
  125. package/dist/server-render.js +193 -0
  126. package/dist/server-render.js.map +1 -0
  127. package/dist/testing-setup.d.ts +2 -0
  128. package/dist/testing-setup.d.ts.map +1 -0
  129. package/dist/testing-setup.js +8 -0
  130. package/dist/testing-setup.js.map +1 -0
  131. package/dist/testing.d.ts +76 -0
  132. package/dist/testing.d.ts.map +1 -0
  133. package/dist/testing.js +106 -0
  134. package/dist/testing.js.map +1 -0
  135. package/dist/vitals-config.d.ts +4 -0
  136. package/dist/vitals-config.d.ts.map +1 -0
  137. package/dist/vitals-config.js +5 -0
  138. package/dist/vitals-config.js.map +1 -0
  139. package/dist/vitals.d.ts +13 -0
  140. package/dist/vitals.d.ts.map +1 -0
  141. package/dist/vitals.js +60 -0
  142. package/dist/vitals.js.map +1 -0
  143. package/dist/zod.d.ts +19 -0
  144. package/dist/zod.d.ts.map +1 -0
  145. package/dist/zod.js +65 -0
  146. package/dist/zod.js.map +1 -0
  147. package/package.json +147 -4
  148. package/sidecar/server.mjs +325 -0
  149. package/skills/netjes/SKILL.md +93 -0
  150. package/skills/netjes/references/caching-and-streaming.md +68 -0
  151. package/skills/netjes/references/data-and-actions.md +98 -0
  152. package/skills/netjes/references/deployment.md +57 -0
  153. package/skills/netjes/references/routing.md +65 -0
  154. package/skills/netjes/references/testing.md +57 -0
  155. package/src/actions.tsx +241 -0
  156. package/src/client-entry.tsx +126 -0
  157. package/src/deferred.ts +134 -0
  158. package/src/define-page.tsx +41 -0
  159. package/src/document.tsx +49 -0
  160. package/src/draft-mode.ts +6 -0
  161. package/src/dynamic.tsx +64 -0
  162. package/src/form-data.ts +10 -0
  163. package/src/form.tsx +122 -0
  164. package/src/href.ts +79 -0
  165. package/src/image.tsx +91 -0
  166. package/src/index.ts +40 -0
  167. package/src/link.tsx +73 -0
  168. package/src/locale.ts +6 -0
  169. package/src/metadata.tsx +181 -0
  170. package/src/modules.ts +133 -0
  171. package/src/nonce.tsx +14 -0
  172. package/src/payload.ts +405 -0
  173. package/src/public-config.tsx +19 -0
  174. package/src/query.tsx +82 -0
  175. package/src/render-cleanup.ts +7 -0
  176. package/src/root.tsx +39 -0
  177. package/src/route-tree.tsx +139 -0
  178. package/src/router.tsx +1188 -0
  179. package/src/schema.ts +98 -0
  180. package/src/script.tsx +103 -0
  181. package/src/server-entry.tsx +4 -0
  182. package/src/server-render.tsx +223 -0
  183. package/src/testing-setup.ts +8 -0
  184. package/src/testing.tsx +177 -0
  185. package/src/virtual.d.ts +12 -0
  186. package/src/vitals-config.ts +6 -0
  187. package/src/vitals.tsx +68 -0
  188. package/src/zod.ts +72 -0
  189. package/template/.dockerignore +11 -0
  190. package/template/Dockerfile +58 -0
  191. package/template/Features/Home/HomePage.cs +32 -0
  192. package/template/Program.cs +28 -0
  193. package/template/Properties/launchSettings.json +13 -0
  194. package/template/README.md +24 -0
  195. package/template/SiteProps.cs +4 -0
  196. package/template/_AGENTS.md +64 -0
  197. package/template/_CLAUDE.md +1 -0
  198. package/template/__NAME__.csproj +11 -0
  199. package/template/_gitattributes +2 -0
  200. package/template/_gitignore +11 -0
  201. package/template/app/about/page.tsx +17 -0
  202. package/template/app/error.tsx +15 -0
  203. package/template/app/layout.tsx +33 -0
  204. package/template/app/not-found.tsx +14 -0
  205. package/template/app/page.tsx +14 -0
  206. package/template/appsettings.Development.json +3 -0
  207. package/template/appsettings.json +9 -0
  208. package/template/components/Greeting.test.tsx +25 -0
  209. package/template/components/Greeting.tsx +26 -0
  210. package/template/components/HelloForm.test.tsx +27 -0
  211. package/template/components/HelloForm.tsx +34 -0
  212. package/template/docker-compose.override.yml +15 -0
  213. package/template/docker-compose.prod.yml +26 -0
  214. package/template/docker-compose.yml +16 -0
  215. package/template/generated/netjes.ts +73 -0
  216. package/template/package.json +36 -0
  217. package/template/public/favicon.svg +6 -0
  218. package/template/styles/global.css +7 -0
  219. package/template/tsconfig.json +19 -0
  220. package/template/vite.config.ts +7 -0
  221. package/vite/plugin.d.ts +29 -0
  222. package/vite/plugin.mjs +291 -0
package/src/schema.ts ADDED
@@ -0,0 +1,98 @@
1
+ /** The Standard Schema interface (https://standardschema.dev), implemented by zod, valibot, arktype and others. */
2
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
3
+ readonly '~standard': {
4
+ readonly version: 1;
5
+ readonly vendor: string;
6
+ readonly validate: (value: unknown) => StandardSchemaV1.Result<Output> | Promise<StandardSchemaV1.Result<Output>>;
7
+ readonly types?: { readonly input: Input; readonly output: Output } | undefined;
8
+ };
9
+ }
10
+
11
+ export declare namespace StandardSchemaV1 {
12
+ type Result<Output> = { readonly value: Output; readonly issues?: undefined } | { readonly issues: ReadonlyArray<Issue> };
13
+ type Issue = { readonly message: string; readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }> | undefined };
14
+ type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['output'];
15
+ }
16
+
17
+ function describeIssue(issue: StandardSchemaV1.Issue): string {
18
+ const path = issue.path?.map((segment) => String(typeof segment === 'object' ? segment.key : segment)).join('.');
19
+ return path ? `${path}: ${issue.message}` : issue.message;
20
+ }
21
+
22
+ function validateSync(schema: StandardSchemaV1, value: unknown, what: string) {
23
+ const result = schema['~standard'].validate(value);
24
+ if (result instanceof Promise) throw new Error(`netjes: async schemas are not supported for ${what}`);
25
+ return result;
26
+ }
27
+
28
+ function topLevelKey(issue: StandardSchemaV1.Issue): string | undefined {
29
+ const first = issue.path?.[0];
30
+ const key = typeof first === 'object' ? first.key : first;
31
+ return typeof key === 'string' ? key : undefined;
32
+ }
33
+
34
+ /**
35
+ * Like {@link parseWith}, for input visitors control: values the schema rejects are dropped (so defaults and
36
+ * optional fields apply) before giving up. Only a value that stays invalid without them throws.
37
+ */
38
+ export function parseLenient<Schema extends StandardSchemaV1>(
39
+ schema: Schema,
40
+ value: Record<string, unknown>,
41
+ what: string,
42
+ ): StandardSchemaV1.InferOutput<Schema> {
43
+ const input = { ...value };
44
+ // Each pass drops what the issues name; a schema that names one key at a time must not cost a pass per key.
45
+ for (let pass = 0; ; pass++) {
46
+ const result = validateSync(schema, input, what);
47
+ if (!result.issues) return result.value as StandardSchemaV1.InferOutput<Schema>;
48
+ if (pass >= MAX_PASSES) throw new Error(`Invalid ${what}: ${result.issues.map(describeIssue).join('; ')}`);
49
+ const rejected = result.issues.map(topLevelKey).filter((key): key is string => key !== undefined && key in input);
50
+ // Issues without a path, such as a strict object's unrecognized keys: the keys the issue lists (zod's do), else
51
+ // (for a handful of keys) one whose absence leaves fewer such issues.
52
+ const listed = rejected.length === 0 ? listedKeys(result.issues, input) : [];
53
+ const culprit = rejected.length === 0 && listed.length === 0 ? keyBehindPathlessIssues(schema, input, result.issues, what) : undefined;
54
+ const drop = rejected.length > 0 ? rejected : listed.length > 0 ? listed : culprit === undefined ? [] : [culprit];
55
+ if (drop.length === 0) throw new Error(`Invalid ${what}: ${result.issues.map(describeIssue).join('; ')}`);
56
+ for (const key of drop) delete input[key];
57
+ }
58
+ }
59
+
60
+ /** Keys a pathless issue names itself, like zod's `unrecognized_keys` (`issue.keys`). */
61
+ function listedKeys(issues: readonly StandardSchemaV1.Issue[], input: Record<string, unknown>): string[] {
62
+ return issues.flatMap((issue) => {
63
+ const keys = topLevelKey(issue) === undefined ? (issue as { keys?: unknown }).keys : undefined;
64
+ return Array.isArray(keys) ? keys.filter((key): key is string => typeof key === 'string' && key in input) : [];
65
+ });
66
+ }
67
+
68
+ /** Above these, the search costs too much for input a visitor controls; the parse fails instead. */
69
+ const MAX_SEARCHED_KEYS = 32;
70
+ const MAX_PASSES = 64;
71
+
72
+ function keyBehindPathlessIssues(
73
+ schema: StandardSchemaV1,
74
+ input: Record<string, unknown>,
75
+ issues: readonly StandardSchemaV1.Issue[],
76
+ what: string,
77
+ ): string | undefined {
78
+ const keys = Object.keys(input);
79
+ if (keys.length > MAX_SEARCHED_KEYS) return undefined;
80
+ const pathless = (list: readonly StandardSchemaV1.Issue[]) =>
81
+ new Set(list.filter((issue) => topLevelKey(issue) === undefined).map((issue) => issue.message));
82
+ const before = pathless(issues);
83
+ return keys.find((key) => {
84
+ const { [key]: _, ...rest } = input;
85
+ const result = validateSync(schema, rest, what);
86
+ if (!result.issues) return true;
87
+ // Only a removal that leaves a strict subset: dropping a key a refinement needs adds an issue instead.
88
+ const after = pathless(result.issues);
89
+ return after.size < before.size && [...after].every((message) => before.has(message));
90
+ });
91
+ }
92
+
93
+ /** Validates synchronously (rendering cannot wait) and throws `Invalid <what>: <issues>` when the value is rejected. */
94
+ export function parseWith<Schema extends StandardSchemaV1>(schema: Schema, value: unknown, what: string): StandardSchemaV1.InferOutput<Schema> {
95
+ const result = validateSync(schema, value, what);
96
+ if (result.issues) throw new Error(`Invalid ${what}: ${result.issues.map(describeIssue).join('; ')}`);
97
+ return result.value as StandardSchemaV1.InferOutput<Schema>;
98
+ }
package/src/script.tsx ADDED
@@ -0,0 +1,103 @@
1
+ import { type ScriptHTMLAttributes, useEffect, useRef } from 'react';
2
+ import { preinit } from 'react-dom';
3
+ import { useNonce } from './nonce.tsx';
4
+
5
+ /** When `<Script>` loads its script: before or after the page is interactive, or when the browser is idle. */
6
+ export type ScriptStrategy = 'beforeInteractive' | 'afterInteractive' | 'lazyOnload';
7
+
8
+ /** The props of `<Script>`: a script's own, with a loading strategy and load callbacks. */
9
+ export type ScriptProps = Omit<ScriptHTMLAttributes<HTMLScriptElement>, 'onLoad' | 'onError' | 'children' | 'dangerouslySetInnerHTML'> & {
10
+ /** Identifies the script (required for inline scripts): each is added once, however often it is rendered. */
11
+ id?: string;
12
+ /**
13
+ * `afterInteractive` (default): once the page is interactive. `lazyOnload`: when the browser is idle.
14
+ * `beforeInteractive`: in the server-rendered head, before the page's own scripts (external scripts only).
15
+ */
16
+ strategy?: ScriptStrategy;
17
+ onLoad?: (event: Event) => void;
18
+ onError?: (event: Event) => void;
19
+ /** Inline code, instead of `src`. */
20
+ children?: string;
21
+ };
22
+
23
+ type Entry = { element: HTMLScriptElement; state: 'loading' | 'loaded' | 'failed'; event?: Event };
24
+
25
+ /** Scripts already added to this document, by id or src. */
26
+ const added = new Map<string, Entry>();
27
+
28
+ function whenIdle(run: () => void) {
29
+ const schedule = () =>
30
+ typeof window.requestIdleCallback === 'function' ? window.requestIdleCallback(run) : window.setTimeout(run, 1);
31
+ if (document.readyState === 'complete') schedule();
32
+ else window.addEventListener('load', schedule, { once: true });
33
+ }
34
+
35
+ function add(key: string, { src, children, strategy: _strategy, id, onLoad: _onLoad, onError: _onError, ...attributes }: ScriptProps): Entry {
36
+ const element = document.createElement('script');
37
+ for (const [name, value] of Object.entries(attributes)) {
38
+ if (value === undefined || value === false || typeof value === 'function' || typeof value === 'object') continue;
39
+ element.setAttribute(name === 'className' ? 'class' : name === 'crossOrigin' ? 'crossorigin' : name, value === true ? '' : String(value));
40
+ }
41
+ if (id) element.id = id;
42
+ // The property, not the attribute: browsers hide nonce attributes once a policy applies.
43
+ if (attributes.nonce) element.nonce = attributes.nonce;
44
+ const entry: Entry = { element, state: 'loading' };
45
+ element.addEventListener('load', (event) => Object.assign(entry, { state: 'loaded', event }));
46
+ element.addEventListener('error', (event) => Object.assign(entry, { state: 'failed', event }));
47
+ if (src) {
48
+ element.src = src;
49
+ element.async = true;
50
+ } else {
51
+ element.textContent = children ?? '';
52
+ // Inline scripts run as they are appended; there is no load event to wait for.
53
+ entry.state = 'loaded';
54
+ }
55
+ added.set(key, entry);
56
+ document.body.append(element);
57
+ return entry;
58
+ }
59
+
60
+ /**
61
+ * Like `next/script`: third-party scripts that should not block the page. Renders nothing in place; scripts are
62
+ * added to the document once, whichever component renders them first.
63
+ */
64
+ export function Script(given: ScriptProps) {
65
+ const pageNonce = useNonce();
66
+ const props = given.nonce || !pageNonce ? given : { ...given, nonce: pageNonce };
67
+ const { src, id, strategy = 'afterInteractive', onLoad, onError, nonce, crossOrigin, integrity } = props;
68
+ const callbacks = useRef({ onLoad, onError });
69
+ callbacks.current = { onLoad, onError };
70
+ const key = id ?? src;
71
+
72
+ if (strategy === 'beforeInteractive' && src) {
73
+ preinit(src, { as: 'script', ...(nonce ? { nonce } : {}), ...(integrity ? { integrity } : {}), ...(crossOrigin ? { crossOrigin } : {}) });
74
+ }
75
+
76
+ useEffect(() => {
77
+ if (!key || (strategy === 'beforeInteractive' && src)) return;
78
+ let cancelled = false;
79
+ const attach = () => {
80
+ if (cancelled) return;
81
+ const entry = added.get(key) ?? add(key, props);
82
+ const notify = () => {
83
+ if (cancelled) return;
84
+ if (entry.state === 'loaded') callbacks.current.onLoad?.(entry.event ?? new Event('load'));
85
+ else if (entry.state === 'failed') callbacks.current.onError?.(entry.event ?? new Event('error'));
86
+ };
87
+ if (entry.state !== 'loading') {
88
+ notify();
89
+ return;
90
+ }
91
+ entry.element.addEventListener('load', notify, { once: true });
92
+ entry.element.addEventListener('error', notify, { once: true });
93
+ };
94
+ if (strategy === 'lazyOnload') whenIdle(attach);
95
+ else attach();
96
+ return () => {
97
+ cancelled = true;
98
+ };
99
+ // The script is identified by its key; changing other props does not add it again.
100
+ }, [key, strategy]);
101
+
102
+ return null;
103
+ }
@@ -0,0 +1,4 @@
1
+ import { pages } from 'virtual:netjes/pages';
2
+ import { createServerRenderer } from './server-render.tsx';
3
+
4
+ export const { render, renderToStream } = createServerRenderer(pages);
@@ -0,0 +1,223 @@
1
+ import { type Readable, Transform, Writable } from 'node:stream';
2
+ import type { ReactNode } from 'react';
3
+ import { renderToPipeableStream } from 'react-dom/server';
4
+ import { type DeferredSource, deferredScript, revivePayload } from './deferred.ts';
5
+ import { type ModuleMap, createModuleLoader } from './modules.ts';
6
+ import { type InitialPayload, parseInitialPayload } from './payload.ts';
7
+ import { RenderCleanupContext } from './render-cleanup.ts';
8
+ import { Root } from './root.tsx';
9
+
10
+ export type RenderOptions = {
11
+ /** Aborts the render (React stops waiting on suspended content), e.g. when the server stops waiting for it. */
12
+ signal?: AbortSignal;
13
+ /** Promises for the payload's Page.Defer placeholders, by id (the sidecar settles them as .NET resolves them). */
14
+ deferred?: (id: string) => PromiseLike<unknown>;
15
+ };
16
+
17
+ export type StreamOptions = RenderOptions & {
18
+ /** Start sending only once everything has rendered (no Suspense fallbacks): for crawlers and the static export. */
19
+ waitForAll?: boolean;
20
+ };
21
+
22
+ /**
23
+ * React writes a Suspense boundary that is ready when the shell goes out in place, unless what it has written so far
24
+ * (the shell, with the payload and dev styles) and the boundary pass this many bytes (12.8 kB by default): then it
25
+ * sends the boundary after the shell, hidden, for an inline script to reveal, which a browser without JavaScript never
26
+ * runs. No limit: whatever is ready goes in place, and only what is still pending streams.
27
+ */
28
+ const progressiveChunkSize = Number.POSITIVE_INFINITY;
29
+
30
+ /**
31
+ * Streams the document: resolves once the shell (everything outside unresolved Suspense boundaries) is ready, with
32
+ * a stream that carries it and then each boundary as it resolves. Rejects, before anything is sent, if the shell
33
+ * fails to render.
34
+ */
35
+ function renderToStream(
36
+ element: ReactNode,
37
+ options: StreamOptions,
38
+ deferredIds: readonly string[],
39
+ source: DeferredSource,
40
+ nonce: string | undefined,
41
+ ): Promise<Readable> {
42
+ const { signal, waitForAll = false } = options;
43
+ return new Promise((resolve, reject) => {
44
+ signal?.throwIfAborted();
45
+ // Passes React's chunks through, and puts a script for each deferred value between them once the shell is out.
46
+ // React writes a flush as several synchronous chunks, so a chunk can end in the middle of an element: scripts are
47
+ // only ever pushed from a later turn (setImmediate, or a promise callback), when React is between flushes.
48
+ let shellSent = false;
49
+ let draining = false;
50
+ const queued: string[] = [];
51
+ const drain = () => {
52
+ draining = false;
53
+ if (output.writableEnded || output.destroyed) return;
54
+ for (const script of queued.splice(0)) output.push(script);
55
+ };
56
+ const output = new Transform({
57
+ transform(chunk, _encoding, callback) {
58
+ shellSent = true;
59
+ this.push(chunk);
60
+ if (queued.length > 0 && !draining) {
61
+ draining = true;
62
+ setImmediate(drain);
63
+ }
64
+ callback();
65
+ },
66
+ flush(callback) {
67
+ for (const script of queued.splice(0)) this.push(script);
68
+ callback();
69
+ },
70
+ });
71
+ const send = (script: string) => {
72
+ if (shellSent && !draining && !output.writableEnded) output.push(script);
73
+ else queued.push(script);
74
+ };
75
+ for (const id of deferredIds) {
76
+ source(id).then(
77
+ (value) => send(deferredScript(id, value, undefined, nonce)),
78
+ (error: unknown) => send(deferredScript(id, null, error instanceof Error ? error.message : String(error), nonce)),
79
+ );
80
+ }
81
+ let started = false;
82
+ const start = () => {
83
+ started = true;
84
+ stream.pipe(output);
85
+ resolve(output);
86
+ };
87
+ const stream = renderToPipeableStream(element, {
88
+ // React's own inline scripts (Suspense reveals) carry the CSP nonce.
89
+ ...(nonce ? { nonce } : {}),
90
+ progressiveChunkSize,
91
+ onShellReady() {
92
+ if (!waitForAll) start();
93
+ },
94
+ onAllReady() {
95
+ if (waitForAll) start();
96
+ },
97
+ onShellError: reject,
98
+ onError(error) {
99
+ // Errors inside a Suspense boundary: React streams the fallback and retries on the client.
100
+ if (!signal?.aborted) console.error(error);
101
+ },
102
+ });
103
+ signal?.addEventListener(
104
+ 'abort',
105
+ () => {
106
+ stream.abort(signal.reason);
107
+ if (started) output.destroy(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)));
108
+ else reject(signal.reason);
109
+ },
110
+ { once: true },
111
+ );
112
+ });
113
+ }
114
+
115
+ function renderToHtml(element: ReactNode, signal: AbortSignal | undefined, nonce: string | undefined): Promise<string> {
116
+ return new Promise((resolve, reject) => {
117
+ signal?.throwIfAborted();
118
+ const chunks: Buffer[] = [];
119
+ const sink = new Writable({
120
+ write(chunk: Buffer | string, _encoding, callback) {
121
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
122
+ callback();
123
+ },
124
+ final(callback) {
125
+ resolve(Buffer.concat(chunks).toString('utf8'));
126
+ callback();
127
+ },
128
+ });
129
+ const stream = renderToPipeableStream(element, {
130
+ // For any inline script React itself writes.
131
+ ...(nonce ? { nonce } : {}),
132
+ progressiveChunkSize,
133
+ // Wait for every Suspense boundary: the sidecar answers with one complete document.
134
+ onAllReady() {
135
+ stream.pipe(sink);
136
+ },
137
+ onShellError: reject,
138
+ onError(error) {
139
+ // Errors inside a Suspense boundary: React renders the fallback and retries on the client.
140
+ if (!signal?.aborted) console.error(error);
141
+ },
142
+ });
143
+ signal?.addEventListener(
144
+ 'abort',
145
+ () => {
146
+ reject(signal.reason);
147
+ stream.abort(signal.reason);
148
+ },
149
+ { once: true },
150
+ );
151
+ });
152
+ }
153
+
154
+ /** Revives the payload's deferred placeholders, noting which ids it found. */
155
+ function withDeferred(payload: InitialPayload, deferred: RenderOptions['deferred']) {
156
+ const ids: string[] = [];
157
+ const promises = new Map<string, Promise<unknown>>();
158
+ const source: DeferredSource = (id) => {
159
+ let promise = promises.get(id);
160
+ if (!promise) {
161
+ ids.push(id);
162
+ // Without a source (e.g. a test render) a deferred value never arrives; its boundary shows the fallback.
163
+ promise = deferred ? Promise.resolve(deferred(id)) : new Promise<unknown>(() => {});
164
+ promise.catch(() => {});
165
+ promises.set(id, promise);
166
+ }
167
+ return promise;
168
+ };
169
+ return { payload: revivePayload(payload, source), ids, source };
170
+ }
171
+
172
+ /** What one render registered through {@link RenderCleanupContext}, run once its document is done. */
173
+ function cleanups() {
174
+ const registered: (() => void)[] = [];
175
+ return {
176
+ wrap: (element: ReactNode) => <RenderCleanupContext value={(cleanup) => registered.push(cleanup)}>{element}</RenderCleanupContext>,
177
+ run: () => {
178
+ for (const cleanup of registered.splice(0)) cleanup();
179
+ },
180
+ };
181
+ }
182
+
183
+ export function createServerRenderer(modules: ModuleMap) {
184
+ const loader = createModuleLoader(modules);
185
+ return {
186
+ /** Renders the page described by the (HTML-safe) payload JSON to a complete HTML document. */
187
+ async render(dataJson: string, options: RenderOptions = {}): Promise<string> {
188
+ const { payload } = withDeferred(parseInitialPayload(JSON.parse(dataJson)), options.deferred);
189
+ const routeModules = await loader.loadRoute(payload);
190
+ const done = cleanups();
191
+ try {
192
+ return await renderToHtml(
193
+ done.wrap(<Root payload={payload} modules={routeModules} loader={loader} dataJson={dataJson} />),
194
+ options.signal,
195
+ payload.document.nonce,
196
+ );
197
+ } finally {
198
+ done.run();
199
+ }
200
+ },
201
+ /** Like {@link render}, streamed: see {@link StreamOptions}. */
202
+ async renderToStream(dataJson: string, options: StreamOptions = {}): Promise<Readable> {
203
+ const { payload, ids, source } = withDeferred(parseInitialPayload(JSON.parse(dataJson)), options.deferred);
204
+ const routeModules = await loader.loadRoute(payload);
205
+ const done = cleanups();
206
+ try {
207
+ const stream = await renderToStream(
208
+ done.wrap(<Root payload={payload} modules={routeModules} loader={loader} dataJson={dataJson} />),
209
+ options,
210
+ ids,
211
+ source,
212
+ payload.document.nonce,
213
+ );
214
+ // Ended, failed or abandoned: the document needs nothing more from the render.
215
+ stream.once('close', done.run);
216
+ return stream;
217
+ } catch (error) {
218
+ done.run();
219
+ throw error;
220
+ }
221
+ },
222
+ };
223
+ }
@@ -0,0 +1,8 @@
1
+ // Registered by netjes() as a Vitest setup file: unmount what each test rendered (Vitest has no globals, so
2
+ // Testing Library cannot register this itself).
3
+ import { cleanup } from '@testing-library/react';
4
+ import { afterEach } from 'vitest';
5
+
6
+ afterEach(() => {
7
+ cleanup();
8
+ });
@@ -0,0 +1,177 @@
1
+ // Test harness for components and pages that use Netjes: `render(<Page />, { wrapper: createTestApp().Wrapper })`.
2
+ // The router is a fake: it records what the component asked for and moves to the new URL, but loads nothing.
3
+ import { type DefaultOptions, QueryClient, QueryClientProvider, hydrate } from '@tanstack/react-query';
4
+ import { type ReactNode, useLayoutEffect, useMemo, useState } from 'react';
5
+ import { type ActionClient, type ActionState, setActionClient } from './actions.tsx';
6
+ import type { AntiforgeryToken, PagePayload, Params } from './payload.ts';
7
+ import { PublicConfigProvider } from './public-config.tsx';
8
+ import { dehydratedStateOf, mergeOptions } from './query.tsx';
9
+ import { type InternalRouter, type NavigationState, RouterContexts, type SubmitBody } from './router.tsx';
10
+
11
+ /** What the fake app of `createTestApp` reports to the components under test. */
12
+ export type TestAppOptions = {
13
+ /** The current URL. Default `/`. */
14
+ url?: string;
15
+ /** The page's route pattern, e.g. `/blog/[slug]`. */
16
+ route?: string;
17
+ /** What `useLocale()` returns. */
18
+ locale?: string;
19
+ /** What `useDraftMode()` reports. */
20
+ draftMode?: boolean;
21
+ /** What `usePublicConfig()` returns, like the server's `Netjes:PublicConfig` section. */
22
+ publicConfig?: Record<string, unknown>;
23
+ params?: Params;
24
+ /** What the server would send as `SharedProps`. */
25
+ shared?: unknown;
26
+ /** Seeded into the QueryClient the way `PageResult.WithQuery` seeds it, as of now. */
27
+ queries?: ReadonlyArray<{ key: readonly unknown[]; data: unknown }>;
28
+ /** Like `netjes({ query: { defaultOptions } })`; the harness also turns retries off. */
29
+ defaultOptions?: DefaultOptions;
30
+ /** What `useNavigation()` returns. Default idle. */
31
+ navigation?: NavigationState;
32
+ /** The token `<Form method="post">` adds. Default a test token; `false` for none. */
33
+ antiforgery?: AntiforgeryToken | false;
34
+ queryClient?: QueryClient;
35
+ /**
36
+ * Answers server action calls by name; unknown actions succeed with no result. Like `.WithQuery` and
37
+ * `.Invalidate` in C#, an answer can set and invalidate query entries.
38
+ */
39
+ actions?: Record<string, (input: unknown) => TestActionAnswer | Promise<TestActionAnswer>>;
40
+ };
41
+
42
+ /** What a fake server action answers: a success or failure state, plus query entries to set or invalidate. */
43
+ export type TestActionAnswer = ActionState<unknown> & {
44
+ queries?: ReadonlyArray<{ key: readonly unknown[]; data: unknown }>;
45
+ invalidate?: ReadonlyArray<readonly unknown[]>;
46
+ /** 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
+ fields?: readonly string[];
48
+ };
49
+
50
+ /** One thing the component under test did: a navigation, a router call, a form post or a server action call. */
51
+ export type TestNavigation =
52
+ | { type: 'push' | 'replace'; href: string }
53
+ | { type: 'refresh' | 'back' | 'forward' }
54
+ | { type: 'submit'; action: string; data: URLSearchParams | FormData }
55
+ | { type: 'action'; name: string; input: unknown };
56
+
57
+ /** The fake app `createTestApp` returns: a wrapper for Testing Library and a record of what the components did. */
58
+ export type TestApp = {
59
+ /** Pass as Testing Library's `wrapper`. */
60
+ Wrapper: (props: { children: ReactNode }) => ReactNode;
61
+ /** Every navigation, router call and form post, in order. */
62
+ navigations: TestNavigation[];
63
+ queryClient: QueryClient;
64
+ };
65
+
66
+ /**
67
+ * Registers the stand-in in the commit, and only then: a render React throws away (`renderToString`, children that
68
+ * threw) leaves calls made outside any Wrapper failing at once. React runs the effects of earlier siblings first, and
69
+ * a component's after those of the components inside it, so rendered ahead of the children this runs before any of
70
+ * their effects, layout effects included.
71
+ */
72
+ function RegisterActionClient({ client }: { client: ActionClient }) {
73
+ useLayoutEffect(() => setActionClient(client), [client]);
74
+ return null;
75
+ }
76
+
77
+ const TEST_TOKEN: AntiforgeryToken = { formFieldName: '__RequestVerificationToken', headerName: 'RequestVerificationToken', requestToken: 'test-token' };
78
+
79
+ function dataOf(body: SubmitBody): URLSearchParams | FormData {
80
+ return body.contentType === null ? body.body : new URLSearchParams(body.body);
81
+ }
82
+
83
+ /** 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
+ export function createTestApp(options: TestAppOptions = {}): TestApp {
85
+ const navigations: TestNavigation[] = [];
86
+ const defaults = mergeOptions(options.defaultOptions);
87
+ const queryClient =
88
+ options.queryClient ??
89
+ new QueryClient({ defaultOptions: { ...defaults, queries: { retry: false, gcTime: Number.POSITIVE_INFINITY, ...defaults.queries } } });
90
+ const now = Date.now();
91
+ const seeded = dehydratedStateOf(options.queries?.map((query) => ({ ...query, updatedAt: now })));
92
+ if (seeded) hydrate(queryClient, seeded);
93
+ const initialUrl = new URL(options.url ?? '/', window.location.origin);
94
+ // Forms without an action post to the current URL, which they read from the window.
95
+ window.history.replaceState(null, '', initialUrl.href);
96
+ const antiforgery = options.antiforgery === false ? undefined : (options.antiforgery ?? TEST_TOKEN);
97
+ // The router's stand-in for server action calls: records each one and answers it from `options.actions`.
98
+ const actionClient: ActionClient = {
99
+ async invoke(name, _url, input) {
100
+ navigations.push({ type: 'action', name, input });
101
+ const answer: TestActionAnswer = (await options.actions?.[name]?.(input)) ?? { ok: true, result: undefined };
102
+ for (const query of answer.queries ?? []) queryClient.setQueryData(query.key, query.data);
103
+ for (const queryKey of answer.invalidate ?? []) void queryClient.invalidateQueries({ queryKey: [...queryKey] });
104
+ return answer.ok
105
+ ? { kind: 'action', ok: true, status: 200, result: answer.result, revalidate: false }
106
+ : {
107
+ kind: 'action',
108
+ ok: false,
109
+ status: answer.status,
110
+ errors: answer.errors,
111
+ ...(answer.fields ? { fields: [...answer.fields] } : {}),
112
+ revalidate: false,
113
+ };
114
+ },
115
+ };
116
+
117
+ function Wrapper({ children }: { children: ReactNode }) {
118
+ const [url, setUrl] = useState(initialUrl);
119
+ const router = useMemo<InternalRouter>(() => {
120
+ const go = (type: 'push' | 'replace', href: string) => {
121
+ navigations.push({ type, href });
122
+ const next = new URL(href, window.location.href);
123
+ window.history.replaceState(null, '', next.href);
124
+ setUrl(next);
125
+ return Promise.resolve();
126
+ };
127
+ return {
128
+ push: (href) => go('push', href),
129
+ replace: (href) => go('replace', href),
130
+ prefetch: () => {},
131
+ refresh: () => {
132
+ navigations.push({ type: 'refresh' });
133
+ return Promise.resolve();
134
+ },
135
+ back: () => navigations.push({ type: 'back' }),
136
+ forward: () => navigations.push({ type: 'forward' }),
137
+ submit: (action, body) => {
138
+ navigations.push({ type: 'submit', action: action.href, data: dataOf(body) });
139
+ return Promise.resolve();
140
+ },
141
+ onActionResponse: () => () => {},
142
+ onCookiesChanged: () => () => {},
143
+ };
144
+ }, []);
145
+ const payload = useMemo<PagePayload>(
146
+ () => ({
147
+ kind: 'page',
148
+ route: options.route ?? null,
149
+ page: null,
150
+ segments: [],
151
+ props: {},
152
+ params: options.params ?? {},
153
+ shared: options.shared ?? null,
154
+ status: 200,
155
+ url: url.pathname + url.search,
156
+ version: 'test',
157
+ ...(antiforgery ? { antiforgery } : {}),
158
+ ...(options.draftMode ? { draftMode: true } : {}),
159
+ ...(options.locale ? { locale: options.locale } : {}),
160
+ }),
161
+ [url],
162
+ );
163
+
164
+ return (
165
+ <QueryClientProvider client={queryClient}>
166
+ <PublicConfigProvider value={options.publicConfig ?? {}}>
167
+ <RouterContexts router={router} payload={payload} url={url} navigation={options.navigation ?? { state: 'idle' }}>
168
+ <RegisterActionClient client={actionClient} />
169
+ {children}
170
+ </RouterContexts>
171
+ </PublicConfigProvider>
172
+ </QueryClientProvider>
173
+ );
174
+ }
175
+
176
+ return { Wrapper, navigations, queryClient };
177
+ }
@@ -0,0 +1,12 @@
1
+ declare module 'virtual:netjes/pages' {
2
+ /** Every page, layout and other convention file, keyed `/app/...`, as lazy imports. Generated by the netjes Vite plugin. */
3
+ export const pages: Record<string, () => Promise<unknown>>;
4
+ }
5
+
6
+ declare module 'virtual:netjes/query' {
7
+ import type { ComponentType, ReactNode } from 'react';
8
+ /** The QueryClient provider, or undefined when the app turned TanStack Query off (`netjes({ query: false })`). */
9
+ export const QueryShell: ComponentType<{ children: ReactNode }> | undefined;
10
+ }
11
+
12
+ declare module '@vitejs/plugin-react/preamble';
@@ -0,0 +1,6 @@
1
+ import { createContext } from 'react';
2
+
3
+ /** Where `<WebVitals />` sends reports: set when the server collects them (`NetjesOptions.CollectWebVitals`). */
4
+ export const VitalsEndpointContext = createContext<string | undefined>(undefined);
5
+
6
+ export const VitalsEndpointProvider = VitalsEndpointContext.Provider;