@webjsdev/cli 0.10.49 → 0.10.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/bin/webjs.js +133 -30
- package/lib/api-gallery.js +6 -7
- package/lib/app-name.js +208 -0
- package/lib/create.js +67 -18
- package/lib/doctor.js +479 -7
- package/package.json +2 -2
- package/templates/.agents/rules/workflow.md +9 -1
- package/templates/.agents/skills/webjs/SKILL.md +26 -11
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +38 -6
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +17 -2
- package/templates/.agents/skills/webjs/references/components.md +16 -2
- package/templates/.agents/skills/webjs/references/data-and-actions.md +42 -8
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +98 -1
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +35 -14
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +35 -16
- package/templates/.agents/skills/webjs/references/runtime.md +5 -1
- package/templates/.agents/skills/webjs/references/service-worker.md +3 -1
- package/templates/.agents/skills/webjs/references/styling.md +30 -1
- package/templates/.agents/skills/webjs/references/testing.md +61 -3
- package/templates/.agents/skills/webjs/references/typescript.md +71 -2
- package/templates/.agents/skills/webjs/references/ui-kit.md +5 -2
- package/templates/.github/pull_request_template.md +1 -0
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/AGENTS.md +31 -5
- package/templates/CONVENTIONS.md +4 -1
- package/templates/gallery/app/examples/layout.ts +2 -1
- package/templates/gallery/app/examples/todo/page.ts +5 -17
- package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
- package/templates/gallery/app/features/auth/signup/page.ts +4 -23
- package/templates/gallery/app/features/caching/page.ts +33 -9
- package/templates/gallery/app/features/file-storage/page.ts +8 -19
- package/templates/gallery/app/features/forms/page.ts +12 -38
- package/templates/gallery/app/features/layout.ts +6 -2
- package/templates/gallery/app/features/route-handler/data/route.ts +2 -1
- package/templates/gallery/app/features/server-actions/page.ts +43 -0
- package/templates/gallery/app/features/view-transitions/page.ts +1 -1
- package/templates/gallery/app/global-error.ts +7 -4
- package/templates/gallery/modules/auth/actions/signup.server.ts +27 -11
- package/templates/gallery/modules/auth/queries/current-user.server.ts +7 -5
- package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
- package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
- package/templates/gallery/modules/gallery/nav.ts +2 -2
- package/templates/gallery/modules/server-actions/actions/bump-clock.server.ts +20 -0
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
- package/templates/gallery/modules/server-actions/components/clock-reader.ts +98 -0
- package/templates/gallery/modules/server-actions/queries/read-clock.server.ts +38 -0
- package/templates/gallery/modules/server-actions/utils/clock.server.ts +27 -0
- package/templates/gallery/modules/todo/actions/submit-todo.server.ts +31 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
- package/templates/gallery/modules/todo/queries/list-todos.server.ts +4 -2
- package/templates/gallery/modules/todo/types.ts +15 -10
- package/templates/gallery/test/auth/auth.test.ts +31 -16
- package/templates/partials/agents-playbook-api.md +5 -0
- package/templates/partials/agents-playbook-fullstack.md +5 -0
- package/templates/public/sw.js +11 -2
- package/templates/scripts/clear-gallery.mjs +11 -6
- package/templates/test/hello/e2e/hello.test.ts +18 -1
package/templates/CONVENTIONS.md
CHANGED
|
@@ -24,8 +24,11 @@ is the short version.
|
|
|
24
24
|
teaches the same and survives the clear), run `npm run gallery:clear` to shed
|
|
25
25
|
the showcase, then grow the app in place. `AGENTS.md` has the full
|
|
26
26
|
template-specific playbook.
|
|
27
|
+
- **Derive types at every boundary.** Rows from `$inferSelect`, action inputs
|
|
28
|
+
from an `interface`, routing files from `PageProps` / `LayoutProps`. Never
|
|
29
|
+
`any`, and never `unknown` where a real type exists.
|
|
27
30
|
- **Progressive enhancement is the default.** Pages render as HTML, `<a>`
|
|
28
|
-
navigates, `<form
|
|
31
|
+
navigates, a `<form action=${importedAction}>` submits, all with JavaScript off; opt into
|
|
29
32
|
interactivity per behaviour inside a component.
|
|
30
33
|
- **Commit per logical unit** as soon as it is complete, and never push to `main`.
|
|
31
34
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { html } from '@webjsdev/core';
|
|
2
|
+
import type { LayoutProps } from '@webjsdev/core';
|
|
2
3
|
import { backLink } from '#lib/utils/ui.ts';
|
|
3
4
|
|
|
4
5
|
// Shared layout for every gallery example app under /examples/*. It adds the same
|
|
5
6
|
// slim "back to the gallery" link the feature demos get, so an example is never a
|
|
6
7
|
// dead end. A non-root layout, so it never writes the document shell.
|
|
7
|
-
export default function ExamplesLayout({ children }:
|
|
8
|
+
export default function ExamplesLayout({ children }: LayoutProps) {
|
|
8
9
|
// An example app has no sidebar, so center it in a focused reading column
|
|
9
10
|
// (the root centers the whole page; this narrows the example within it).
|
|
10
11
|
return html`
|
|
@@ -1,34 +1,22 @@
|
|
|
1
1
|
// A THIN route adapter: app/ is routing only. It fetches the initial data
|
|
2
2
|
// (server-side) via the 'use server' query and renders the interactive
|
|
3
|
-
// component
|
|
4
|
-
//
|
|
3
|
+
// component. All the real logic lives in modules/todo/, including the action
|
|
4
|
+
// the component's forms bind to. This is the idiomatic app-thin +
|
|
5
|
+
// modules-logic split.
|
|
5
6
|
import { html } from '@webjsdev/core';
|
|
6
7
|
import type { Metadata } from '@webjsdev/core'; // Metadata is a @webjsdev/core type
|
|
7
8
|
import { pageHeading } from '#lib/utils/ui.ts';
|
|
8
9
|
import { listTodos } from '#modules/todo/queries/list-todos.server.ts';
|
|
9
|
-
import { createTodo } from '#modules/todo/actions/create-todo.server.ts';
|
|
10
|
-
import { toggleTodo } from '#modules/todo/actions/toggle-todo.server.ts';
|
|
11
|
-
import { deleteTodo } from '#modules/todo/actions/delete-todo.server.ts';
|
|
12
10
|
import '#modules/todo/components/todo-app.ts';
|
|
13
11
|
|
|
14
12
|
export const metadata: Metadata = { title: 'Todo (optimistic UI) | examples' };
|
|
15
13
|
|
|
16
14
|
export default async function TodoExample() {
|
|
17
|
-
//
|
|
15
|
+
// Fetched here on the server and handed down as a property, so <todo-app>
|
|
16
|
+
// paints the real list on the first byte with nothing to fetch on hydration.
|
|
18
17
|
const todos = await listTodos();
|
|
19
18
|
return html`
|
|
20
19
|
${pageHeading('Optimistic todo')}
|
|
21
20
|
<todo-app .todos=${todos}></todo-app>
|
|
22
21
|
`;
|
|
23
22
|
}
|
|
24
|
-
|
|
25
|
-
// No-JS write path: the component's <form>s post here; with JS the component
|
|
26
|
-
// intercepts and mutates optimistically instead. Success is a 303 PRG.
|
|
27
|
-
export async function action({ formData }: { formData: FormData }) {
|
|
28
|
-
const intent = String(formData.get('intent') ?? '');
|
|
29
|
-
const id = String(formData.get('id') ?? '');
|
|
30
|
-
if (intent === 'create') return createTodo({ title: String(formData.get('title') ?? '') });
|
|
31
|
-
if (intent === 'toggle') return toggleTodo({ id });
|
|
32
|
-
if (intent === 'delete') return deleteTodo({ id });
|
|
33
|
-
return { success: false as const, error: 'Unknown action.', status: 400 };
|
|
34
|
-
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { html } from '@webjsdev/core';
|
|
2
|
+
import type { LayoutProps } from '@webjsdev/core';
|
|
2
3
|
import { buttonClass } from '#components/ui/button.ts';
|
|
3
4
|
|
|
4
5
|
// Nested layout for the protected dashboard subtree. Logout is a plain
|
|
@@ -7,7 +8,7 @@ import { buttonClass } from '#components/ui/button.ts';
|
|
|
7
8
|
// default). signOut is server-only (modules/auth/auth.server.ts), so we POST to
|
|
8
9
|
// its route rather than import it into a browser-shipping page. After signout the
|
|
9
10
|
// dashboard middleware bounces any later visit to login.
|
|
10
|
-
export default function DashboardLayout({ children }:
|
|
11
|
+
export default function DashboardLayout({ children }: LayoutProps) {
|
|
11
12
|
return html`
|
|
12
13
|
<nav class="flex items-center gap-4 mb-6 pb-4 border-b border-border">
|
|
13
14
|
<a href="/features/auth/dashboard" class="text-sm font-medium text-foreground hover:underline">Dashboard</a>
|
|
@@ -8,28 +8,6 @@ export const metadata = { title: 'Sign up' };
|
|
|
8
8
|
|
|
9
9
|
const inputCls = inputClass();
|
|
10
10
|
|
|
11
|
-
// Page server action: handles the POST from the form below. With JS disabled this
|
|
12
|
-
// is a plain <form> round-trip; with JS the client router swaps the 422 re-render
|
|
13
|
-
// (errors) or follows the 302 (success) in place. A validation failure returns
|
|
14
|
-
// fieldErrors + values so the page re-renders with messages and the user's typed
|
|
15
|
-
// input preserved.
|
|
16
|
-
export async function action({ formData }: { formData: FormData }) {
|
|
17
|
-
const name = String(formData.get('name') || '').trim();
|
|
18
|
-
const email = String(formData.get('email') || '').trim();
|
|
19
|
-
const password = String(formData.get('password') || '');
|
|
20
|
-
const values = { name, email };
|
|
21
|
-
const fieldErrors: Record<string, string> = {};
|
|
22
|
-
if (!name) fieldErrors.name = 'Name is required';
|
|
23
|
-
if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';
|
|
24
|
-
if (password.length < 8) fieldErrors.password = 'At least 8 characters';
|
|
25
|
-
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 };
|
|
26
|
-
const result = await signup({ name, email, password });
|
|
27
|
-
// On success signup returns signIn's 302 Response (auto-login -> dashboard); a
|
|
28
|
-
// page action may return a Response, so pass it straight through.
|
|
29
|
-
if (result instanceof Response) return result;
|
|
30
|
-
return { success: false, fieldErrors: { email: result.error }, values, status: result.status };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
11
|
export default function SignupPage({ actionData }: { actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> } }) {
|
|
34
12
|
const errors = actionData?.fieldErrors || {};
|
|
35
13
|
const values = actionData?.values || {};
|
|
@@ -37,7 +15,10 @@ export default function SignupPage({ actionData }: { actionData?: { fieldErrors?
|
|
|
37
15
|
<div class="max-w-[420px] mx-auto">
|
|
38
16
|
<h1 class="text-h2 font-bold mb-2">Create an account</h1>
|
|
39
17
|
<p class="text-muted-foreground mb-5">Get started with your new workspace.</p>
|
|
40
|
-
|
|
18
|
+
<!-- The form is bound to the action: no adapter, no fetch handler. With JS
|
|
19
|
+
disabled this is a plain round-trip; with JS the client router swaps
|
|
20
|
+
the 422 re-render (errors) or follows the 302 (success) in place. -->
|
|
21
|
+
<form action=${signup} class="${cardClass()} grid gap-4 p-5">
|
|
41
22
|
<div class="grid gap-1.5">
|
|
42
23
|
<label for="name" class="text-[13px] font-medium text-muted-foreground">Name</label>
|
|
43
24
|
<input id="name" name="name" type="text" value=${values.name || ''} required class=${inputCls} placeholder="Ada Lovelace" />
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Caching: `export const revalidate = N` opts the page into the server HTML
|
|
2
|
-
// response cache, keyed by URL for N seconds. The
|
|
3
|
-
// changes once per window: reload inside 10s and
|
|
4
|
-
// and it refreshes. SAFETY: only cache a page that
|
|
5
|
-
// visitor (no cookies(), no session, no per-user data),
|
|
6
|
-
//
|
|
7
|
-
// use
|
|
8
|
-
import { html } from '@webjsdev/core';
|
|
2
|
+
// response cache, keyed by the request origin plus the URL for N seconds. The
|
|
3
|
+
// rendered timestamp below only changes once per window: reload inside 10s and
|
|
4
|
+
// it is identical, reload after and it refreshes. SAFETY: only cache a page that
|
|
5
|
+
// is identical for every visitor (no cookies(), no session, no per-user data),
|
|
6
|
+
// since the key carries no per-user component. For per-query reads use cache() + tags with revalidateTag; for a
|
|
7
|
+
// public/ asset use asset(), demonstrated at the bottom of this page.
|
|
8
|
+
import { html, asset } from '@webjsdev/core';
|
|
9
9
|
import type { Metadata } from '@webjsdev/core';
|
|
10
10
|
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
11
11
|
import '#modules/caching/components/cache-buster.ts';
|
|
@@ -23,7 +23,7 @@ export default function CachingExample() {
|
|
|
23
23
|
${pageHeading('Caching')}
|
|
24
24
|
${lede(html`
|
|
25
25
|
This page sets <code>export const revalidate = 10</code>, so its
|
|
26
|
-
server-rendered HTML is cached per URL for ten seconds.
|
|
26
|
+
server-rendered HTML is cached per origin and URL for ten seconds.
|
|
27
27
|
`)}
|
|
28
28
|
<p class="mb-4">
|
|
29
29
|
Rendered at
|
|
@@ -34,7 +34,9 @@ export default function CachingExample() {
|
|
|
34
34
|
Only for pages identical for every visitor. For per-user or per-query data
|
|
35
35
|
use <code>cache()</code> with <code>tags</code> and
|
|
36
36
|
<code>revalidateTag</code>, or a GET action's
|
|
37
|
-
<code>export const cache</code
|
|
37
|
+
<code>export const cache</code>, which the
|
|
38
|
+
<a class="text-primary underline underline-offset-2" href="/features/server-actions">server actions card</a>
|
|
39
|
+
demonstrates end to end.
|
|
38
40
|
</p>
|
|
39
41
|
<p class="text-muted-foreground text-sm">
|
|
40
42
|
A mutation evicts the cache on demand. Click below (it calls
|
|
@@ -44,5 +46,27 @@ export default function CachingExample() {
|
|
|
44
46
|
copy until the window elapses.
|
|
45
47
|
</p>
|
|
46
48
|
<cache-buster></cache-buster>
|
|
49
|
+
|
|
50
|
+
<h2 class="mt-8 mb-2 font-semibold">Caching a public/ asset</h2>
|
|
51
|
+
<p class="mb-2">
|
|
52
|
+
A file in <code>public/</code> sits at a stable url, so after a deploy a
|
|
53
|
+
browser or CDN can keep serving the PREVIOUS bytes until its cache
|
|
54
|
+
expires. Wrap the url in <code>asset()</code> and it gains a content hash,
|
|
55
|
+
which the framework then serves <code>immutable</code> for a year:
|
|
56
|
+
</p>
|
|
57
|
+
<pre class="mb-2 overflow-x-auto rounded-md bg-muted p-3 text-sm"><code><link rel="stylesheet" href=\${asset('/public/tailwind.css')}></code></pre>
|
|
58
|
+
<p class="mb-2">
|
|
59
|
+
This app's stylesheet resolves to
|
|
60
|
+
<code class="font-mono text-primary">${asset('/public/tailwind.css')}</code>
|
|
61
|
+
(the hash appears in production only, so dev output stays byte-identical).
|
|
62
|
+
New bytes mean a new url, so a stale copy can never be served.
|
|
63
|
+
</p>
|
|
64
|
+
<p class="text-muted-foreground text-sm">
|
|
65
|
+
Mark the thing that FETCHES, not a hint. Wrapping a
|
|
66
|
+
<code><link rel="preload"></code> whose asset is really fetched by an
|
|
67
|
+
<code>@font-face url()</code> in your CSS would version the hint but not
|
|
68
|
+
the request, so the preload could never match and the file would download
|
|
69
|
+
twice.
|
|
70
|
+
</p>
|
|
47
71
|
`;
|
|
48
72
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// File storage: a no-JS upload. A
|
|
2
|
-
// (the progressive-enhancement write path)
|
|
3
|
-
//
|
|
4
|
-
// (PRG) with the new key in the
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// File storage: a no-JS upload. A <form> bound to a 'use server' action streams
|
|
2
|
+
// the bytes into the FileStore (the progressive-enhancement write path). The
|
|
3
|
+
// framework emits the multipart enctype an upload needs, so the binding is the
|
|
4
|
+
// whole wiring. On success the action redirects (PRG) with the new key in the
|
|
5
|
+
// query, and the page renders a download link that streams the file back
|
|
6
|
+
// through file/[key]/route.ts. Works with JS off; the client router applies the
|
|
7
|
+
// same flow in place with JS on.
|
|
7
8
|
import { html } from '@webjsdev/core';
|
|
8
9
|
import { buttonClass } from '#components/ui/button.ts';
|
|
9
10
|
import { cardClass } from '#components/ui/card.ts';
|
|
@@ -13,18 +14,6 @@ import { storeUpload } from '#modules/file-storage/actions/store-upload.server.t
|
|
|
13
14
|
|
|
14
15
|
export const metadata: Metadata = { title: 'File storage (upload + serve) | features' };
|
|
15
16
|
|
|
16
|
-
export async function action({ formData }: { formData: FormData }) {
|
|
17
|
-
const file = formData.get('file');
|
|
18
|
-
if (!(file instanceof File) || file.size === 0) {
|
|
19
|
-
return { success: false, error: 'Choose a file to upload.' };
|
|
20
|
-
}
|
|
21
|
-
const result = await storeUpload(file);
|
|
22
|
-
if (!result.success) return result;
|
|
23
|
-
const { key, name, size } = result.data;
|
|
24
|
-
const q = new URLSearchParams({ key, name, size: String(size) });
|
|
25
|
-
return { success: true, redirect: '/features/file-storage?' + q.toString() };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
17
|
export default function FileStorageExample({
|
|
29
18
|
searchParams,
|
|
30
19
|
actionData,
|
|
@@ -43,7 +32,7 @@ export default function FileStorageExample({
|
|
|
43
32
|
gitignored). Swap the backend for S3/R2 with one
|
|
44
33
|
<code class="font-mono">setFileStore()</code> call, no call-site change.
|
|
45
34
|
`)}
|
|
46
|
-
<form
|
|
35
|
+
<form action=${storeUpload} class="flex flex-wrap gap-3 items-center mb-4">
|
|
47
36
|
<input type="file" name="file" required aria-label="Choose a file to upload"
|
|
48
37
|
class="text-sm text-muted-foreground file:mr-3 file:px-3.5 file:py-2 file:rounded-xl file:border-0 file:bg-card file:border file:border-border file:text-foreground file:text-sm file:cursor-pointer" />
|
|
49
38
|
<button type="submit"
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
// forms: the no-JS write path.
|
|
2
|
-
// `action`
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// forms: the no-JS write path. Bind a server action straight into the form with
|
|
2
|
+
// `action=${sendMessage}` and that is the whole wiring: the framework posts to
|
|
3
|
+
// this page's own url, runs the action, and re-renders the SAME page with the
|
|
4
|
+
// result on `actionData`. WHY it matters: the form works with JS OFF (a plain
|
|
5
|
+
// server round-trip), and with JS the client router applies the response in
|
|
6
|
+
// place (no full reload). Never reach for fetch() + a click handler where a
|
|
7
|
+
// bound <form> does. On failure the framework re-renders at 422 with the
|
|
8
|
+
// result; on success it does a 303 Post-Redirect-Get, so we redirect to ?sent=1
|
|
9
|
+
// to show a confirmation.
|
|
8
10
|
import { html } from '@webjsdev/core';
|
|
11
|
+
import { sendMessage, type Result } from '#modules/forms/actions/send-message.server.ts';
|
|
9
12
|
import { cardClass } from '#components/ui/card.ts';
|
|
10
13
|
import { inputClass } from '#components/ui/input.ts';
|
|
11
14
|
import { buttonClass } from '#components/ui/button.ts';
|
|
@@ -14,13 +17,6 @@ import type { Metadata } from '@webjsdev/core';
|
|
|
14
17
|
|
|
15
18
|
export const metadata: Metadata = { title: 'Forms (no-JS PE) | features' };
|
|
16
19
|
|
|
17
|
-
interface Result {
|
|
18
|
-
success: boolean;
|
|
19
|
-
fieldErrors?: Record<string, string>;
|
|
20
|
-
values?: Record<string, string>;
|
|
21
|
-
redirect?: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
20
|
const field = (label: string, name: string, input: unknown, error?: string) => html`
|
|
25
21
|
<div class="grid gap-1.5">
|
|
26
22
|
<label for=${name} class="text-[13px] font-medium text-muted-foreground">${label}</label>
|
|
@@ -48,8 +44,8 @@ export default function FormsFeature({ searchParams, actionData }: { searchParam
|
|
|
48
44
|
const v = actionData?.values ?? {};
|
|
49
45
|
return html`
|
|
50
46
|
<h1 class="text-h2 font-bold mb-2">Forms</h1>
|
|
51
|
-
<p class="text-muted-foreground mb-5 max-w-[460px]">A real <code><form></code>
|
|
52
|
-
<form
|
|
47
|
+
<p class="text-muted-foreground mb-5 max-w-[460px]">A real <code><form></code> bound to a server action. It works with JS off; validation errors come back on <code>actionData</code>.</p>
|
|
48
|
+
<form action=${sendMessage} class="${cardClass()} max-w-[460px] grid gap-4 p-5">
|
|
53
49
|
${field('Name', 'name', html`<input id="name" name="name" value=${v.name ?? ''} class=${inputCls} placeholder="Ada Lovelace" />`, errs.name)}
|
|
54
50
|
${field('Email', 'email', html`<input id="email" name="email" type="email" value=${v.email ?? ''} class=${inputCls} placeholder="ada@example.com" />`, errs.email)}
|
|
55
51
|
${field('Message', 'message', html`<textarea id="message" name="message" rows="3" class=${inputCls} placeholder="Say hello...">${v.message ?? ''}</textarea>`, errs.message)}
|
|
@@ -57,25 +53,3 @@ export default function FormsFeature({ searchParams, actionData }: { searchParam
|
|
|
57
53
|
</form>
|
|
58
54
|
`;
|
|
59
55
|
}
|
|
60
|
-
|
|
61
|
-
// The page action runs on a non-GET submission to this URL (the no-JS write
|
|
62
|
-
// path). Validate, then return a failure (re-renders at 422 with fieldErrors +
|
|
63
|
-
// values) or a success with a same-site `redirect` (a 303 PRG to the confirmation).
|
|
64
|
-
//
|
|
65
|
-
// FOOTGUN: to redirect on success, RETURN `{ success: true, redirect: '/path' }`
|
|
66
|
-
// (a 303 See Other, so the browser follows with a GET). Do NOT THROW `redirect()`
|
|
67
|
-
// from a page action, that is a 307 which PRESERVES the POST method and body, so
|
|
68
|
-
// the browser re-POSTs to the target and re-runs the mutation (a duplicate write).
|
|
69
|
-
// Throw `redirect()` only from a page render / GET context, never a page action.
|
|
70
|
-
export async function action({ formData }: { formData: FormData }): Promise<Result> {
|
|
71
|
-
const name = String(formData.get('name') ?? '').trim();
|
|
72
|
-
const email = String(formData.get('email') ?? '').trim();
|
|
73
|
-
const message = String(formData.get('message') ?? '').trim();
|
|
74
|
-
const fieldErrors: Record<string, string> = {};
|
|
75
|
-
if (!name) fieldErrors.name = 'Your name is required.';
|
|
76
|
-
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) fieldErrors.email = 'A valid email is required.';
|
|
77
|
-
if (message.length < 5) fieldErrors.message = 'Message must be at least 5 characters.';
|
|
78
|
-
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values: { name, email, message } };
|
|
79
|
-
// A real app would persist / email here. We just confirm.
|
|
80
|
-
return { success: true, redirect: '/features/forms?sent=1' };
|
|
81
|
-
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { html } from '@webjsdev/core';
|
|
2
|
+
import type { LayoutProps } from '@webjsdev/core';
|
|
2
3
|
import { backLink } from '#lib/utils/ui.ts';
|
|
3
4
|
import '#modules/gallery/components/gallery-nav.ts';
|
|
4
5
|
|
|
@@ -45,8 +46,11 @@ const STACK_CSS = `
|
|
|
45
46
|
// sidebar is hidden, so a slim back link keeps a demo from being a dead end.
|
|
46
47
|
// Nested layouts (like the auth dashboard's sub-nav) render inside ${children}.
|
|
47
48
|
// A non-root layout, so it never writes the document shell (the framework does).
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
// LayoutProps types every argument a layout receives, so `url` is known to be
|
|
50
|
+
// an absolute URL string (the framework passes url.toString()) and needs no
|
|
51
|
+
// defensive widening or runtime typeof check.
|
|
52
|
+
export default function FeaturesLayout({ children, url }: LayoutProps) {
|
|
53
|
+
const path = new URL(url).pathname;
|
|
50
54
|
return html`
|
|
51
55
|
<style>${SIDENAV_CSS}${STACK_CSS}</style>
|
|
52
56
|
<div class="lg:hidden mb-6">${backLink('/', html`← Gallery`)}</div>
|
|
@@ -24,7 +24,8 @@ export async function GET(req: Request) {
|
|
|
24
24
|
// not a cookie, so do not read it here.
|
|
25
25
|
cookieCount: cookies().entries().length,
|
|
26
26
|
// cspNonce() reads the request's CSP nonce ('' with CSP off). Server-side
|
|
27
|
-
// you use it to nonce a server-rendered inline <script
|
|
27
|
+
// you use it to nonce a server-rendered inline <script> under CSP (a <style>
|
|
28
|
+
// only needs it if you tighten style-src, which by default allows inline).
|
|
28
29
|
hasNonce: cspNonce().length > 0,
|
|
29
30
|
});
|
|
30
31
|
}
|
|
@@ -2,6 +2,7 @@ import { html } from '@webjsdev/core';
|
|
|
2
2
|
import type { Metadata } from '@webjsdev/core';
|
|
3
3
|
import { pageHeading, lede } from '#lib/utils/ui.ts';
|
|
4
4
|
import '#modules/server-actions/components/greeter.ts';
|
|
5
|
+
import '#modules/server-actions/components/clock-reader.ts';
|
|
5
6
|
|
|
6
7
|
export const metadata: Metadata = { title: 'Server actions (.server vs use server) | features' };
|
|
7
8
|
|
|
@@ -20,5 +21,47 @@ export default function ServerActionsExample() {
|
|
|
20
21
|
</p>
|
|
21
22
|
<p class="text-muted-foreground mb-4 text-sm">Signed out, the greeter returns a real 401. <a class="text-primary underline underline-offset-2" href="/features/auth/login">Sign in</a> first to see it succeed. (This card depends on the auth card; prune both together.)</p>
|
|
22
23
|
<server-greeter></server-greeter>
|
|
24
|
+
|
|
25
|
+
<h2 class="text-xl font-semibold mt-10 mb-3">HTTP verbs and caching</h2>
|
|
26
|
+
<p class="text-muted-foreground mb-4">
|
|
27
|
+
An action declares its HTTP semantics through reserved sibling exports the
|
|
28
|
+
framework reads statically, the same way a page declares
|
|
29
|
+
<code class="font-mono">export const revalidate</code>. The read below sets
|
|
30
|
+
<code class="font-mono">method = 'GET'</code>, so its args ride the URL, it is
|
|
31
|
+
CSRF-exempt, and it carries a weak ETag, so a revalidated read whose result has
|
|
32
|
+
not changed answers 304. Caching itself is opted into by the
|
|
33
|
+
<code class="font-mono">cache</code> export below, not by the verb: a GET without
|
|
34
|
+
one is <code class="font-mono">no-store</code>. An action with no
|
|
35
|
+
<code class="font-mono">method</code> export is a POST mutation. Seeding is a
|
|
36
|
+
separate mechanism and needs no verb: an action invoked during a fully
|
|
37
|
+
buffered SSR render has its result serialized into the page, so the first
|
|
38
|
+
client call reads that seed instead of making a hydration round-trip. A page
|
|
39
|
+
that streams (a <code class="font-mono">Suspense</code> or
|
|
40
|
+
<code class="font-mono"><webjs-suspense></code> boundary) emits no seed
|
|
41
|
+
block, so its actions do call out on hydration.
|
|
42
|
+
</p>
|
|
43
|
+
<p class="text-muted-foreground mb-4">
|
|
44
|
+
<code class="font-mono">cache = 10</code> is the max-age in seconds, and it is
|
|
45
|
+
<strong class="text-foreground">private</strong> by default. Reach for
|
|
46
|
+
<code class="font-mono">{ public: true }</code> only when the data is identical
|
|
47
|
+
for every visitor, because a shared cache keys the entry on the URL and args
|
|
48
|
+
alone. That is the same safety rule as a page's
|
|
49
|
+
<code class="font-mono">export const revalidate</code>. The number is shorthand
|
|
50
|
+
for the object form, so
|
|
51
|
+
<code class="font-mono">cache = { maxAge: 10, swr: 30 }</code> keeps serving an
|
|
52
|
+
expired entry for another thirty seconds while the browser revalidates it in
|
|
53
|
+
the background. There is no separate <code class="font-mono">swr</code> export.
|
|
54
|
+
</p>
|
|
55
|
+
<p class="text-muted-foreground mb-4">
|
|
56
|
+
<code class="font-mono">tags</code> labels the cached entry and
|
|
57
|
+
<code class="font-mono">invalidates</code> on the mutation evicts it by name.
|
|
58
|
+
The read reports how many times it actually ran on the server, so press Read twice
|
|
59
|
+
inside ten seconds and that count does not move: the second answer came from the
|
|
60
|
+
browser cache without reaching the server. Then bump the counter and read again.
|
|
61
|
+
The count moves and the value is fresh, because the mutation reported its
|
|
62
|
+
invalidated tag and the next read bypassed the stale entry instead of waiting out
|
|
63
|
+
the window.
|
|
64
|
+
</p>
|
|
65
|
+
<clock-reader></clock-reader>
|
|
23
66
|
`;
|
|
24
67
|
}
|
|
@@ -22,7 +22,7 @@ export default function ViewTransitionsExample() {
|
|
|
22
22
|
${pageHeading('View transitions')}
|
|
23
23
|
<div class="rounded-2xl bg-primary/10 border border-primary/30 p-6 mb-6">
|
|
24
24
|
<p class="text-foreground m-0">Page one. Navigate to page two: with the
|
|
25
|
-
<code class="font-mono"><meta name="view-transition"></code> opt-in,
|
|
25
|
+
<code class="font-mono"><meta name="view-transition" content="same-origin"></code> opt-in,
|
|
26
26
|
the swap cross-fades.</p>
|
|
27
27
|
</div>
|
|
28
28
|
<label class="block mb-6">
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// returned verbatim with NO framework <head> splice, so it ships no importmap
|
|
6
6
|
// and no boot script. Keep it static HTML with no components or hydration: a
|
|
7
7
|
// last-resort page must not depend on the module system that may have just
|
|
8
|
-
// failed. (Under an opt-in CSP,
|
|
9
|
-
//
|
|
8
|
+
// failed. (Under an opt-in CSP, an inline <script> here needs a nonce via
|
|
9
|
+
// cspNonce() from @webjsdev/core. An inline <style> only needs one if you
|
|
10
|
+
// tighten style-src, since the default policy allows inline style outright.)
|
|
10
11
|
//
|
|
11
12
|
// Distinct from error.ts (a nested, per-segment boundary that renders a body
|
|
12
13
|
// fragment the framework wraps) and from global-not-found.ts (the unmatched-URL
|
|
@@ -17,8 +18,10 @@ export default function GlobalError({ error }: { error: Error }) {
|
|
|
17
18
|
const message = process.env.NODE_ENV === 'production'
|
|
18
19
|
? 'Something went wrong. Please try again.'
|
|
19
20
|
: error?.message || 'Unknown error';
|
|
20
|
-
// cspNonce() is '' with CSP off (the default), so this is safe as-is
|
|
21
|
-
// opt-in
|
|
21
|
+
// cspNonce() is '' with CSP off (the default), so this is safe as-is. Under the
|
|
22
|
+
// default opt-in policy the inline <style> is allowed by 'unsafe-inline' rather
|
|
23
|
+
// than by this nonce, which is stamped so the page still works if you tighten
|
|
24
|
+
// style-src to a nonce source.
|
|
22
25
|
return html`<!doctype html>
|
|
23
26
|
<html lang="en">
|
|
24
27
|
<head>
|
|
@@ -5,15 +5,31 @@ import { users } from '#db/schema.server.ts';
|
|
|
5
5
|
import { hash } from '../password.server.ts';
|
|
6
6
|
import { signIn } from '../auth.server.ts';
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
8
|
+
// The action the signup <form> is bound to. It takes the FormData directly:
|
|
9
|
+
// that is what a form-bound action always receives, on the JS path and the
|
|
10
|
+
// no-JS path alike, so validation lives HERE rather than in a per-page adapter.
|
|
11
|
+
//
|
|
12
|
+
// A validation failure returns fieldErrors + values, which re-renders the SAME
|
|
13
|
+
// page at 422 with the messages and the user's typed input preserved. On
|
|
14
|
+
// success it signs the new user in: signIn returns a 302 Response carrying the
|
|
15
|
+
// session cookie, and an action may return a Response, which the framework
|
|
16
|
+
// honors verbatim. signIn lives in the server-only auth module, imported here
|
|
17
|
+
// server-to-server, so it never reaches the browser.
|
|
18
|
+
export async function signup(formData: FormData) {
|
|
19
|
+
const name = String(formData.get('name') || '').trim();
|
|
20
|
+
const email = String(formData.get('email') || '').trim();
|
|
21
|
+
const password = String(formData.get('password') || '');
|
|
22
|
+
const values = { name, email };
|
|
23
|
+
const fieldErrors: Record<string, string> = {};
|
|
24
|
+
if (!name) fieldErrors.name = 'Name is required';
|
|
25
|
+
if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';
|
|
26
|
+
if (password.length < 8) fieldErrors.password = 'At least 8 characters';
|
|
27
|
+
if (Object.keys(fieldErrors).length) return { success: false as const, fieldErrors, values, status: 422 };
|
|
28
|
+
|
|
29
|
+
const exists = await db.query.users.findFirst({ where: { email }, columns: { id: true } });
|
|
30
|
+
if (exists) {
|
|
31
|
+
return { success: false as const, fieldErrors: { email: 'Email already registered' }, values, status: 409 };
|
|
32
|
+
}
|
|
33
|
+
await db.insert(users).values({ name, email, passwordHash: await hash(password) });
|
|
34
|
+
return signIn('credentials', { email, password }, { redirectTo: '/features/auth/dashboard' });
|
|
19
35
|
}
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { getCurrentUser } from '../auth.server.ts';
|
|
4
4
|
|
|
5
|
-
// This read deliberately stays POST-default (no 'method' export). A
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// This read deliberately stays POST-default (no 'method' export). A per-session
|
|
6
|
+
// result differs per user and changes on sign-in / sign-out, so it must never
|
|
7
|
+
// end up in a cache, and GET is the verb a `cache` window would later be added
|
|
8
|
+
// to. Keeping it POST keeps that door shut. Reach for GET + cache + tags on a
|
|
9
|
+
// read stable enough to serve twice, and for `{ public: true }` only when the
|
|
10
|
+
// data is identical for every visitor. (Staying POST costs nothing on the first
|
|
11
|
+
// paint, since SSR seeding applies to an action of any verb.)
|
|
10
12
|
export async function currentUser() {
|
|
11
13
|
return getCurrentUser();
|
|
12
14
|
}
|
|
@@ -1,21 +1,32 @@
|
|
|
1
|
-
// 'use server' so the page's action can call it (server-side, a direct call) and
|
|
2
|
-
// it never crashes the browser module that imports it (the client gets a safe
|
|
3
|
-
// RPC stub, not the node:fs code). getFileStore() is the pluggable storage
|
|
4
|
-
// singleton: a local diskStore rooted at <cwd>/.webjs/uploads by default
|
|
5
|
-
// (gitignored), swappable for S3/R2/GCS with one setFileStore() call at boot.
|
|
6
|
-
// generateKey() mints a collision-free, traversal-safe key preserving a
|
|
7
|
-
// whitelisted extension.
|
|
8
1
|
'use server';
|
|
2
|
+
|
|
3
|
+
// The directive comes FIRST, ahead of this header: the framework reads it from
|
|
4
|
+
// the file's first few lines, so a long comment block above it would push it
|
|
5
|
+
// out of range and the file would not register as an action at all.
|
|
6
|
+
//
|
|
7
|
+
// The action the upload <form> is bound to. 'use server' means it never
|
|
8
|
+
// crashes the browser module that imports it (the client gets a safe RPC stub,
|
|
9
|
+
// not the node:fs code). getFileStore() is the pluggable storage singleton: a
|
|
10
|
+
// local diskStore rooted at <cwd>/.webjs/uploads by default (gitignored),
|
|
11
|
+
// swappable for S3/R2/GCS with one setFileStore() call at boot. generateKey()
|
|
12
|
+
// mints a collision-free, traversal-safe key preserving a whitelisted
|
|
13
|
+
// extension.
|
|
14
|
+
//
|
|
15
|
+
// It takes the FormData, which is what a form-bound action always receives, and
|
|
16
|
+
// pulls the File out of it. The framework emits the multipart enctype the
|
|
17
|
+
// upload needs, so a bound form carries a file with no extra attribute.
|
|
9
18
|
import { getFileStore, generateKey } from '@webjsdev/server';
|
|
10
19
|
// Importing the config module runs setFileStore(diskStore(...)) once at load,
|
|
11
20
|
// so uploads land in the configured store. See ../store.server.ts.
|
|
12
21
|
import '../store.server.ts';
|
|
13
22
|
|
|
14
|
-
export async function storeUpload(
|
|
23
|
+
export async function storeUpload(formData: FormData) {
|
|
24
|
+
const file = formData.get('file');
|
|
15
25
|
if (!(file instanceof File) || file.size === 0) {
|
|
16
|
-
return { success: false as const, error: '
|
|
26
|
+
return { success: false as const, error: 'Choose a file to upload.' };
|
|
17
27
|
}
|
|
18
28
|
const key = generateKey(file.name);
|
|
19
29
|
const { size, contentType } = await getFileStore().put(key, file, { contentType: file.type });
|
|
20
|
-
|
|
30
|
+
const q = new URLSearchParams({ key, name: file.name, size: String(size) });
|
|
31
|
+
return { success: true as const, redirect: '/features/file-storage?' + q.toString(), data: { key, name: file.name, size, contentType } };
|
|
21
32
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
// The action a <form action=${sendMessage}> submits to. It receives the
|
|
4
|
+
// FormData directly: a form-bound action always does, on the JS path and the
|
|
5
|
+
// no-JS path alike.
|
|
6
|
+
//
|
|
7
|
+
// Return a FAILURE to re-render the SAME page at 422 with the result on
|
|
8
|
+
// `actionData` (so the fields repopulate), or a SUCCESS with a same-site
|
|
9
|
+
// `redirect` for a 303 Post-Redirect-Get.
|
|
10
|
+
//
|
|
11
|
+
// FOOTGUN: to redirect on success, RETURN `{ success: true, redirect: '/path' }`
|
|
12
|
+
// (a 303 See Other, so the browser follows with a GET). Do NOT THROW `redirect()`
|
|
13
|
+
// from a form action, that is a 307 which PRESERVES the POST method and body, so
|
|
14
|
+
// the browser re-POSTs to the target and re-runs the mutation (a duplicate
|
|
15
|
+
// write). Throw `redirect()` only from a page render / GET context.
|
|
16
|
+
export interface Result {
|
|
17
|
+
success: boolean;
|
|
18
|
+
fieldErrors?: Record<string, string>;
|
|
19
|
+
values?: Record<string, string>;
|
|
20
|
+
redirect?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function sendMessage(formData: FormData): Promise<Result> {
|
|
24
|
+
const name = String(formData.get('name') ?? '').trim();
|
|
25
|
+
const email = String(formData.get('email') ?? '').trim();
|
|
26
|
+
const message = String(formData.get('message') ?? '').trim();
|
|
27
|
+
const fieldErrors: Record<string, string> = {};
|
|
28
|
+
if (!name) fieldErrors.name = 'Your name is required.';
|
|
29
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) fieldErrors.email = 'A valid email is required.';
|
|
30
|
+
if (message.length < 5) fieldErrors.message = 'Message must be at least 5 characters.';
|
|
31
|
+
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values: { name, email, message } };
|
|
32
|
+
// A real app would persist / email here. We just confirm.
|
|
33
|
+
return { success: true, redirect: '/features/forms?sent=1' };
|
|
34
|
+
}
|
|
@@ -27,9 +27,9 @@ export const FEATURE_GROUPS: NavGroup[] = [
|
|
|
27
27
|
{
|
|
28
28
|
label: 'Data & actions',
|
|
29
29
|
items: [
|
|
30
|
-
{ href: '/features/server-actions', title: 'Server actions', blurb: 'A use-server RPC action next to a server-only .server.ts utility,
|
|
30
|
+
{ href: '/features/server-actions', title: 'Server actions', blurb: 'A use-server RPC action next to a server-only .server.ts utility, plus the HTTP-verb config exports that make a read a cached GET.' },
|
|
31
31
|
{ href: '/features/route-handler', title: 'Route handlers', blurb: 'A server-only route.ts HTTP endpoint returning JSON, the WebJs equivalent of a Next route handler.' },
|
|
32
|
-
{ href: '/features/forms', title: 'Forms', blurb: 'A no-JS progressive-enhancement form
|
|
32
|
+
{ href: '/features/forms', title: 'Forms', blurb: 'A no-JS progressive-enhancement form bound to a server action, with server-side validation errors.' },
|
|
33
33
|
{ href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' },
|
|
34
34
|
],
|
|
35
35
|
},
|