Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
429e35e
feat(core): bind a server action into <form action=${action}>
vivek7405 Jul 31, 2026
4b94680
feat(server): dispatch a form submission to its bound action
vivek7405 Jul 31, 2026
bf7ff23
test: prove form-action dispatch across runtimes and migrate fixtures
vivek7405 Jul 31, 2026
2fcea21
feat(server): flag a form bound to a GET-declared action in webjs check
vivek7405 Jul 31, 2026
9159e03
test(core): cover the bound form in a real browser
vivek7405 Jul 31, 2026
2e4d73a
test(e2e): assert the served bound-form markup with JS off
vivek7405 Jul 31, 2026
a22a299
feat(cli): teach the scaffold gallery the bound-form shape
vivek7405 Jul 31, 2026
a6d526e
docs: teach the bound form across AGENTS.md and the agent skill
vivek7405 Jul 31, 2026
1c83fad
docs(website): teach the bound form across the docs site
vivek7405 Jul 31, 2026
7e2f2f1
test(bun): migrate the listener-overhead ip fixture to a bound form
vivek7405 Jul 31, 2026
f04b40d
test(server): pin that a bound form ships no JavaScript
vivek7405 Jul 31, 2026
8dacc9e
docs(server): point the module comments at form-dispatch.js
vivek7405 Jul 31, 2026
b306cd1
fix(server): close an open redirect and stop mislabelling a broken ac…
vivek7405 Jul 31, 2026
595826e
fix(core): validate a bound form after every part is committed
vivek7405 Jul 31, 2026
f892f5e
fix(core): a failed render must take its pending form binds with it
vivek7405 Jul 31, 2026
c09413c
fix(core): re-check a bound form on every write path, not just one
vivek7405 Jul 31, 2026
89b6ec0
test(core): cover the three flush sites a root template never reaches
vivek7405 Jul 31, 2026
65d23bb
fix(core): a null action hole releases the binding too
vivek7405 Jul 31, 2026
d7df9dd
fix(core): note a bound form's boolean and property writes too, and u…
vivek7405 Jul 31, 2026
b148be6
fix(core): note the legacy form.encoding alias, and pin the re-bind c…
vivek7405 Jul 31, 2026
83da9fd
refactor(core): decide a bound form from the template, not the DOM
vivek7405 Aug 1, 2026
d12c30d
feat(core): refuse the bound-form shapes the renderers cannot agree on
vivek7405 Aug 1, 2026
0192fcc
fix(server): answer a thrown validate instead of rethrowing it
vivek7405 Aug 1, 2026
6d6bafa
test(core): drive both renderers from one form-action parity table
vivek7405 Aug 1, 2026
b66d7e3
docs: record the two new refusals and the null-vs-false distinction
vivek7405 Aug 1, 2026
d4ab6f7
fix(core): refuse the form shapes the two renderers still disagreed on
vivek7405 Aug 1, 2026
18990a7
fix(server): stop action identity depending on the seed facade's opinion
vivek7405 Aug 1, 2026
c4d58f0
docs: correct the claims round 1 proved wrong, and ship submitForm
vivek7405 Aug 1, 2026
07ee5b6
test(e2e): drive a hydrated component's bound form against the real stub
vivek7405 Aug 1, 2026
74682f5
test(e2e): make the client-half assertion actually discriminating
vivek7405 Aug 1, 2026
0366292
fix(cli): point the scaffold auth test at the database db:migrate pre…
vivek7405 Aug 1, 2026
e56c42e
fix(server): harden the submitForm scan against comments and quoted v…
vivek7405 Aug 1, 2026
573a2f5
fix(server): keep every action identity, preferring the indexed defin…
vivek7405 Aug 1, 2026
045b917
fix(server): say what the barrel fallback costs, and stop the warning…
vivek7405 Aug 1, 2026
091e24b
fix(server): stop the bypass warning asserting what it cannot know
vivek7405 Aug 1, 2026
3a09064
fix(server): stop the warning recommending a harmful remedy
vivek7405 Aug 1, 2026
0deef54
fix(server): the config bypass cuts both ways, not one
vivek7405 Aug 1, 2026
bb60065
fix(server): state the dedicated-module remedy's condition and its re…
vivek7405 Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions .agents/skills/webjs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ WebJs is an AI-first, web-components-first framework with **no build step**: sou
- **`*.server.ts`** is the one server boundary. With `'use server'` its exports are RPC-callable from the client (the import is rewritten to a stub); without it the file is a server-only utility whose browser import throws at load. This, not a component annotation, is how a dependency (the DB driver, secrets, `node:*`) is kept off the client.
- **`route.ts`** is a server-only HTTP handler (named `GET`/`POST` exports), the one routing file that is NOT isomorphic.

**Progressive enhancement is the default architecture.** With JS off, content reads, `<a>` navigates, and `<form>` server actions submit. JS is opt-in per interactive behaviour. Never write a first paint that depends on hydration.
**Progressive enhancement is the default architecture.** With JS off, content reads, `<a>` navigates, and a `<form action=${importedAction}>` submits to its server action. JS is opt-in per interactive behaviour. Never write a first paint that depends on hydration.

## When To Use This Skill

Expand Down Expand Up @@ -54,7 +54,7 @@ Classify the task first, then load the smallest useful reference set. Each refer

Common bundles:

- **Form or CRUD feature** then routing-and-pages, data-and-actions, testing; add auth if user-specific
- **Form or CRUD feature** then data-and-actions, routing-and-pages, testing; add auth if user-specific
- **Interactive widget** then components, styling; add client-router-and-streaming only if it streams
- **Protected area** then auth-and-sessions, routing-and-pages, testing
- **Instant-feeling mutation** then data-and-actions, optimistic-ui
Expand Down Expand Up @@ -105,6 +105,7 @@ App-internal imports use the `#` root alias (`import { db } from '#db/connection
9. No backtick characters inside an `html\`...\`` body, even in comments (it closes the literal and 500s).
10. TypeScript must be erasable (`erasableSyntaxOnly: true`): no `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators.
11. Reactive properties are declared ONLY through the base-class factory `extends WebComponent({ count: Number })`. Never a `static properties` block, never a class-field initializer (it clobbers the reactive accessor).
12. A form that writes binds its action: `<form action=${importedAction}>`. A quoted `action="${fn}"`, a `formaction=${fn}`, `action=${fn}` off a `<form>`, a bound form with `method="get"`, and a non-action function all throw. A page has no `action` export, so a bare `<form method="post">` is a 405.

## Export Map

Expand Down Expand Up @@ -189,24 +190,32 @@ class Counter extends WebComponent({ count: prop(Number) }) {
Counter.register('my-counter');
```

### The no-JS write path (a page action)
### The no-JS write path (a form-bound action)

```ts
// app/contact/page.ts
export const action = async ({ formData }) => {
// modules/contact/actions/send-message.server.ts
'use server';
export async function sendMessage(formData: FormData) {
const email = String(formData.get('email') || '');
if (!email) return { success: false, fieldErrors: { email: 'required' } };
return { success: true, redirect: '/thanks' };
};
export default function Contact({ actionData }) { /* render form + actionData errors */ }
}

// app/contact/page.ts
import { sendMessage } from '#modules/contact/actions/send-message.server.ts';
export default function Contact({ actionData }) {
return html`<form action=${sendMessage}><input name="email"></form>`;
}
```

Success is a 303 (PRG); failure re-renders the page at 422 with the result on `actionData`. With JS the client router applies the response in place.
Binding the action is the whole wiring: the renderer omits `action` (so the form posts to the page's own url), supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` identity field. A form-bound action always receives the `FormData`.

Success is a 303 (PRG); failure re-renders the page at 422 with the result on `actionData`. With JS the client router applies the response in place. A submission that binds nothing is a 405, and the submission is Origin-verified like an RPC call.

## Security And Session Defaults

- Never ship demo secrets. Require session and provider secrets from the environment and fail fast if missing.
- Action RPC CSRF is an Origin / `Sec-Fetch-Site` check, not a token cookie. A safe GET action is CSRF-exempt. A `route.ts` REST endpoint is NOT covered: authenticate every mutating endpoint, validate, rate-limit.
- CSRF is an Origin / `Sec-Fetch-Site` check on both the action RPC and the form-submit path, not a token cookie. A safe GET action is CSRF-exempt. A `route.ts` REST endpoint is NOT covered: authenticate every mutating endpoint, validate, rate-limit.
- Prod action errors are sanitized to a generic message plus a digest. Put a user-facing message on the `ActionResult` `{ success: false, error }` envelope, never on a raw throw.
- Use `forbidden()` for an authenticated user lacking permission, `unauthorized()` for an unauthenticated request. Inside a `'use server'` RPC action, return an `ActionResult` for an auth failure instead of throwing.
- For CORS use `cors()` from `@webjsdev/server`; `credentials: true` REQUIRES an explicit origin allowlist, never `'*'`.
Expand All @@ -225,6 +234,9 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
- Using a `static properties` block or a class-field initializer for reactive props instead of the `WebComponent({ ... })` factory.
- Quoting an event / property / boolean hole (`@click="${fn}"`).
- Writing `fetch()` to call your own server instead of importing the action.
- Writing a bare `<form method="post">` and expecting a page `action` export to catch it. There is no such export; bind the action with `action=${fn}` or the submission is a 405.
- Reaching for `formaction=${fn}` on a submit button to give one form several actions. It is refused; write one form per action.
- Binding an action whose file declares `export const method = 'GET'`. That is a 405 at runtime and a `webjs check` error.
- Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there.
- A placeholder first paint that fetches in `connectedCallback`. SSR does not call `connectedCallback`; put first-paint data in the constructor (server-known inputs) or use `async render()`.
- A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`.
Expand Down
4 changes: 2 additions & 2 deletions .agents/skills/webjs/references/auth-and-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export const POST = handlers.POST;
<form method="POST" action="/api/auth/signout"><button>Log out</button></form>
```

For a programmatic sign-in (the auto-login-after-signup pattern), `signIn('credentials', creds, { redirectTo })` returns a `302` `Response` that a page `action` can return directly.
For a programmatic sign-in (the auto-login-after-signup pattern), `signIn('credentials', creds, { redirectTo })` returns a `302` `Response` that a form-bound action can return directly (the framework honors a returned `Response` verbatim).

Sessions are JWT by default (stateless, scales horizontally). OAuth
providers handle the full redirect flow. Read the session anywhere on the
Expand Down Expand Up @@ -208,7 +208,7 @@ export default async function Admin() {
}
```

Both work from a page or layout render AND from a page `action` (the no-JS
Both work from a page or layout render AND from a form-bound action (the no-JS
write path). The boundary files (`app/forbidden.ts`, `app/unauthorized.ts`,
or nested variants) live alongside the routes they cover and each
default-export a function returning the boundary markup.
Expand Down
29 changes: 27 additions & 2 deletions .agents/skills/webjs/references/data-and-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
- Drizzle rc.3 reads (`db.query.*`) and mutations (`.returning()`)
- Keeping server-only types off the client (`import type` vs a value import)

Read this when a task touches a server mutation, a data read, input validation, a REST endpoint, or the shape a component consumes. Sibling refs: `routing-and-pages.md` (the page `action` write path, `route.ts` handlers), `auth-and-sessions.md` (protecting an action or endpoint), `optimistic-ui.md` (consuming `ActionResult` on the client), `typescript.md` (erasable syntax, full-stack types).
Read this when a task touches a server mutation, a data read, input validation, a REST endpoint, or the shape a component consumes. Sibling refs: `routing-and-pages.md` (where a bound form lives on a page, `route.ts` handlers), `auth-and-sessions.md` (protecting an action or endpoint), `optimistic-ui.md` (consuming `ActionResult` on the client), `typescript.md` (erasable syntax, full-stack types).

## The Architecture (read this first)

Expand Down Expand Up @@ -100,7 +100,7 @@ A `.returning()` row is the table's own columns only, never `with` relations. Wh

## Input validation at the boundary

Declare `export const validate` beside the action. It runs SERVER-SIDE before the action body on the RPC boundary, receiving the action's FIRST argument. The framework only CALLS the validator (it ships no validation library) and reads its return: `{ success: true, data? }` runs the action (an optional `data` replaces the input), `{ success: false, fieldErrors }` returns a 422 WITHOUT running the body, and a THROW becomes a sanitized error.
Declare `export const validate` beside the action. It runs SERVER-SIDE before the action body on every BOUNDARY (the RPC endpoint, a `route()` REST endpoint, and a form submission), receiving the action's FIRST argument. On the form boundary that first argument is the `FormData`, and a `{ success: false, fieldErrors }` return becomes a 422 RE-RENDER of the page with the validator's result on `actionData`, rather than a JSON 422. The framework only CALLS the validator (it ships no validation library) and reads its return: `{ success: true, data? }` runs the action (an optional `data` replaces the input), `{ success: false, fieldErrors }` returns a 422 WITHOUT running the body, and a THROW becomes a sanitized error.

```ts
// modules/posts/actions/create-post.server.ts
Expand All @@ -126,6 +126,31 @@ export async function createPost(input: CreatePostInput) { /* runs only when val

A client call resolves with the failure envelope (it does NOT throw), so the component reads `result.fieldErrors`. A zod adapter wraps `safeParse` so its result becomes the envelope; the framework stays zod-free.

## Binding an action to a form

A `<form action=${importedAction}>` is the one way a form submits to a server action, and it is the whole wiring:

```ts
import { createPost } from '#modules/posts/actions/create-post.server.ts';
html`<form action=${createPost}><input name="title"></form>`;
```

The renderer omits the `action` attribute so the form posts to the page's own url, supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` field carrying the action's `<hash>/<fn>` identity, the same identity the RPC endpoint resolves. Nothing about the action's source reaches the browser. With JS off this is an ordinary HTML submission; with JS the client router posts the same body to the same url, so the two paths are identical by construction.

**A form-bound action always receives the `FormData`**, which is where it differs from the same function called over RPC (rich arguments) or server-to-server. `validate` is the typing seam: it takes the `FormData` and its transform-return becomes the action's typed input.

Everything the action declares applies here too, or an action would be protected over RPC and open over a form:

- `validate` runs on the submitted `FormData`.
- the `middleware` chain runs, with the page's route context (`params`, `searchParams`, `url`) added to `ctx`.
- `invalidates` is evicted when the action actually RAN (a middleware short-circuit does not evict), and the evicted tags are reported on the response so the browser's tag coordinator bypasses a stale cached GET. One reach limit: `fetch` follows the success `303` transparently, so JS cannot read a redirect's headers; the tags are on the wire and the `422` re-render carries them, and the redirect's own render is server-side and seeds fresh data.
- `invalidates` and `tags` receive the SAME first argument the action does, so on a form boundary they receive the `FormData`. `invalidates: (input) => ['post:' + input.id]` returns `post:undefined` for a submission and evicts nothing. Either read the field (`(fd) => ['post:' + fd.get('id')]`), declare a `validate` that transforms the `FormData` into the typed input first (the transform result is what the config functions then see), or use an argument-independent tag.
- `method = 'GET'` cannot be bound to a form: a GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. That is a `405` at runtime and the `form-action-not-a-get-action` error in `webjs check`.

The response drives the page: a success is a `303` PRG (to `result.redirect` when it is a same-site local path, else the page's own url), a failure re-renders the SAME page with `status` (default `422`) and the result on `actionData`, a submission carrying no identity is a `405`, and one whose hash no longer resolves is a `422` with a resubmit message (a form held open across a deploy). The submission is Origin-verified like an RPC call, so no token field is needed.

A streamed return (#489) is refused from a form-bound action: the RPC stub decodes frames, but a submission is answered with a redirect or a page, and with JS off there is no consumer at all. Stream from a programmatic call instead.

## HTTP-verb config exports

A `'use server'` action is a POST by default. Reserved sibling exports, read statically (the same way a page reads `export const revalidate`), change its HTTP semantics WITHOUT changing the call site (you still write `await getUser(7)`).
Expand Down
Loading
Loading