netjes 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +54 -2
- package/bin/netjes.mjs +13 -0
- package/cli/agents.mjs +66 -0
- package/cli/commands.mjs +467 -0
- package/cli/help.mjs +89 -0
- package/cli/info.mjs +454 -0
- package/cli/main.mjs +167 -0
- package/cli/packages.mjs +19 -0
- package/cli/process-io.mjs +163 -0
- package/cli/routes.mjs +31 -0
- package/cli/scaffold.mjs +193 -0
- package/cli/suggest.mjs +50 -0
- package/cli/terminal.mjs +40 -0
- package/dist/actions.d.ts +98 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +176 -0
- package/dist/actions.js.map +1 -0
- package/dist/client-entry.d.ts +2 -0
- package/dist/client-entry.d.ts.map +1 -0
- package/dist/client-entry.js +126 -0
- package/dist/client-entry.js.map +1 -0
- package/dist/deferred.d.ts +39 -0
- package/dist/deferred.d.ts.map +1 -0
- package/dist/deferred.js +123 -0
- package/dist/deferred.js.map +1 -0
- package/dist/define-page.d.ts +15 -0
- package/dist/define-page.d.ts.map +1 -0
- package/dist/define-page.js +27 -0
- package/dist/define-page.js.map +1 -0
- package/dist/document.d.ts +19 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +17 -0
- package/dist/document.js.map +1 -0
- package/dist/draft-mode.d.ts +5 -0
- package/dist/draft-mode.d.ts.map +1 -0
- package/dist/draft-mode.js +6 -0
- package/dist/draft-mode.js.map +1 -0
- package/dist/dynamic.d.ts +20 -0
- package/dist/dynamic.d.ts.map +1 -0
- package/dist/dynamic.js +37 -0
- package/dist/dynamic.js.map +1 -0
- package/dist/form-data.d.ts +10 -0
- package/dist/form-data.d.ts.map +1 -0
- package/dist/form-data.js +10 -0
- package/dist/form-data.js.map +1 -0
- package/dist/form.d.ts +35 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +80 -0
- package/dist/form.js.map +1 -0
- package/dist/href.d.ts +25 -0
- package/dist/href.d.ts.map +1 -0
- package/dist/href.js +71 -0
- package/dist/href.js.map +1 -0
- package/dist/image.d.ts +36 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +60 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/link.d.ts +17 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +46 -0
- package/dist/link.js.map +1 -0
- package/dist/locale.d.ts +3 -0
- package/dist/locale.d.ts.map +1 -0
- package/dist/locale.js +6 -0
- package/dist/locale.js.map +1 -0
- package/dist/metadata.d.ts +83 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +100 -0
- package/dist/metadata.js.map +1 -0
- package/dist/modules.d.ts +74 -0
- package/dist/modules.d.ts.map +1 -0
- package/dist/modules.js +85 -0
- package/dist/modules.js.map +1 -0
- package/dist/nonce.d.ts +8 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/nonce.js +12 -0
- package/dist/nonce.js.map +1 -0
- package/dist/payload.d.ts +171 -0
- package/dist/payload.d.ts.map +1 -0
- package/dist/payload.js +273 -0
- package/dist/payload.js.map +1 -0
- package/dist/public-config.d.ts +11 -0
- package/dist/public-config.d.ts.map +1 -0
- package/dist/public-config.js +10 -0
- package/dist/public-config.js.map +1 -0
- package/dist/query.d.ts +22 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +68 -0
- package/dist/query.js.map +1 -0
- package/dist/render-cleanup.d.ts +6 -0
- package/dist/render-cleanup.d.ts.map +1 -0
- package/dist/render-cleanup.js +7 -0
- package/dist/render-cleanup.js.map +1 -0
- package/dist/root.d.ts +12 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +26 -0
- package/dist/root.js.map +1 -0
- package/dist/route-tree.d.ts +56 -0
- package/dist/route-tree.d.ts.map +1 -0
- package/dist/route-tree.js +86 -0
- package/dist/route-tree.js.map +1 -0
- package/dist/router.d.ts +118 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +938 -0
- package/dist/router.js.map +1 -0
- package/dist/schema.d.ts +35 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +74 -0
- package/dist/schema.js.map +1 -0
- package/dist/script.d.ts +23 -0
- package/dist/script.d.ts.map +1 -0
- package/dist/script.js +89 -0
- package/dist/script.js.map +1 -0
- package/dist/server-entry.d.ts +2 -0
- package/dist/server-entry.d.ts.map +1 -0
- package/dist/server-entry.js +4 -0
- package/dist/server-entry.js.map +1 -0
- package/dist/server-render.d.ts +19 -0
- package/dist/server-render.d.ts.map +1 -0
- package/dist/server-render.js +193 -0
- package/dist/server-render.js.map +1 -0
- package/dist/testing-setup.d.ts +2 -0
- package/dist/testing-setup.d.ts.map +1 -0
- package/dist/testing-setup.js +8 -0
- package/dist/testing-setup.js.map +1 -0
- package/dist/testing.d.ts +76 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +106 -0
- package/dist/testing.js.map +1 -0
- package/dist/vitals-config.d.ts +4 -0
- package/dist/vitals-config.d.ts.map +1 -0
- package/dist/vitals-config.js +5 -0
- package/dist/vitals-config.js.map +1 -0
- package/dist/vitals.d.ts +13 -0
- package/dist/vitals.d.ts.map +1 -0
- package/dist/vitals.js +60 -0
- package/dist/vitals.js.map +1 -0
- package/dist/zod.d.ts +19 -0
- package/dist/zod.d.ts.map +1 -0
- package/dist/zod.js +65 -0
- package/dist/zod.js.map +1 -0
- package/package.json +147 -4
- package/sidecar/server.mjs +325 -0
- package/skills/netjes/SKILL.md +93 -0
- package/skills/netjes/references/caching-and-streaming.md +68 -0
- package/skills/netjes/references/data-and-actions.md +98 -0
- package/skills/netjes/references/deployment.md +57 -0
- package/skills/netjes/references/routing.md +65 -0
- package/skills/netjes/references/testing.md +57 -0
- package/src/actions.tsx +241 -0
- package/src/client-entry.tsx +126 -0
- package/src/deferred.ts +134 -0
- package/src/define-page.tsx +41 -0
- package/src/document.tsx +49 -0
- package/src/draft-mode.ts +6 -0
- package/src/dynamic.tsx +64 -0
- package/src/form-data.ts +10 -0
- package/src/form.tsx +122 -0
- package/src/href.ts +79 -0
- package/src/image.tsx +91 -0
- package/src/index.ts +40 -0
- package/src/link.tsx +73 -0
- package/src/locale.ts +6 -0
- package/src/metadata.tsx +181 -0
- package/src/modules.ts +133 -0
- package/src/nonce.tsx +14 -0
- package/src/payload.ts +405 -0
- package/src/public-config.tsx +19 -0
- package/src/query.tsx +82 -0
- package/src/render-cleanup.ts +7 -0
- package/src/root.tsx +39 -0
- package/src/route-tree.tsx +139 -0
- package/src/router.tsx +1188 -0
- package/src/schema.ts +98 -0
- package/src/script.tsx +103 -0
- package/src/server-entry.tsx +4 -0
- package/src/server-render.tsx +223 -0
- package/src/testing-setup.ts +8 -0
- package/src/testing.tsx +177 -0
- package/src/virtual.d.ts +12 -0
- package/src/vitals-config.ts +6 -0
- package/src/vitals.tsx +68 -0
- package/src/zod.ts +72 -0
- package/template/.dockerignore +11 -0
- package/template/Dockerfile +58 -0
- package/template/Features/Home/HomePage.cs +32 -0
- package/template/Program.cs +28 -0
- package/template/Properties/launchSettings.json +13 -0
- package/template/README.md +24 -0
- package/template/SiteProps.cs +4 -0
- package/template/_AGENTS.md +64 -0
- package/template/_CLAUDE.md +1 -0
- package/template/__NAME__.csproj +11 -0
- package/template/_gitattributes +2 -0
- package/template/_gitignore +11 -0
- package/template/app/about/page.tsx +17 -0
- package/template/app/error.tsx +15 -0
- package/template/app/layout.tsx +33 -0
- package/template/app/not-found.tsx +14 -0
- package/template/app/page.tsx +14 -0
- package/template/appsettings.Development.json +3 -0
- package/template/appsettings.json +9 -0
- package/template/components/Greeting.test.tsx +25 -0
- package/template/components/Greeting.tsx +26 -0
- package/template/components/HelloForm.test.tsx +27 -0
- package/template/components/HelloForm.tsx +34 -0
- package/template/docker-compose.override.yml +15 -0
- package/template/docker-compose.prod.yml +26 -0
- package/template/docker-compose.yml +16 -0
- package/template/generated/netjes.ts +73 -0
- package/template/package.json +36 -0
- package/template/public/favicon.svg +6 -0
- package/template/styles/global.css +7 -0
- package/template/tsconfig.json +19 -0
- package/template/vite.config.ts +7 -0
- package/vite/plugin.d.ts +29 -0
- package/vite/plugin.mjs +291 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Form, useAction } from 'netjes';
|
|
2
|
+
import { serverActions } from '../generated/netjes.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A form posting to a C# server action (Features/Home/HomePage.cs). It shows validation errors from C#, and works
|
|
6
|
+
* without JavaScript too: then the page is rendered again with the errors.
|
|
7
|
+
*/
|
|
8
|
+
export function HelloForm() {
|
|
9
|
+
const [state, formAction, pending] = useAction(serverActions['home.greet']);
|
|
10
|
+
const error = state?.ok === false ? state.errors['name']?.[0] : undefined;
|
|
11
|
+
return (
|
|
12
|
+
<Form action={formAction} className="space-y-3">
|
|
13
|
+
<label className="block space-y-1">
|
|
14
|
+
<span className="font-medium">Your name</span>
|
|
15
|
+
<input
|
|
16
|
+
name="name"
|
|
17
|
+
defaultValue={state?.ok === false ? String(state.values?.['name'] ?? '') : ''}
|
|
18
|
+
aria-invalid={error ? true : undefined}
|
|
19
|
+
aria-describedby={error ? 'name-error' : undefined}
|
|
20
|
+
className="block w-full rounded-lg border border-slate-300 px-3 py-2 aria-invalid:border-red-600"
|
|
21
|
+
/>
|
|
22
|
+
</label>
|
|
23
|
+
{error && (
|
|
24
|
+
<p id="name-error" className="text-sm text-red-700">
|
|
25
|
+
{error}
|
|
26
|
+
</p>
|
|
27
|
+
)}
|
|
28
|
+
<button type="submit" disabled={pending} className="cursor-pointer rounded-full bg-slate-900 px-4 py-2 font-medium text-white disabled:opacity-60">
|
|
29
|
+
{pending ? 'Saying hello…' : 'Say hello'}
|
|
30
|
+
</button>
|
|
31
|
+
{state?.ok && <p role="status">{state.result.text}</p>}
|
|
32
|
+
</Form>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Development: dotnet watch for C#, the Vite dev server for pages (HMR on 5173). Open http://localhost:8080.
|
|
2
|
+
# Both ports are bound to localhost only: dev error pages show stack traces and Vite serves the source.
|
|
3
|
+
services:
|
|
4
|
+
app:
|
|
5
|
+
build:
|
|
6
|
+
target: dev
|
|
7
|
+
ports:
|
|
8
|
+
- "127.0.0.1:8080:8080"
|
|
9
|
+
- "127.0.0.1:5173:5173"
|
|
10
|
+
volumes:
|
|
11
|
+
- .:/src
|
|
12
|
+
# Keep the container's own installs and build output out of the bind mount.
|
|
13
|
+
- /src/node_modules
|
|
14
|
+
- /src/bin
|
|
15
|
+
- /src/obj
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
|
2
|
+
services:
|
|
3
|
+
app:
|
|
4
|
+
restart: always
|
|
5
|
+
ports:
|
|
6
|
+
- "8080:8080"
|
|
7
|
+
read_only: true
|
|
8
|
+
tmpfs:
|
|
9
|
+
- /tmp
|
|
10
|
+
security_opt:
|
|
11
|
+
- no-new-privileges:true
|
|
12
|
+
cap_drop:
|
|
13
|
+
- ALL
|
|
14
|
+
environment:
|
|
15
|
+
ASPNETCORE_ENVIRONMENT: Production
|
|
16
|
+
volumes:
|
|
17
|
+
# Data Protection keys sign antiforgery tokens; keep them across restarts and image updates.
|
|
18
|
+
- dataprotection:/home/app/.aspnet/DataProtection-Keys
|
|
19
|
+
deploy:
|
|
20
|
+
resources:
|
|
21
|
+
limits:
|
|
22
|
+
cpus: "1.0"
|
|
23
|
+
memory: 512M
|
|
24
|
+
|
|
25
|
+
volumes:
|
|
26
|
+
dataprotection:
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Base definition: the production image. docker-compose.override.yml (loaded automatically) switches to dev;
|
|
2
|
+
# docker-compose.prod.yml publishes it for real use. Ports live in those files.
|
|
3
|
+
services:
|
|
4
|
+
app:
|
|
5
|
+
# Dev and production builds of the app share this service's default image name; always build, so `up -d` never
|
|
6
|
+
# reuses a dev image (or a stale one after a pull).
|
|
7
|
+
pull_policy: build
|
|
8
|
+
build:
|
|
9
|
+
context: .
|
|
10
|
+
target: production
|
|
11
|
+
healthcheck:
|
|
12
|
+
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/healthz').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
|
|
13
|
+
interval: 10s
|
|
14
|
+
timeout: 3s
|
|
15
|
+
start_period: 60s
|
|
16
|
+
retries: 3
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Generated by Netjes from the app's C# types and page files. Do not edit: the app rewrites it when it starts in
|
|
2
|
+
// Development, and `netjes generate` writes it for CI (`netjes generate --check` fails when it is out of date).
|
|
3
|
+
import { buildHref, isRouteHref, serverAction, type SearchParamsInput } from 'netjes';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
export const HomeProps = z.object({
|
|
7
|
+
message: z.string(),
|
|
8
|
+
renderedAt: z.iso.datetime({ offset: true, local: true }),
|
|
9
|
+
});
|
|
10
|
+
export type HomeProps = z.infer<typeof HomeProps>;
|
|
11
|
+
export const SiteProps = z.object({
|
|
12
|
+
siteName: z.string(),
|
|
13
|
+
});
|
|
14
|
+
export type SiteProps = z.infer<typeof SiteProps>;
|
|
15
|
+
export const Greeting = z.object({
|
|
16
|
+
text: z.string(),
|
|
17
|
+
});
|
|
18
|
+
export type Greeting = z.infer<typeof Greeting>;
|
|
19
|
+
|
|
20
|
+
/** Props of each page by URL pattern, as its C# loader returns them: `definePage(pages['/blog/[slug]'], Page)`. */
|
|
21
|
+
export const pages = {
|
|
22
|
+
"/": HomeProps,
|
|
23
|
+
"/about": z.object({}),
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
/** `NetjesOptions.UseSharedProps`: `useSharedProps(sharedProps)`. */
|
|
27
|
+
export const sharedProps = SiteProps;
|
|
28
|
+
|
|
29
|
+
/** JSON endpoints by method and route: what they accept and return. */
|
|
30
|
+
export const api = {} as const;
|
|
31
|
+
|
|
32
|
+
/** Form fields of page actions (`MapPageAction`), by page pattern. */
|
|
33
|
+
export const forms = {} as const;
|
|
34
|
+
|
|
35
|
+
/** Server actions (`MapAction`), typed from their C# signatures: call them, or pass them to `useAction`. */
|
|
36
|
+
export const serverActions = {
|
|
37
|
+
"home.greet": serverAction<FormData, Greeting>("home.greet", { result: Greeting }),
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
/** Every page's URL pattern and the parameters `route()` needs for it. */
|
|
41
|
+
export type RouteParams = {
|
|
42
|
+
"/": Record<string, never>;
|
|
43
|
+
"/about": Record<string, never>;
|
|
44
|
+
};
|
|
45
|
+
export type RoutePattern = keyof RouteParams;
|
|
46
|
+
/** The URLs the app's pages serve. */
|
|
47
|
+
export type Route = '/' | '/about';
|
|
48
|
+
/** What `<Link href>` and `router.push` accept: the app's routes, with a query or fragment, or another site. */
|
|
49
|
+
export type Href = Route | `${Route}?${string}` | `${Route}#${string}` | `?${string}` | `#${string}` | `http://${string}` | `https://${string}` | `mailto:${string}` | `tel:${string}`;
|
|
50
|
+
|
|
51
|
+
/** The URL of a page, with its parameters encoded: `route('/blog/[slug]', { slug })`. */
|
|
52
|
+
export function route<P extends RoutePattern>(
|
|
53
|
+
pattern: P,
|
|
54
|
+
...args: {} extends RouteParams[P] ? [params?: RouteParams[P], search?: SearchParamsInput] : [params: RouteParams[P], search?: SearchParamsInput]
|
|
55
|
+
): Href {
|
|
56
|
+
return buildHref(pattern, args[0] ?? {}, args[1]) as Href;
|
|
57
|
+
}
|
|
58
|
+
/** Every page's URL pattern. */
|
|
59
|
+
export const routePatterns = ["/", "/about"] as const;
|
|
60
|
+
|
|
61
|
+
/** Whether a URL that comes from data (a CMS, shared props) is one of the app's pages or another site. */
|
|
62
|
+
export function isHref(value: string): value is Href {
|
|
63
|
+
return isRouteHref(value, routePatterns);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Checks such a URL at the boundary: `<Link href={Href.parse(item.href)}>`. */
|
|
67
|
+
export const Href = z.custom<Href>((value) => typeof value === 'string' && isHref(value), 'Not a URL this app serves');
|
|
68
|
+
|
|
69
|
+
declare module 'netjes' {
|
|
70
|
+
interface Register {
|
|
71
|
+
href: Href;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__name__",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "netjes dev",
|
|
10
|
+
"build": "vite build && vite build --ssr",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
13
|
+
"generate": "netjes generate",
|
|
14
|
+
"routes": "netjes routes"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@tanstack/react-query": "^5.105.0",
|
|
18
|
+
"netjes": "__NETJES_DEPENDENCY__",
|
|
19
|
+
"react": "^19.3.1",
|
|
20
|
+
"react-dom": "^19.3.1",
|
|
21
|
+
"zod": "^4.6.7"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
25
|
+
"@testing-library/dom": "^10.4.2",
|
|
26
|
+
"@testing-library/react": "^16.3.3",
|
|
27
|
+
"@testing-library/user-event": "^14.6.8",
|
|
28
|
+
"@types/react": "^19.3.0",
|
|
29
|
+
"@types/react-dom": "^19.3.0",
|
|
30
|
+
"jsdom": "^30.1.3",
|
|
31
|
+
"tailwindcss": "^4.3.3",
|
|
32
|
+
"typescript": "^7.0.2",
|
|
33
|
+
"vite": "^8.3.5",
|
|
34
|
+
"vitest": "^5.0.5"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
|
2
|
+
<!-- The Netjes mark: a lowercase n and a full stop. Edges fall on multiples of 4, whole pixels at 16 and 32 px. -->
|
|
3
|
+
<rect width="64" height="64" rx="14" fill="#162032"/>
|
|
4
|
+
<path d="M16 48V16M16 30a10 10 0 0 1 20 0v18" fill="none" stroke="#fbf7f0" stroke-width="8"/>
|
|
5
|
+
<rect x="44" y="40" width="8" height="8" fill="#e5484d"/>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
7
|
+
"types": ["vite/client"],
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noUncheckedIndexedAccess": true,
|
|
11
|
+
"noImplicitOverride": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
14
|
+
"allowImportingTsExtensions": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"noEmit": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["app", "components", "generated", "vite.config.ts"]
|
|
19
|
+
}
|
package/vite/plugin.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DefaultOptions } from '@tanstack/react-query';
|
|
2
|
+
import type { Options as ReactOptions } from '@vitejs/plugin-react';
|
|
3
|
+
import type { PluginOption } from 'vite';
|
|
4
|
+
|
|
5
|
+
/** Options for the `netjes()` Vite plugin. */
|
|
6
|
+
export type NetjesPluginOptions = {
|
|
7
|
+
/** The directory of page files (Next.js App Router conventions), relative to the Vite root. Must match Netjes:AppDirectory. Default `app`. */
|
|
8
|
+
appDir?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Like Next.js's `basePath`: the path the app is mounted under with `app.UsePathBase(...)`, e.g. `/docs`.
|
|
11
|
+
* Built asset URLs get it; links, forms, redirects and router calls get it from the server at runtime.
|
|
12
|
+
*/
|
|
13
|
+
basePath?: string;
|
|
14
|
+
/** Options for @vitejs/plugin-react, which netjes() includes. */
|
|
15
|
+
react?: ReactOptions;
|
|
16
|
+
/** Tailwind CSS through @tailwindcss/vite. Default true; `@import "tailwindcss"` in a CSS file a layout imports. */
|
|
17
|
+
tailwind?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* The built-in TanStack Query client, seeded from C# with `PageResult.WithQuery`. Default true. `false` removes it
|
|
20
|
+
* (and TanStack Query from the bundle); an object configures it. Options are written into the bundle as literals,
|
|
21
|
+
* so plain values (numbers including Infinity, strings, booleans, objects, arrays) work and functions are rejected.
|
|
22
|
+
*/
|
|
23
|
+
query?: boolean | { defaultOptions?: DefaultOptions };
|
|
24
|
+
/** Vitest defaults (jsdom, Testing Library cleanup, no e2e/ or build output). Default true; the app's own `test` settings win. */
|
|
25
|
+
test?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The Netjes Vite plugin: page routes, the client and server builds, the dev server, TanStack Query, Tailwind and Vitest defaults. */
|
|
29
|
+
export default function netjes(options?: NetjesPluginOptions): PluginOption[];
|
package/vite/plugin.mjs
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { copyFile, mkdir, readdir } from 'node:fs/promises';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import react from '@vitejs/plugin-react';
|
|
7
|
+
import { normalizePath, searchForWorkspaceRoot } from 'vite';
|
|
8
|
+
|
|
9
|
+
const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
10
|
+
// The compiled modules the package exports (npm run build writes them), so the app runs the code it type-checked, and
|
|
11
|
+
// gets one copy of each module: the same files the app's own imports of netjes resolve to. Vite names a file by its path
|
|
12
|
+
// with forward slashes, on Windows too, so these are named that way.
|
|
13
|
+
const CLIENT_ENTRY = normalizePath(fileURLToPath(new URL('../dist/client-entry.js', import.meta.url)));
|
|
14
|
+
const SERVER_ENTRY = normalizePath(fileURLToPath(new URL('../dist/server-entry.js', import.meta.url)));
|
|
15
|
+
const SIDECAR = fileURLToPath(new URL('../sidecar/server.mjs', import.meta.url));
|
|
16
|
+
const QUERY_MODULE = normalizePath(fileURLToPath(new URL('../dist/query.js', import.meta.url)));
|
|
17
|
+
const TEST_SETUP = normalizePath(fileURLToPath(new URL('../dist/testing-setup.js', import.meta.url)));
|
|
18
|
+
|
|
19
|
+
const PAGES_ID = 'virtual:netjes/pages';
|
|
20
|
+
const RESOLVED_PAGES_ID = `\0${PAGES_ID}`;
|
|
21
|
+
const QUERY_ID = 'virtual:netjes/query';
|
|
22
|
+
const RESOLVED_QUERY_ID = `\0${QUERY_ID}`;
|
|
23
|
+
/** The URL the .NET dev asset resolver points browsers at. */
|
|
24
|
+
const DEV_CLIENT_ENTRY_URL = '/@netjes/client-entry';
|
|
25
|
+
|
|
26
|
+
/** What Vite denies by default; setting `server.fs.deny` replaces the list. */
|
|
27
|
+
const VITE_DENY = ['.env', '.env.*', '*.{crt,pem}', '**/.git/**'];
|
|
28
|
+
/** Backend files recognised by their name, wherever they are: configuration, source, build output, dev certificates, SQLite databases, credentials and deployment settings. */
|
|
29
|
+
const BACKEND_FILES = ['**/appsettings*.json', '**/launchSettings.json', '**/*.cs', '**/*.csproj', '**/*.pdb', '**/*.dll', '**/*.{pfx,p12,key}', '**/*.{db,sqlite,sqlite3}',
|
|
30
|
+
// Feed credentials, user secrets and per-user settings, deployment files and the build's shared settings. Vite matches without regard to case.
|
|
31
|
+
'**/nuget.config', '**/secrets.json', '**/*.user', '**/compose*.y*ml', '**/docker-compose*.y*ml', '**/Directory.Build.*', '**/global.json',
|
|
32
|
+
];
|
|
33
|
+
/** Backend folders, matched below the workspace root only: Vite matches absolute paths, so an app that itself lives under ~/bin must still be served. */
|
|
34
|
+
const BACKEND_DIRS = ['bin', 'obj', 'dist/server'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The ASP.NET side of the dev root (the content root): configuration, build output, source and the server bundle.
|
|
38
|
+
* @param {string} workspaceRoot
|
|
39
|
+
*/
|
|
40
|
+
function backendDeny(workspaceRoot) {
|
|
41
|
+
const root = normalizePath(workspaceRoot).replace(/[()[\]{}!+@*?\\]/g, '\\$&');
|
|
42
|
+
return [...BACKEND_FILES, ...BACKEND_DIRS.map((dir) => `${root}/**/${dir}/**`)];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Loads @tailwindcss/vite only when Tailwind is on, so apps that turn it off need not install it. */
|
|
46
|
+
async function tailwind() {
|
|
47
|
+
try {
|
|
48
|
+
const { default: plugin } = await import('@tailwindcss/vite');
|
|
49
|
+
return plugin();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
'netjes: Tailwind CSS is on by default but @tailwindcss/vite could not be loaded. ' +
|
|
53
|
+
'Install it (npm install -D tailwindcss @tailwindcss/vite) or turn it off with netjes({ tailwind: false }).',
|
|
54
|
+
{ cause: error },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether the app (or a parent workspace) can import a package.
|
|
61
|
+
* @param {string} root
|
|
62
|
+
* @param {string} name
|
|
63
|
+
*/
|
|
64
|
+
function canResolve(root, name) {
|
|
65
|
+
try {
|
|
66
|
+
createRequire(path.join(root, 'package.json')).resolve(name);
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Vitest settings for component tests, unless the app set them itself: a DOM (when jsdom is installed), Testing
|
|
75
|
+
* Library cleanup (when it is installed), and no Playwright specs (e2e/), build output or .NET bin/obj folders.
|
|
76
|
+
* @param {Record<string, unknown> | undefined} test
|
|
77
|
+
* @param {string} root
|
|
78
|
+
*/
|
|
79
|
+
function testDefaults(test = {}, root) {
|
|
80
|
+
/** @type {Record<string, unknown>} */
|
|
81
|
+
const defaults = {};
|
|
82
|
+
if (test.environment === undefined && canResolve(root, 'jsdom')) defaults.environment = 'jsdom';
|
|
83
|
+
if (test.exclude === undefined) {
|
|
84
|
+
defaults.exclude = ['**/node_modules/**', '**/dist/**', '**/bin/**', '**/obj/**', '**/e2e/**', '**/.git/**'];
|
|
85
|
+
}
|
|
86
|
+
if (test.setupFiles === undefined && canResolve(root, '@testing-library/react')) defaults.setupFiles = [TEST_SETUP];
|
|
87
|
+
return defaults;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Writes a value as a JavaScript literal for the generated query module. Unlike JSON it keeps Infinity (a common
|
|
92
|
+
* staleTime); anything that cannot reach the browser as a literal, such as a function, is an error.
|
|
93
|
+
* @param {unknown} value
|
|
94
|
+
* @param {string} at
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function literal(value, at) {
|
|
98
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value);
|
|
99
|
+
if (typeof value === 'number') {
|
|
100
|
+
if (Number.isNaN(value)) throw new Error(`netjes: ${at} is NaN`);
|
|
101
|
+
return Number.isFinite(value) ? JSON.stringify(value) : value > 0 ? 'Infinity' : '-Infinity';
|
|
102
|
+
}
|
|
103
|
+
if (Array.isArray(value)) return `[${value.map((item, i) => literal(item, `${at}[${i}]`)).join(',')}]`;
|
|
104
|
+
if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
|
105
|
+
const entries = Object.entries(/** @type {Record<string, unknown>} */ (value)).filter(([, item]) => item !== undefined);
|
|
106
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${literal(item, `${at}.${key}`)}`).join(',')}}`;
|
|
107
|
+
}
|
|
108
|
+
throw new Error(
|
|
109
|
+
`netjes: ${at} is a ${typeof value}. netjes({ query }) options are written into the browser bundle, so only ` +
|
|
110
|
+
'plain values work there; set function options per query (useQuery({ retry })) instead.',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The first route handler file (`route.ts`, `route.js`, ...) in the app directory, in ordinal order, as the app's route
|
|
116
|
+
* table would name it, relative to the root; null when there is none. Like the route table, it leaves out tests
|
|
117
|
+
* (`route.test.ts`), private folders (`_lib/`) and parallel route slots (`@modal/`), which hold no route handlers.
|
|
118
|
+
* @param {string} root
|
|
119
|
+
* @param {string} appDir
|
|
120
|
+
* @returns {Promise<string | null>}
|
|
121
|
+
*/
|
|
122
|
+
async function findRouteHandler(root, appDir) {
|
|
123
|
+
const dir = path.join(root, appDir);
|
|
124
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
125
|
+
let entries;
|
|
126
|
+
try {
|
|
127
|
+
entries = await readdir(dir, { recursive: true, withFileTypes: true });
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const handlers = entries
|
|
132
|
+
.filter((entry) => entry.isFile() && path.parse(entry.name).name === 'route')
|
|
133
|
+
.filter((entry) =>
|
|
134
|
+
normalizePath(path.relative(dir, entry.parentPath))
|
|
135
|
+
.split('/')
|
|
136
|
+
.every((folder) => !folder.startsWith('_') && !(folder.length > 1 && folder.startsWith('@'))),
|
|
137
|
+
)
|
|
138
|
+
.map((entry) => normalizePath(path.relative(root, path.join(entry.parentPath, entry.name))));
|
|
139
|
+
// UTF-16 code unit order, as the route table's ordinal order.
|
|
140
|
+
return handlers.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))[0] ?? null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Vite plugin for Netjes apps: React with Fast Refresh, Tailwind CSS, the built-in TanStack Query client, the page
|
|
145
|
+
* module map, Vitest defaults, and the client + server builds (`vite build` writes dist/client with a manifest,
|
|
146
|
+
* `vite build --ssr` writes dist/server with the sidecar).
|
|
147
|
+
* @param {import('./plugin.d.ts').NetjesPluginOptions} [options]
|
|
148
|
+
* @returns {import('vite').PluginOption[]}
|
|
149
|
+
*/
|
|
150
|
+
export default function netjes(options = {}) {
|
|
151
|
+
const appDir = (options.appDir ?? 'app').replace(/^\/+|\/+$/g, '');
|
|
152
|
+
// Only the convention files are routes; everything else in app/ is colocated code.
|
|
153
|
+
const appFiles = `/${appDir}/**/{page,layout,template,loading,error,not-found,forbidden,unauthorized,global-error,default}.{tsx,jsx}`;
|
|
154
|
+
const basePath = (options.basePath ?? '').replace(/\/+$/, '');
|
|
155
|
+
const query = options.query === false ? null : options.query === true || options.query === undefined ? {} : options.query;
|
|
156
|
+
// Fail when the config loads, not in the browser.
|
|
157
|
+
const queryLiteral = query ? literal(query, 'query') : '';
|
|
158
|
+
let isSsrBuild = false;
|
|
159
|
+
let isBuild = false;
|
|
160
|
+
/** @type {string} */
|
|
161
|
+
let rootDir = '';
|
|
162
|
+
/** @type {string} */
|
|
163
|
+
let outDir = '';
|
|
164
|
+
/** @type {import('vite').ViteDevServer | undefined} */
|
|
165
|
+
let devServer;
|
|
166
|
+
|
|
167
|
+
/** @type {import('vite').Plugin} */
|
|
168
|
+
const plugin = {
|
|
169
|
+
name: 'netjes',
|
|
170
|
+
config(config, env) {
|
|
171
|
+
isSsrBuild = env.isSsrBuild === true;
|
|
172
|
+
isBuild = env.command === 'build';
|
|
173
|
+
// `test` is Vitest's addition to the Vite config.
|
|
174
|
+
const userTest = /** @type {{ test?: Record<string, unknown> }} */ (/** @type {unknown} */ (config)).test;
|
|
175
|
+
const root = path.resolve(config.root ?? process.cwd());
|
|
176
|
+
const workspaceRoot = searchForWorkspaceRoot(root);
|
|
177
|
+
const test = env.command === 'serve' && options.test !== false ? { test: testDefaults(userTest, root) } : {};
|
|
178
|
+
// Built asset URLs live under the base path; in development the browser loads modules from the Vite server's root.
|
|
179
|
+
const base = env.command === 'build' && basePath ? { base: `${basePath}/` } : {};
|
|
180
|
+
return {
|
|
181
|
+
...test,
|
|
182
|
+
...base,
|
|
183
|
+
appType: 'custom',
|
|
184
|
+
// One copy of each, even when netjes is linked (a `file:` dependency, npm link) and has its own node_modules.
|
|
185
|
+
resolve: {
|
|
186
|
+
dedupe: [
|
|
187
|
+
'react',
|
|
188
|
+
'react-dom',
|
|
189
|
+
...(query ? ['@tanstack/react-query'] : []),
|
|
190
|
+
// The test setup's cleanup must unmount what the app's tests rendered.
|
|
191
|
+
...(env.mode === 'test' ? ['@testing-library/react'] : []),
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
// netjes serves its own modules (client entry, runtime) and, for their source maps, its sources; a linked
|
|
195
|
+
// package lives outside the app. The workspace root keeps Vite's default, but only when the app did not narrow
|
|
196
|
+
// fs.allow itself: Vite concatenates arrays. The dev root is the .NET content root, so deny the backend's files.
|
|
197
|
+
// A configured deny list replaces Vite's defaults, which are repeated here.
|
|
198
|
+
server: {
|
|
199
|
+
fs: {
|
|
200
|
+
allow: [...(config.server?.fs?.allow ? [] : [workspaceRoot]), PACKAGE_ROOT],
|
|
201
|
+
deny: [...VITE_DENY, ...backendDeny(workspaceRoot)],
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
optimizeDeps: {
|
|
205
|
+
// There is no index.html to scan; start from the pages so dependencies are pre-bundled up front.
|
|
206
|
+
entries: [appFiles.slice(1)],
|
|
207
|
+
include: [
|
|
208
|
+
'react',
|
|
209
|
+
'react-dom',
|
|
210
|
+
'react-dom/client',
|
|
211
|
+
...(query ? ['@tanstack/react-query'] : []),
|
|
212
|
+
// Imported from inside netjes (netjes/vitals), which the dependency scan skips: found late, it would reload the page.
|
|
213
|
+
...(canResolve(root, 'web-vitals') ? ['web-vitals'] : []),
|
|
214
|
+
],
|
|
215
|
+
// netjes imports the virtual modules below, which pre-bundling cannot resolve, and the plugin loads its query
|
|
216
|
+
// shell by path: a pre-bundled copy of netjes would hold a second router, whose contexts the app's pages miss.
|
|
217
|
+
exclude: ['netjes'],
|
|
218
|
+
},
|
|
219
|
+
// netjes imports the plugin's virtual modules and must share its module instances with the app's pages, so Vite
|
|
220
|
+
// processes it in development as well; production bundles everything for the sidecar.
|
|
221
|
+
ssr: { noExternal: env.command === 'build' ? true : ['netjes'] },
|
|
222
|
+
build: isSsrBuild
|
|
223
|
+
? {
|
|
224
|
+
outDir: 'dist/server',
|
|
225
|
+
emptyOutDir: true,
|
|
226
|
+
copyPublicDir: false,
|
|
227
|
+
ssr: SERVER_ENTRY,
|
|
228
|
+
rollupOptions: { output: { entryFileNames: 'server-entry.js', format: 'es' } },
|
|
229
|
+
}
|
|
230
|
+
: {
|
|
231
|
+
outDir: 'dist/client',
|
|
232
|
+
emptyOutDir: true,
|
|
233
|
+
manifest: true,
|
|
234
|
+
rollupOptions: { input: CLIENT_ENTRY },
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
configResolved(config) {
|
|
239
|
+
rootDir = config.root;
|
|
240
|
+
outDir = path.resolve(config.root, config.build.outDir);
|
|
241
|
+
},
|
|
242
|
+
// The app's route table stops it on a route handler in development, and in every command that starts it. A build
|
|
243
|
+
// takes only the convention files, so it would leave one out without a word: it stops on it too, saying the same.
|
|
244
|
+
async buildStart() {
|
|
245
|
+
if (!isBuild) return;
|
|
246
|
+
const handler = await findRouteHandler(rootDir, appDir);
|
|
247
|
+
if (handler) {
|
|
248
|
+
this.error(`'${handler}': route handlers are C# in Netjes. Map a minimal API endpoint instead (app.MapGet, app.MapPost, ...).`);
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
configureServer(server) {
|
|
252
|
+
devServer = server;
|
|
253
|
+
},
|
|
254
|
+
// For the development sidecar, which finds this plugin in its dev server's config.
|
|
255
|
+
api: {
|
|
256
|
+
/**
|
|
257
|
+
* Has the next render, and the next page the browser loads, build the page module map again from the files on
|
|
258
|
+
* disk, as Vite does once its watcher reports a page file added or removed.
|
|
259
|
+
*/
|
|
260
|
+
rescanPages() {
|
|
261
|
+
const timestamp = Date.now();
|
|
262
|
+
for (const { moduleGraph } of Object.values(devServer?.environments ?? {})) {
|
|
263
|
+
const pages = moduleGraph.getModuleById(RESOLVED_PAGES_ID);
|
|
264
|
+
if (pages) moduleGraph.invalidateModule(pages, new Set(), timestamp, true);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
resolveId(id) {
|
|
269
|
+
if (id === PAGES_ID) return RESOLVED_PAGES_ID;
|
|
270
|
+
if (id === QUERY_ID) return RESOLVED_QUERY_ID;
|
|
271
|
+
if (id === DEV_CLIENT_ENTRY_URL) return CLIENT_ENTRY;
|
|
272
|
+
return undefined;
|
|
273
|
+
},
|
|
274
|
+
load(id) {
|
|
275
|
+
if (id === RESOLVED_QUERY_ID) {
|
|
276
|
+
if (!query) return 'export const QueryShell = undefined;\n';
|
|
277
|
+
return `import { createQueryShell } from ${JSON.stringify(QUERY_MODULE)};\nexport const QueryShell = createQueryShell(${queryLiteral});\n`;
|
|
278
|
+
}
|
|
279
|
+
if (id !== RESOLVED_PAGES_ID) return undefined;
|
|
280
|
+
const glob = JSON.stringify([appFiles]);
|
|
281
|
+
return `export const pages = import.meta.glob(${glob});\n`;
|
|
282
|
+
},
|
|
283
|
+
async writeBundle() {
|
|
284
|
+
if (!isSsrBuild) return;
|
|
285
|
+
await mkdir(outDir, { recursive: true });
|
|
286
|
+
await copyFile(SIDECAR, path.join(outDir, 'netjes-sidecar.mjs'));
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
return [react(options.react), plugin, options.tailwind === false ? null : tailwind()];
|
|
291
|
+
}
|