@webjsdev/cli 0.10.50 → 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.
Files changed (50) hide show
  1. package/README.md +3 -1
  2. package/bin/webjs.js +133 -30
  3. package/lib/api-gallery.js +6 -7
  4. package/lib/app-name.js +208 -0
  5. package/lib/create.js +37 -9
  6. package/lib/doctor.js +479 -7
  7. package/package.json +2 -2
  8. package/templates/.agents/rules/workflow.md +9 -1
  9. package/templates/.agents/skills/webjs/SKILL.md +25 -11
  10. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
  11. package/templates/.agents/skills/webjs/references/built-ins.md +25 -6
  12. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +6 -2
  13. package/templates/.agents/skills/webjs/references/components.md +9 -1
  14. package/templates/.agents/skills/webjs/references/data-and-actions.md +40 -7
  15. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +54 -18
  16. package/templates/.agents/skills/webjs/references/optimistic-ui.md +35 -14
  17. package/templates/.agents/skills/webjs/references/routing-and-pages.md +34 -15
  18. package/templates/.agents/skills/webjs/references/runtime.md +5 -1
  19. package/templates/.agents/skills/webjs/references/styling.md +1 -1
  20. package/templates/.agents/skills/webjs/references/testing.md +61 -3
  21. package/templates/.agents/skills/webjs/references/typescript.md +71 -2
  22. package/templates/.agents/skills/webjs/references/ui-kit.md +5 -2
  23. package/templates/.github/pull_request_template.md +1 -0
  24. package/templates/.github/workflows/ci.yml +13 -0
  25. package/templates/AGENTS.md +31 -5
  26. package/templates/CONVENTIONS.md +4 -1
  27. package/templates/gallery/app/examples/layout.ts +2 -1
  28. package/templates/gallery/app/examples/todo/page.ts +3 -16
  29. package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
  30. package/templates/gallery/app/features/auth/signup/page.ts +4 -23
  31. package/templates/gallery/app/features/caching/page.ts +6 -6
  32. package/templates/gallery/app/features/file-storage/page.ts +8 -19
  33. package/templates/gallery/app/features/forms/page.ts +12 -38
  34. package/templates/gallery/app/features/layout.ts +6 -2
  35. package/templates/gallery/app/features/route-handler/data/route.ts +2 -1
  36. package/templates/gallery/app/features/view-transitions/page.ts +1 -1
  37. package/templates/gallery/app/global-error.ts +7 -4
  38. package/templates/gallery/modules/auth/actions/signup.server.ts +27 -11
  39. package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
  40. package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
  41. package/templates/gallery/modules/gallery/nav.ts +1 -1
  42. package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
  43. package/templates/gallery/modules/todo/actions/submit-todo.server.ts +31 -0
  44. package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
  45. package/templates/gallery/modules/todo/types.ts +15 -10
  46. package/templates/gallery/test/auth/auth.test.ts +31 -16
  47. package/templates/partials/agents-playbook-api.md +5 -0
  48. package/templates/partials/agents-playbook-fullstack.md +5 -0
  49. package/templates/scripts/clear-gallery.mjs +5 -4
  50. package/templates/test/hello/e2e/hello.test.ts +18 -1
@@ -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 }: { children: unknown }) {
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
- <form method="POST" class="${cardClass()} grid gap-4 p-5">
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,9 +1,9 @@
1
1
  // Caching: `export const revalidate = N` opts the page into the server HTML
2
- // response cache, keyed by URL for N seconds. The rendered timestamp below only
3
- // changes once per window: reload inside 10s and it is identical, reload after
4
- // and it refreshes. SAFETY: only cache a page that is identical for every
5
- // visitor (no cookies(), no session, no per-user data), since the key is the URL
6
- // alone. For per-query reads use cache() + tags with revalidateTag; for a
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
7
  // public/ asset use asset(), demonstrated at the bottom of this page.
8
8
  import { html, asset } from '@webjsdev/core';
9
9
  import type { Metadata } from '@webjsdev/core';
@@ -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
@@ -1,9 +1,10 @@
1
- // File storage: a no-JS upload. A multipart <form> posts to this page's `action`
2
- // (the progressive-enhancement write path); the action calls a 'use server'
3
- // helper that streams the bytes into the FileStore. On success it redirects
4
- // (PRG) with the new key in the query, and the page renders a download link that
5
- // streams the file back through file/[key]/route.ts. Works with JS off; the
6
- // client router applies the same flow in place with JS on.
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 method="post" enctype="multipart/form-data" class="flex flex-wrap gap-3 items-center mb-4">
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. A real <form method="post"> posts to this page's
2
- // `action` export, and the framework re-renders the SAME page with the result on
3
- // `actionData`. WHY it matters: the form works with JS OFF (server round-trip),
4
- // and with JS the client router applies the response in place (no full reload).
5
- // Never reach for fetch() + a click handler where a <form> + page action does.
6
- // On failure the framework re-renders at 422 with the result; on success it
7
- // does a 303 Post-Redirect-Get, so we redirect to ?sent=1 to show a confirmation.
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>&lt;form&gt;</code> posting to this page's <code>action</code>. It works with JS off; validation errors come back on <code>actionData</code>.</p>
52
- <form method="post" action="" class="${cardClass()} max-w-[460px] grid gap-4 p-5">
47
+ <p class="text-muted-foreground mb-5 max-w-[460px]">A real <code>&lt;form&gt;</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
- export default function FeaturesLayout({ children, url }: { children: unknown; url: URL | string }) {
49
- const path = typeof url === 'string' ? new URL(url, 'http://x').pathname : url.pathname;
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`&larr; 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>/<style> under CSP.
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
  }
@@ -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">&lt;meta name="view-transition"&gt;</code> opt-in,
25
+ <code class="font-mono">&lt;meta name="view-transition" content="same-origin"&gt;</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, any inline <style>/<script> here needs a nonce
9
- // via cspNonce() from @webjsdev/server.)
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; under an
21
- // opt-in CSP it carries the per-request nonce so the inline <style> is allowed.
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
- // Creates the account, then signs the new user in and lands on the dashboard.
9
- // signIn returns a 302 Response carrying the session cookie; the signup page
10
- // action returns that Response as-is (a page action may return a Response).
11
- // signIn lives in the server-only auth module, imported here server-to-server,
12
- // so it never reaches the browser (the signup page only imports this action's
13
- // RPC stub).
14
- export async function signup(input: { name: string; email: string; password: string }) {
15
- const exists = await db.query.users.findFirst({ where: { email: input.email }, columns: { id: true } });
16
- if (exists) return { success: false as const, error: 'Email already registered', status: 409 };
17
- await db.insert(users).values({ name: input.name, email: input.email, passwordHash: await hash(input.password) });
18
- return signIn('credentials', { email: input.email, password: input.password }, { redirectTo: '/features/auth/dashboard' });
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
  }
@@ -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(file: File) {
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: 'No file provided.' };
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
- return { success: true as const, data: { key, name: file.name, size, contentType } };
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
+ }
@@ -29,7 +29,7 @@ export const FEATURE_GROUPS: NavGroup[] = [
29
29
  items: [
30
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 posting to the page action, with server-side validation errors.' },
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
  },
@@ -13,7 +13,7 @@
13
13
  import { test } from 'node:test';
14
14
  import assert from 'node:assert/strict';
15
15
  import { createRequestHandler, buildRouteTable, matchPage, matchApi, invokeActionForTest } from '@webjsdev/server';
16
- import { testRequest, loginAndGetCookies } from '@webjsdev/server/testing';
16
+ import { submitForm, loginAndGetCookies } from '@webjsdev/server/testing';
17
17
 
18
18
  const appDir = process.cwd();
19
19
  process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';
@@ -54,10 +54,11 @@ test('an authenticated greet reads the caller off the session via actionContext(
54
54
  // migrated (run db:generate + db:migrate) rather than fail misleadingly.
55
55
  const email = `greet+${Date.now()}@example.com`;
56
56
  const password = 'password123';
57
- const signupRes = await testRequest(app.handle, '/features/auth/signup', {
58
- method: 'POST',
59
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
60
- body: new URLSearchParams({ name: 'Ada', email, password }).toString(),
57
+ // `submitForm` renders the signup page and reuses the identity the server put
58
+ // in the form's hidden `__webjs_action` field, which is what tells the
59
+ // dispatcher which action to run; a POST without it is answered 405.
60
+ const signupRes = await submitForm(app.handle, '/features/auth/signup', {
61
+ name: 'Ada', email, password,
61
62
  });
62
63
  if (signupRes.status !== 302) { t.skip('app deps/db not ready; run db:generate + db:migrate'); return; }
63
64
  const { cookies } = await loginAndGetCookies(app.handle, { email, password });
@@ -0,0 +1,31 @@
1
+ 'use server';
2
+
3
+ import { createTodo } from './create-todo.server.ts';
4
+ import { toggleTodo } from './toggle-todo.server.ts';
5
+ import { deleteTodo } from './delete-todo.server.ts';
6
+
7
+ // The no-JS write path for the todo forms. Every <form> in <todo-app> binds
8
+ // THIS action, and the submit button's own `name="intent"` says which mutation
9
+ // to run, which is how one form serves several buttons.
10
+ //
11
+ // Why an intent dispatcher here rather than binding each button to its own
12
+ // action: this form carries the todo's `id` on a hidden input and needs the
13
+ // SAME id for whichever mutation runs, so one action reading both fields is the
14
+ // simpler shape. When the buttons need no shared payload, bind each one
15
+ // directly instead, with `formaction=${action}` on a <button> inside the bound
16
+ // form. The identity then rides that button's own name/value pair, so it works
17
+ // with JS off too. Two things to know: it must be a <button> (on an
18
+ // <input type="submit"> the identity would occupy `value`, which is also that
19
+ // control's visible label), and a bound submitter cannot carry its own
20
+ // `name`/`value`, which is exactly the channel `name="intent"` uses below.
21
+ //
22
+ // With JS the component intercepts the submit and calls the underlying action
23
+ // directly for the optimistic path, so this runs only with JS off.
24
+ export async function submitTodo(formData: FormData) {
25
+ const intent = String(formData.get('intent') ?? '');
26
+ const id = String(formData.get('id') ?? '');
27
+ if (intent === 'create') return createTodo({ title: String(formData.get('title') ?? '') });
28
+ if (intent === 'toggle') return toggleTodo({ id });
29
+ if (intent === 'delete') return deleteTodo({ id });
30
+ return { success: false as const, error: 'Unknown action.', status: 400 };
31
+ }
@@ -1,6 +1,6 @@
1
1
  // The interactive surface. Demonstrates: the WebComponent factory + reactive
2
2
  // prop, the DECLARATIVE optimistic() API (instant update, auto-rollback), and
3
- // progressive enhancement (each mutation is a <form> posting to the page action,
3
+ // progressive enhancement (each mutation is a <form action=${submitTodo}> bound to a server action,
4
4
  // intercepted by JS for the optimistic path). All interactivity lives in a
5
5
  // component; a page/layout cannot be interactive in its own markup.
6
6
  //
@@ -17,6 +17,7 @@ import { cn } from '#lib/utils/cn.ts';
17
17
  import { createTodo } from '../actions/create-todo.server.ts';
18
18
  import { toggleTodo } from '../actions/toggle-todo.server.ts';
19
19
  import { deleteTodo } from '../actions/delete-todo.server.ts';
20
+ import { submitTodo } from '../actions/submit-todo.server.ts';
20
21
  import type { Todo } from '../types.ts';
21
22
 
22
23
  type Op =
@@ -95,9 +96,9 @@ export class TodoApp extends WebComponent({
95
96
  </div>
96
97
  </header>
97
98
 
98
- <!-- Add: a real <form> so it works with JS off (posts to the page action);
99
- with JS, @submit intercepts and runs the optimistic path. -->
100
- <form method="post" action="" @submit=${(e: SubmitEvent) => this.add(e)}
99
+ <!-- Add: a real <form> bound to the server action, so it works with JS
100
+ off; with JS, @submit intercepts and runs the optimistic path. -->
101
+ <form action=${submitTodo} @submit=${(e: SubmitEvent) => this.add(e)}
101
102
  class="${cardClass()} flex items-center gap-2 p-2 pl-4 shadow-[0_1px_0_0_color-mix(in_oklch,var(--foreground)_5%,transparent)]">
102
103
  <input type="hidden" name="intent" value="create" />
103
104
  <input name="title" required maxlength="280" autocomplete="off" placeholder="What needs doing?"
@@ -109,7 +110,9 @@ export class TodoApp extends WebComponent({
109
110
  <ul class="list-none m-0 p-0 grid gap-2">
110
111
  ${list.length ? list.map((todo) => html`
111
112
  <li>
112
- <form method="post" action=""
113
+ <!-- One form, two submit buttons: each carries its own
114
+ name="intent", which the bound action dispatches on. -->
115
+ <form action=${submitTodo}
113
116
  class="group flex items-center gap-3 px-3 py-2.5 rounded-xl bg-card border border-border transition-colors hover:border-border-strong ${todo.pending ? 'opacity-55' : ''}">
114
117
  <input type="hidden" name="id" value=${todo.id} />
115
118
  <!-- Toggle is a submit button (degrades to a form POST no-JS); with JS
@@ -1,12 +1,17 @@
1
- // WHY a hand-written interface (not `typeof todos.$inferSelect` from the schema):
2
- // this type is imported by the browser-shipped <todo-app> component. A VALUE
3
- // import from `db/*.server.ts` would pin the component to a server module and
4
- // crash it at load (webjs check flags it). A browser-safe shape here is safe.
5
- // See the server-actions section in this app's AGENTS.md.
6
- export interface Todo {
7
- id: string;
8
- title: string;
9
- completed: boolean;
10
- createdAt: Date;
1
+ // The row shape is DERIVED from the schema, never re-declared: rename a column
2
+ // in db/schema.server.ts and every consumer of this type is a compile error
3
+ // instead of a silent `undefined` at runtime.
4
+ //
5
+ // `import type` is what makes that safe here. This type is imported by the
6
+ // browser-shipped <todo-app> component, and a VALUE import from a
7
+ // `db/*.server.ts` file would pin the component to a server module and crash it
8
+ // at load (webjs check's no-server-import-in-browser-module flags it). A
9
+ // type-only import is erased by the TypeScript stripper before it can reach the
10
+ // browser, so it is exempt from that rule and costs the client nothing.
11
+ import type { todos } from '#db/schema.server.ts';
12
+
13
+ type TodoRow = typeof todos.$inferSelect;
14
+
15
+ export interface Todo extends TodoRow {
11
16
  pending?: boolean; // client-only: true while an optimistic create is in flight
12
17
  }
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { dirname, resolve } from 'node:path';
5
5
 
6
6
  import { createRequestHandler } from '@webjsdev/server';
7
- import { testRequest, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing';
7
+ import { testRequest, submitForm, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing';
8
8
 
9
9
  const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
10
10
 
@@ -14,7 +14,11 @@ const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
14
14
  // we detect that at the RESPONSE level (a 5xx on the dashboard) and SKIP with a
15
15
  // clear message rather than report a misleading failure. After the db is set up
16
16
  // every assertion runs for real.
17
- process.env.DATABASE_URL ||= 'file:./dev.db';
17
+ // The SAME path `.env.example` and `drizzle.config.ts` use, so `db:migrate`
18
+ // prepares the database this test connects to. Pointing somewhere else made the
19
+ // skip below permanent: it told you to run `db:migrate`, and running it
20
+ // migrated a different file.
21
+ process.env.DATABASE_URL ||= 'file:./db/dev.db';
18
22
  process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';
19
23
 
20
24
  function makeHandler() {
@@ -47,24 +51,35 @@ test('signup -> login -> dashboard renders for the authenticated user', async (t
47
51
  const email = `harness+${Date.now()}@example.com`;
48
52
  const password = 'password123';
49
53
 
50
- // Real signup through the page server action (the no-JS form write-path).
51
- let canSignup = true;
54
+ // Real signup through the bound server action (the no-JS form write-path).
55
+ // `submitForm` renders the page and reuses the identity the server put in the
56
+ // form's hidden field, exactly as a browser with JS off submits it; a POST
57
+ // without that field is not a form submission and is answered 405.
58
+ // Only the REQUEST is guarded: an unmigrated table makes the action throw, and
59
+ // that is the one condition worth skipping for. The assertions below stay
60
+ // outside the try on purpose, so a genuine regression fails loudly instead of
61
+ // being caught and reported as a database that was never set up.
62
+ let signupRes: Response | null = null;
52
63
  try {
53
- const signupRes = await testRequest(app.handle, '/features/auth/signup', {
54
- method: 'POST',
55
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
56
- body: new URLSearchParams({ name: 'Harness', email, password }).toString(),
64
+ signupRes = await submitForm(app.handle, '/features/auth/signup', {
65
+ name: 'Harness', email, password,
57
66
  });
58
- // Success auto-logs-in and 302s to the dashboard (carrying the session
59
- // cookie); a 422 means validation failed. Either way the action ran.
60
- assert.ok([302, 422].includes(signupRes.status), 'signup action ran');
61
- if (signupRes.status === 302) assert.equal(signupRes.headers.get('location'), '/features/auth/dashboard', 'signup lands on the dashboard');
62
- if (signupRes.status !== 302) canSignup = false;
63
67
  } catch {
64
- // No migrated DB table -> the action throws. Skip the DB-backed assertions.
65
- canSignup = false;
68
+ signupRes = null;
69
+ }
70
+ if (!signupRes || signupRes.status >= 500) {
71
+ t.skip('no migrated DB; run db:migrate to enable the full flow');
72
+ return;
73
+ }
74
+ // Success auto-logs-in and 302s to the dashboard (carrying the session
75
+ // cookie); a 422 means validation failed. Either way the action ran.
76
+ assert.ok([302, 422].includes(signupRes.status), 'signup action ran');
77
+ if (signupRes.status === 302) {
78
+ assert.equal(signupRes.headers.get('location'), '/features/auth/dashboard', 'signup lands on the dashboard');
79
+ } else {
80
+ t.skip('signup was rejected by validation; run db:migrate to enable the full flow');
81
+ return;
66
82
  }
67
- if (!canSignup) { t.skip('no migrated DB; run db:migrate to enable the full flow'); return; }
68
83
 
69
84
  // Real login captures the genuine signed session cookie.
70
85
  const { cookies } = await loginAndGetCookies(app.handle, { email, password });