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
package/src/payload.ts
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import type { Metadata } from './metadata.tsx';
|
|
2
|
+
|
|
3
|
+
// The JSON contract with the .NET side (src/Netjes/Rendering/PagePayload.cs). Keep the two in sync.
|
|
4
|
+
|
|
5
|
+
export const DATA_HEADER = 'X-Netjes-Data';
|
|
6
|
+
export const SERVER_TIME_HEADER = 'X-Netjes-Now';
|
|
7
|
+
export const SEGMENTS_HEADER = 'X-Netjes-Segments';
|
|
8
|
+
/** `invalid` on an action's 400 when the server refused the call's antiforgery token: the action did not run. */
|
|
9
|
+
export const ANTIFORGERY_HEADER = 'X-Netjes-Antiforgery';
|
|
10
|
+
/**
|
|
11
|
+
* `changed` on the response to an action call or a form post that set or deleted a cookie (signing in or out): the
|
|
12
|
+
* pages the router keeps for back and forward show the app as it was before.
|
|
13
|
+
*/
|
|
14
|
+
export const COOKIES_HEADER = 'X-Netjes-Cookies';
|
|
15
|
+
export const DATA_ELEMENT_ID = '__NETJES_DATA__';
|
|
16
|
+
/** The id of the empty element that ends the body of every server-rendered document, and so the shell. */
|
|
17
|
+
export const SHELL_END_ID = '__NETJES_SHELL_END__';
|
|
18
|
+
|
|
19
|
+
/** The page's route values by parameter name: a string per single segment, an array per catch-all (empty for an optional one). A single parameter the request has no value for is `null`, as when a page is rendered with `Page.Render` from an endpoint whose route lacks it. */
|
|
20
|
+
export type Params = Record<string, string | string[] | null>;
|
|
21
|
+
|
|
22
|
+
export type DocumentAssets = {
|
|
23
|
+
stylesheets: string[];
|
|
24
|
+
modulePreloads: string[];
|
|
25
|
+
scripts: string[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A folder's segment files, outermost first: the client wraps the page in each segment's layout, template,
|
|
30
|
+
* error boundary and loading (Suspense) fallback. `props` come from the folder's C# layout loader.
|
|
31
|
+
*/
|
|
32
|
+
export type SegmentRef = {
|
|
33
|
+
id: string;
|
|
34
|
+
layout?: string | undefined;
|
|
35
|
+
template?: string | undefined;
|
|
36
|
+
loading?: string | undefined;
|
|
37
|
+
error?: string | undefined;
|
|
38
|
+
props?: Record<string, unknown> | undefined;
|
|
39
|
+
/** Identifies what the segment's loader saw; the client sends the keys it holds on navigations. */
|
|
40
|
+
key?: string | undefined;
|
|
41
|
+
/** The loader was skipped because the client holds this segment (same key); its props are the client's. */
|
|
42
|
+
reused?: boolean | undefined;
|
|
43
|
+
/** Parallel routes: the file each `@slot` renders for this page (its page, else its default), or null. */
|
|
44
|
+
slots?: Record<string, string | null> | undefined;
|
|
45
|
+
/** How many URL segments a layout's folder covers for this URL (a catch-all counts what it matched). */
|
|
46
|
+
depth?: number | undefined;
|
|
47
|
+
/** Each slot page's own route values (its parameter names can differ from the page's). */
|
|
48
|
+
slotParams?: Record<string, Params> | undefined;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type SpecialPageRef = { page: string; segments: SegmentRef[] };
|
|
52
|
+
|
|
53
|
+
/** An intercepting page that can show this URL in a slot, with its own route values. */
|
|
54
|
+
export type InterceptRef = { segment: string; slot: string; page: string; params?: Params | undefined };
|
|
55
|
+
|
|
56
|
+
/** Why the server rendered a boundary file instead of the route's page. */
|
|
57
|
+
export type PageKind = 'notFound' | 'error' | 'forbidden' | 'unauthorized';
|
|
58
|
+
|
|
59
|
+
/** CSS inlined by the dev sidecar so server-rendered pages are styled before the client script runs. */
|
|
60
|
+
export type DevStyle = { id: string; css: string };
|
|
61
|
+
|
|
62
|
+
export type AntiforgeryToken = {
|
|
63
|
+
formFieldName: string;
|
|
64
|
+
headerName: string | null;
|
|
65
|
+
requestToken: string | null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** A TanStack Query cache entry seeded by the C# loader (`PageResult.WithQuery`). `updatedAt` is Unix milliseconds. */
|
|
69
|
+
export type SeededQuery = { key: readonly unknown[]; data: unknown; updatedAt: number };
|
|
70
|
+
|
|
71
|
+
/** Left by a server action posted without JavaScript that failed validation; the form's page renders with it. */
|
|
72
|
+
export type ActionStateRef = {
|
|
73
|
+
action: string;
|
|
74
|
+
errors: Record<string, string[]>;
|
|
75
|
+
values: Record<string, string | string[]>;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** What a server action call answers with (`ServerActionResult` in C#). */
|
|
79
|
+
export type ActionResponse = {
|
|
80
|
+
kind: 'action';
|
|
81
|
+
ok: boolean;
|
|
82
|
+
status: number;
|
|
83
|
+
result?: unknown;
|
|
84
|
+
errors?: Record<string, string[]>;
|
|
85
|
+
/** After a failed form call: the submitted fields the failure state may hand back (declared by the action, not private). */
|
|
86
|
+
fields?: string[];
|
|
87
|
+
redirect?: string;
|
|
88
|
+
revalidate: boolean;
|
|
89
|
+
invalidate?: unknown[][];
|
|
90
|
+
queries?: SeededQuery[];
|
|
91
|
+
message?: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type PagePayload = {
|
|
95
|
+
kind: 'page';
|
|
96
|
+
/** The page's URL pattern, e.g. `/blog/[slug]`; null for the 404 and error pages. */
|
|
97
|
+
route: string | null;
|
|
98
|
+
/** Module path of the page, e.g. `app/blog/[slug]/page.tsx`; null renders the built-in 404/error page. */
|
|
99
|
+
page: string | null;
|
|
100
|
+
/** Set when `page` is a not-found, error, forbidden or unauthorized file rather than the route's page. */
|
|
101
|
+
pageKind?: PageKind;
|
|
102
|
+
segments: SegmentRef[];
|
|
103
|
+
props: Record<string, unknown>;
|
|
104
|
+
params: Params;
|
|
105
|
+
shared: unknown;
|
|
106
|
+
status: number;
|
|
107
|
+
url: string;
|
|
108
|
+
version: string;
|
|
109
|
+
/** The server's clock (Unix ms) when it built this payload. */
|
|
110
|
+
renderedAt?: number;
|
|
111
|
+
antiforgery?: AntiforgeryToken;
|
|
112
|
+
queries?: SeededQuery[];
|
|
113
|
+
actionState?: ActionStateRef;
|
|
114
|
+
/** Intercepting routes: slots that may show this page instead, on a client navigation from inside their segment. */
|
|
115
|
+
intercepts?: InterceptRef[];
|
|
116
|
+
/** From the C# loader (`.WithMetadata(...)`), merged over the files' `export const metadata`. */
|
|
117
|
+
metadata?: Metadata;
|
|
118
|
+
/** The page was rendered in draft mode. */
|
|
119
|
+
draftMode?: boolean;
|
|
120
|
+
/** `UsePathBase`'s base path, when the app is not mounted at the root; `url` includes it. */
|
|
121
|
+
basePath?: string;
|
|
122
|
+
/** The request culture, with `AddNetjesLocalization`. */
|
|
123
|
+
locale?: string;
|
|
124
|
+
/**
|
|
125
|
+
* Client only, never sent: how far the browser clock is ahead of the server's, measured when the payload arrived.
|
|
126
|
+
* Server timestamps plus this offset are browser timestamps.
|
|
127
|
+
*/
|
|
128
|
+
clockOffset?: number;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** The `<Image>` optimizer: its path and the widths it serves (Netjes:Images). */
|
|
132
|
+
export type ImageConfigInfo = { path: string; widths: number[]; qualities?: number[]; basePath?: string };
|
|
133
|
+
|
|
134
|
+
export type DocumentInfo = {
|
|
135
|
+
lang: string;
|
|
136
|
+
assets: DocumentAssets;
|
|
137
|
+
errorPage: SpecialPageRef | null;
|
|
138
|
+
devStyles?: DevStyle[];
|
|
139
|
+
images?: ImageConfigInfo;
|
|
140
|
+
/** The `Netjes:PublicConfig` configuration section. */
|
|
141
|
+
publicConfig?: Record<string, unknown>;
|
|
142
|
+
/** The Content-Security-Policy nonce for this document's scripts and styles. */
|
|
143
|
+
nonce?: string;
|
|
144
|
+
/** Where `<WebVitals />` reports to, when the server collects web vitals. */
|
|
145
|
+
vitalsEndpoint?: string;
|
|
146
|
+
/**
|
|
147
|
+
* No sidecar was ready to render this document, so the server sent it without the page: the client entry renders
|
|
148
|
+
* it instead of hydrating.
|
|
149
|
+
*/
|
|
150
|
+
clientRender?: boolean;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export type InitialPayload = PagePayload & { document: DocumentInfo };
|
|
154
|
+
|
|
155
|
+
export type RedirectPayload = { kind: 'redirect'; location: string; version: string };
|
|
156
|
+
|
|
157
|
+
export type NavigationPayload = PagePayload | RedirectPayload;
|
|
158
|
+
|
|
159
|
+
export class PayloadError extends Error {
|
|
160
|
+
override name = 'PayloadError';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
type Json = Record<string, unknown>;
|
|
164
|
+
|
|
165
|
+
function fail(message: string): never {
|
|
166
|
+
throw new PayloadError(`Invalid netjes payload: ${message}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function object(value: unknown, what: string): Json {
|
|
170
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) fail(`${what} must be an object`);
|
|
171
|
+
return value as Json;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function string(value: unknown, what: string): string {
|
|
175
|
+
if (typeof value !== 'string') fail(`${what} must be a string`);
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function nullableString(value: unknown, what: string): string | null {
|
|
180
|
+
return value === null ? null : string(value, what);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function number(value: unknown, what: string): number {
|
|
184
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) fail(`${what} must be a number`);
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function strings(value: unknown, what: string): string[] {
|
|
189
|
+
if (!Array.isArray(value)) fail(`${what} must be an array`);
|
|
190
|
+
return value.map((item, i) => string(item, `${what}[${i}]`));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function params(value: unknown): Params {
|
|
194
|
+
const result: Params = {};
|
|
195
|
+
for (const [key, item] of Object.entries(object(value, 'params'))) {
|
|
196
|
+
result[key] = Array.isArray(item) ? strings(item, `params.${key}`) : nullableString(item, `params.${key}`);
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function parseAntiforgery(value: unknown): AntiforgeryToken {
|
|
202
|
+
return antiforgery(value);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function antiforgery(value: unknown): AntiforgeryToken {
|
|
206
|
+
const json = object(value, 'antiforgery');
|
|
207
|
+
return {
|
|
208
|
+
formFieldName: string(json.formFieldName, 'antiforgery.formFieldName'),
|
|
209
|
+
headerName: nullableString(json.headerName ?? null, 'antiforgery.headerName'),
|
|
210
|
+
requestToken: nullableString(json.requestToken ?? null, 'antiforgery.requestToken'),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function optionalString(value: unknown, what: string): string | undefined {
|
|
215
|
+
return value === undefined || value === null ? undefined : string(value, what);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function segments(value: unknown, what: string): SegmentRef[] {
|
|
219
|
+
if (!Array.isArray(value)) fail(`${what} must be an array`);
|
|
220
|
+
return value.map((item, i) => {
|
|
221
|
+
const json = object(item, `${what}[${i}]`);
|
|
222
|
+
const segment: SegmentRef = { id: string(json.id, `${what}[${i}].id`) };
|
|
223
|
+
for (const key of ['layout', 'template', 'loading', 'error'] as const) {
|
|
224
|
+
const file = optionalString(json[key], `${what}[${i}].${key}`);
|
|
225
|
+
if (file !== undefined) segment[key] = file;
|
|
226
|
+
}
|
|
227
|
+
if (json.props !== undefined && json.props !== null) segment.props = object(json.props, `${what}[${i}].props`);
|
|
228
|
+
const key = optionalString(json.key, `${what}[${i}].key`);
|
|
229
|
+
if (key !== undefined) segment.key = key;
|
|
230
|
+
if (json.reused === true) segment.reused = true;
|
|
231
|
+
if (json.depth !== undefined && json.depth !== null) {
|
|
232
|
+
if (typeof json.depth !== 'number' || !Number.isInteger(json.depth) || json.depth < 0) {
|
|
233
|
+
throw new PayloadError(`${what}[${i}].depth must be a whole number`);
|
|
234
|
+
}
|
|
235
|
+
segment.depth = json.depth;
|
|
236
|
+
}
|
|
237
|
+
if (json.slotParams !== undefined && json.slotParams !== null) {
|
|
238
|
+
segment.slotParams = Object.fromEntries(
|
|
239
|
+
Object.entries(object(json.slotParams, `${what}[${i}].slotParams`)).map(([name, values]) => [name, params(values)]),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (json.slots !== undefined && json.slots !== null) {
|
|
243
|
+
const slots: Record<string, string | null> = {};
|
|
244
|
+
for (const [name, file] of Object.entries(object(json.slots, `${what}[${i}].slots`))) {
|
|
245
|
+
slots[name] = nullableString(file, `${what}[${i}].slots.${name}`);
|
|
246
|
+
}
|
|
247
|
+
segment.slots = slots;
|
|
248
|
+
}
|
|
249
|
+
return segment;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function errorMap(value: unknown, what: string): Record<string, string[]> {
|
|
254
|
+
const result: Record<string, string[]> = {};
|
|
255
|
+
for (const [key, messages] of Object.entries(object(value, what))) result[key] = strings(messages, `${what}.${key}`);
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function actionState(value: unknown): ActionStateRef {
|
|
260
|
+
const json = object(value, 'actionState');
|
|
261
|
+
const values: Record<string, string | string[]> = {};
|
|
262
|
+
for (const [key, item] of Object.entries(object(json.values ?? {}, 'actionState.values'))) {
|
|
263
|
+
values[key] = Array.isArray(item) ? strings(item, `actionState.values.${key}`) : string(item, `actionState.values.${key}`);
|
|
264
|
+
}
|
|
265
|
+
return { action: string(json.action, 'actionState.action'), errors: errorMap(json.errors ?? {}, 'actionState.errors'), values };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function parseActionResponse(value: unknown): ActionResponse {
|
|
269
|
+
const json = object(value, 'action response');
|
|
270
|
+
if (json.kind !== 'action') fail(`expected an action response, got kind ${JSON.stringify(json.kind)}`);
|
|
271
|
+
const response: ActionResponse = {
|
|
272
|
+
kind: 'action',
|
|
273
|
+
ok: json.ok === true,
|
|
274
|
+
status: number(json.status, 'status'),
|
|
275
|
+
revalidate: json.revalidate === true,
|
|
276
|
+
};
|
|
277
|
+
if ('result' in json) response.result = json.result;
|
|
278
|
+
if (json.errors !== undefined && json.errors !== null) response.errors = errorMap(json.errors, 'errors');
|
|
279
|
+
if (json.fields !== undefined && json.fields !== null) response.fields = strings(json.fields, 'fields');
|
|
280
|
+
if (json.redirect !== undefined && json.redirect !== null) response.redirect = string(json.redirect, 'redirect');
|
|
281
|
+
if (json.invalidate !== undefined && json.invalidate !== null) {
|
|
282
|
+
if (!Array.isArray(json.invalidate) || !json.invalidate.every(Array.isArray)) fail('invalidate must be an array of query keys');
|
|
283
|
+
response.invalidate = json.invalidate as unknown[][];
|
|
284
|
+
}
|
|
285
|
+
if (json.queries !== undefined && json.queries !== null) response.queries = queries(json.queries);
|
|
286
|
+
if (typeof json.message === 'string') response.message = json.message;
|
|
287
|
+
return response;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const PAGE_KINDS: readonly PageKind[] = ['notFound', 'error', 'forbidden', 'unauthorized'];
|
|
291
|
+
|
|
292
|
+
function queries(value: unknown): SeededQuery[] {
|
|
293
|
+
if (!Array.isArray(value)) fail('queries must be an array');
|
|
294
|
+
return value.map((item, i) => {
|
|
295
|
+
const query = object(item, `queries[${i}]`);
|
|
296
|
+
if (!Array.isArray(query.key) || query.key.length === 0) fail(`queries[${i}].key must be a non-empty array`);
|
|
297
|
+
return { key: query.key, data: query.data ?? null, updatedAt: number(query.updatedAt, `queries[${i}].updatedAt`) };
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function pagePayload(json: Json): PagePayload {
|
|
302
|
+
const payload: PagePayload = {
|
|
303
|
+
kind: 'page',
|
|
304
|
+
route: nullableString(json.route ?? null, 'route'),
|
|
305
|
+
page: nullableString(json.page ?? null, 'page'),
|
|
306
|
+
segments: segments(json.segments, 'segments'),
|
|
307
|
+
props: object(json.props, 'props'),
|
|
308
|
+
params: params(json.params),
|
|
309
|
+
shared: json.shared ?? null,
|
|
310
|
+
status: number(json.status, 'status'),
|
|
311
|
+
url: string(json.url, 'url'),
|
|
312
|
+
version: string(json.version, 'version'),
|
|
313
|
+
};
|
|
314
|
+
if (json.antiforgery !== undefined && json.antiforgery !== null) payload.antiforgery = antiforgery(json.antiforgery);
|
|
315
|
+
if (json.queries !== undefined && json.queries !== null) payload.queries = queries(json.queries);
|
|
316
|
+
if (json.actionState !== undefined && json.actionState !== null) payload.actionState = actionState(json.actionState);
|
|
317
|
+
if (Array.isArray(json.intercepts)) {
|
|
318
|
+
payload.intercepts = json.intercepts.map((item, i) => {
|
|
319
|
+
const intercept = object(item, `intercepts[${i}]`);
|
|
320
|
+
return {
|
|
321
|
+
segment: string(intercept.segment, `intercepts[${i}].segment`),
|
|
322
|
+
slot: string(intercept.slot, `intercepts[${i}].slot`),
|
|
323
|
+
page: string(intercept.page, `intercepts[${i}].page`),
|
|
324
|
+
...(intercept.params !== undefined && intercept.params !== null ? { params: params(intercept.params) } : {}),
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
if (json.metadata !== undefined && json.metadata !== null) payload.metadata = object(json.metadata, 'metadata') as Metadata;
|
|
329
|
+
if (json.pageKind !== undefined && json.pageKind !== null) {
|
|
330
|
+
if (!PAGE_KINDS.includes(json.pageKind as PageKind)) fail(`unknown pageKind ${JSON.stringify(json.pageKind)}`);
|
|
331
|
+
payload.pageKind = json.pageKind as PageKind;
|
|
332
|
+
}
|
|
333
|
+
if (json.renderedAt !== undefined && json.renderedAt !== null) payload.renderedAt = number(json.renderedAt, 'renderedAt');
|
|
334
|
+
if (json.basePath !== undefined && json.basePath !== null) payload.basePath = string(json.basePath, 'basePath');
|
|
335
|
+
if (json.locale !== undefined && json.locale !== null) payload.locale = string(json.locale, 'locale');
|
|
336
|
+
if (json.draftMode !== undefined && json.draftMode !== null) {
|
|
337
|
+
if (typeof json.draftMode !== 'boolean') fail('draftMode must be a boolean');
|
|
338
|
+
payload.draftMode = json.draftMode;
|
|
339
|
+
}
|
|
340
|
+
return payload;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function parseNavigationPayload(value: unknown): NavigationPayload {
|
|
344
|
+
const json = object(value, 'payload');
|
|
345
|
+
switch (json.kind) {
|
|
346
|
+
case 'page':
|
|
347
|
+
return pagePayload(json);
|
|
348
|
+
case 'redirect':
|
|
349
|
+
return { kind: 'redirect', location: string(json.location, 'location'), version: string(json.version, 'version') };
|
|
350
|
+
default:
|
|
351
|
+
return fail(`unknown kind ${JSON.stringify(json.kind)}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function parseInitialPayload(value: unknown): InitialPayload {
|
|
356
|
+
const json = object(value, 'payload');
|
|
357
|
+
if (json.kind !== 'page') fail('the initial payload must be a page');
|
|
358
|
+
const document = object(json.document, 'document');
|
|
359
|
+
const assets = object(document.assets, 'document.assets');
|
|
360
|
+
const errorPage = document.errorPage === null || document.errorPage === undefined
|
|
361
|
+
? null
|
|
362
|
+
: object(document.errorPage, 'document.errorPage');
|
|
363
|
+
const info: DocumentInfo = {
|
|
364
|
+
lang: string(document.lang, 'document.lang'),
|
|
365
|
+
assets: {
|
|
366
|
+
stylesheets: strings(assets.stylesheets, 'document.assets.stylesheets'),
|
|
367
|
+
modulePreloads: strings(assets.modulePreloads, 'document.assets.modulePreloads'),
|
|
368
|
+
scripts: strings(assets.scripts, 'document.assets.scripts'),
|
|
369
|
+
},
|
|
370
|
+
errorPage: errorPage && {
|
|
371
|
+
page: string(errorPage.page, 'document.errorPage.page'),
|
|
372
|
+
segments: segments(errorPage.segments, 'document.errorPage.segments'),
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
if (Array.isArray(document.devStyles)) {
|
|
376
|
+
info.devStyles = document.devStyles.map((style, i) => {
|
|
377
|
+
const item = object(style, `document.devStyles[${i}]`);
|
|
378
|
+
return { id: string(item.id, 'devStyles.id'), css: string(item.css, 'devStyles.css') };
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
if (document.images !== undefined && document.images !== null) {
|
|
382
|
+
const images = object(document.images, 'document.images');
|
|
383
|
+
if (!Array.isArray(images.widths)) fail('document.images.widths must be an array');
|
|
384
|
+
info.images = {
|
|
385
|
+
path: string(images.path, 'document.images.path'),
|
|
386
|
+
widths: images.widths.map((w, i) => number(w, `document.images.widths[${i}]`)),
|
|
387
|
+
...(typeof images.basePath === 'string' && images.basePath ? { basePath: images.basePath } : {}),
|
|
388
|
+
...(Array.isArray(images.qualities)
|
|
389
|
+
? { qualities: images.qualities.map((q, i) => number(q, `document.images.qualities[${i}]`)) }
|
|
390
|
+
: {}),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (document.nonce !== undefined && document.nonce !== null) info.nonce = string(document.nonce, 'document.nonce');
|
|
394
|
+
if (document.vitalsEndpoint !== undefined && document.vitalsEndpoint !== null) {
|
|
395
|
+
info.vitalsEndpoint = string(document.vitalsEndpoint, 'document.vitalsEndpoint');
|
|
396
|
+
}
|
|
397
|
+
if (document.publicConfig !== undefined && document.publicConfig !== null) {
|
|
398
|
+
info.publicConfig = object(document.publicConfig, 'document.publicConfig');
|
|
399
|
+
}
|
|
400
|
+
if (document.clientRender !== undefined && document.clientRender !== null) {
|
|
401
|
+
if (typeof document.clientRender !== 'boolean') fail('document.clientRender must be a boolean');
|
|
402
|
+
if (document.clientRender) info.clientRender = true;
|
|
403
|
+
}
|
|
404
|
+
return { ...pagePayload(json), document: info };
|
|
405
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo } from 'react';
|
|
2
|
+
import { type StandardSchemaV1, parseWith } from './schema.ts';
|
|
3
|
+
|
|
4
|
+
const PublicConfigContext = createContext<Record<string, unknown>>({});
|
|
5
|
+
|
|
6
|
+
/** Provided by Netjes from the first document; tests can provide their own (or use `createTestApp({ publicConfig })`). */
|
|
7
|
+
export const PublicConfigProvider = PublicConfigContext.Provider;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The server's `Netjes:PublicConfig` configuration section, the runtime counterpart of Next.js's `NEXT_PUBLIC_`
|
|
11
|
+
* variables: change it per environment without rebuilding the client. Values arrive as strings (configuration has
|
|
12
|
+
* no other type), so use coercing schemas such as `z.coerce.number()` or `z.stringbool()`. Everything in it is public.
|
|
13
|
+
*/
|
|
14
|
+
export function usePublicConfig(): unknown;
|
|
15
|
+
export function usePublicConfig<Schema extends StandardSchemaV1>(schema: Schema): StandardSchemaV1.InferOutput<Schema>;
|
|
16
|
+
export function usePublicConfig(schema?: StandardSchemaV1): unknown {
|
|
17
|
+
const config = useContext(PublicConfigContext);
|
|
18
|
+
return useMemo(() => (schema ? parseWith(schema, config, 'public config') : config), [config, schema]);
|
|
19
|
+
}
|
package/src/query.tsx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type DefaultOptions, type DehydratedState, HydrationBoundary, QueryClient, QueryClientProvider, dehydrate } from '@tanstack/react-query';
|
|
2
|
+
import { type ReactNode, useContext, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import type { SeededQuery } from './payload.ts';
|
|
4
|
+
import { RenderCleanupContext } from './render-cleanup.ts';
|
|
5
|
+
import { useInternalRouter, usePagePayload } from './router.tsx';
|
|
6
|
+
|
|
7
|
+
export type QueryShellOptions = {
|
|
8
|
+
/** Merged over the Netjes defaults (`staleTime` of one minute, so seeded data is not refetched straight away). */
|
|
9
|
+
defaultOptions?: DefaultOptions;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const DEFAULTS: DefaultOptions = { queries: { staleTime: 60_000 } };
|
|
13
|
+
|
|
14
|
+
/** The Netjes defaults with the app's options merged over them, as the built-in client and the test harness use. */
|
|
15
|
+
export function mergeOptions(options: DefaultOptions | undefined): DefaultOptions {
|
|
16
|
+
return {
|
|
17
|
+
...DEFAULTS,
|
|
18
|
+
...options,
|
|
19
|
+
queries: { ...DEFAULTS.queries, ...options?.queries },
|
|
20
|
+
mutations: { ...DEFAULTS.mutations, ...options?.mutations },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Turns the payload's seeded queries into TanStack's dehydrated state, using only public APIs. Server timestamps
|
|
26
|
+
* are moved onto the browser clock, so freshness and "newer data wins" compare like with like.
|
|
27
|
+
*/
|
|
28
|
+
export function dehydratedStateOf(queries: readonly SeededQuery[] | undefined, clockOffset = 0): DehydratedState | undefined {
|
|
29
|
+
if (!queries?.length) return undefined;
|
|
30
|
+
const scratch = new QueryClient();
|
|
31
|
+
for (const query of queries) scratch.setQueryData(query.key, query.data, { updatedAt: query.updatedAt + clockOffset });
|
|
32
|
+
const state = dehydrate(scratch);
|
|
33
|
+
scratch.clear();
|
|
34
|
+
return state;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The server keeps one QueryClient per render in a long-lived process: a finite gcTime would leave a timer (and the
|
|
39
|
+
* client's data) behind for every query of every request.
|
|
40
|
+
*/
|
|
41
|
+
function onServer(options: DefaultOptions): DefaultOptions {
|
|
42
|
+
return { ...options, queries: { ...options.queries, gcTime: Number.POSITIVE_INFINITY } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The app-wide QueryClient: one per request on the server, one per page load in the browser (it survives
|
|
47
|
+
* client navigations). Each page's seeded queries are hydrated into it; data the client fetched more recently wins.
|
|
48
|
+
*/
|
|
49
|
+
export function createQueryShell(options: QueryShellOptions = {}) {
|
|
50
|
+
const defaultOptions = mergeOptions(options.defaultOptions);
|
|
51
|
+
|
|
52
|
+
return function QueryShell({ children }: { children: ReactNode }) {
|
|
53
|
+
const onRenderEnd = useContext(RenderCleanupContext);
|
|
54
|
+
const [client] = useState(() => {
|
|
55
|
+
const created = new QueryClient({ defaultOptions: typeof window === 'undefined' ? onServer(defaultOptions) : defaultOptions });
|
|
56
|
+
// A query's own gcTime still schedules a timer; the sidecar drops the client with its document.
|
|
57
|
+
onRenderEnd?.(() => created.clear());
|
|
58
|
+
return created;
|
|
59
|
+
});
|
|
60
|
+
const { queries, clockOffset } = usePagePayload();
|
|
61
|
+
const state = useMemo(() => dehydratedStateOf(queries, clockOffset), [queries, clockOffset]);
|
|
62
|
+
const router = useInternalRouter();
|
|
63
|
+
// Server actions can set entries (.WithQuery) and invalidate keys (.Invalidate) in C#.
|
|
64
|
+
useEffect(
|
|
65
|
+
() =>
|
|
66
|
+
router.onActionResponse((response) => {
|
|
67
|
+
for (const query of response.queries ?? []) client.setQueryData(query.key, query.data);
|
|
68
|
+
for (const queryKey of response.invalidate ?? []) void client.invalidateQueries({ queryKey });
|
|
69
|
+
}),
|
|
70
|
+
[router, client],
|
|
71
|
+
);
|
|
72
|
+
// Signing in or out (a call or post that set or deleted a cookie): any query may hold the previous user's data,
|
|
73
|
+
// those seeded by earlier pages too, so all of them go. The page loads again, seeding its own; the action's own
|
|
74
|
+
// query entries are applied after this.
|
|
75
|
+
useEffect(() => router.onCookiesChanged(() => client.clear()), [router, client]);
|
|
76
|
+
return (
|
|
77
|
+
<QueryClientProvider client={client}>
|
|
78
|
+
<HydrationBoundary state={state}>{children}</HydrationBoundary>
|
|
79
|
+
</QueryClientProvider>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* During a server render: registers something to release once the document is done, such as the render's
|
|
5
|
+
* QueryClient (whose queries may have their own gcTime timers). Null in the browser.
|
|
6
|
+
*/
|
|
7
|
+
export const RenderCleanupContext = createContext<((cleanup: () => void) => void) | null>(null);
|
package/src/root.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { QueryShell } from 'virtual:netjes/query';
|
|
3
|
+
import { Document } from './document.tsx';
|
|
4
|
+
import { ImageConfigProvider } from './image.tsx';
|
|
5
|
+
import { NonceProvider } from './nonce.tsx';
|
|
6
|
+
import { PublicConfigProvider } from './public-config.tsx';
|
|
7
|
+
import type { ModuleLoader, RouteModules } from './modules.ts';
|
|
8
|
+
import type { DocumentInfo, InitialPayload } from './payload.ts';
|
|
9
|
+
import { RouterProvider } from './router.tsx';
|
|
10
|
+
import { VitalsEndpointProvider } from './vitals-config.ts';
|
|
11
|
+
|
|
12
|
+
type RootProps = { payload: InitialPayload; modules: RouteModules; loader: ModuleLoader; dataJson: string };
|
|
13
|
+
|
|
14
|
+
/** App-wide configuration the server sends with the first document (it does not change between navigations). */
|
|
15
|
+
function RootProviders({ info, children }: { info: DocumentInfo; children: ReactNode }) {
|
|
16
|
+
let element = children;
|
|
17
|
+
if (info.images) element = <ImageConfigProvider value={info.images}>{element}</ImageConfigProvider>;
|
|
18
|
+
if (info.publicConfig) element = <PublicConfigProvider value={info.publicConfig}>{element}</PublicConfigProvider>;
|
|
19
|
+
if (info.nonce) element = <NonceProvider value={info.nonce}>{element}</NonceProvider>;
|
|
20
|
+
if (info.vitalsEndpoint) element = <VitalsEndpointProvider value={info.vitalsEndpoint}>{element}</VitalsEndpointProvider>;
|
|
21
|
+
return element;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The same tree on the server and in the browser, so hydration matches. */
|
|
25
|
+
export function Root({ payload, modules, loader, dataJson }: RootProps) {
|
|
26
|
+
return (
|
|
27
|
+
<Document info={payload.document} dataJson={dataJson}>
|
|
28
|
+
<RootProviders info={payload.document}>
|
|
29
|
+
<RouterProvider
|
|
30
|
+
initial={payload}
|
|
31
|
+
initialModules={modules}
|
|
32
|
+
loader={loader}
|
|
33
|
+
errorPage={payload.document.errorPage}
|
|
34
|
+
shell={QueryShell}
|
|
35
|
+
/>
|
|
36
|
+
</RootProviders>
|
|
37
|
+
</Document>
|
|
38
|
+
);
|
|
39
|
+
}
|