# Forms and state

> A form turns a user's action into a request; the server validates it, changes state and returns the next representation

Everything the static half of the course shipped was read-only: the browser
asked for a page and got one. A form is the other direction. It turns a user's
action into a request that changes something, and the response is the next
version of the page.

## The request path

The whole mechanism is four attributes of ordinary HTML. This is the guestbook
in the [lecture demo](https://lecture-demos.fly.dev/), complete:

```html
<form method="post" action="/api/messages">
  <label for="body">Leave a message</label>
  <input id="body" name="body" required maxlength="500" />
  <button>Sign</button>
</form>
```

- **`method`** and **`action`** say what request to make and where to send it.
  The browser makes it; there is no JavaScript in this form at all
- **`name`** is the only part of the markup the server ever sees. The request
  body is `body=g%27day`, and `name` is where that key came from. Rename it and
  the server is reading a field that no longer arrives
- **`required`** and **`maxlength`** are a courtesy to the person typing, not
  validation. Anything can post to this URL, so the handler does the work again

### Server-side routing {#server-side-routing}

A route pairs a method and a path, such as `POST /api/messages`, with the code
that answers it: the handler. Frameworks like
[Express](https://expressjs.com/en/guide/routing/) make you register each route
by hand. In Astro the file path is the URL and the exported function is the
method:

```text
src/pages/index.astro       GET   /
src/pages/readme.astro      GET   /readme/
src/pages/api/messages.ts   POST  /api/messages     export const POST
src/pages/api/events.ts     GET   /api/events       export const GET
```

Nothing registers a route. What makes this different from the static half is one
line of `astro.config.ts` --- `output: "server"` plus the Node adapter --- which
builds a running server instead of a directory of HTML files.

That has a consequence to account for: a server-rendered app has no
`dist/*.html` for a check to walk, so the course's invariants visit an explicit
list of routes in `spec/routes.ts`. Add a page without adding its route and the
checks stop covering it.

### Validating and responding

The handler re-does the client's checks and then answers:

```ts
const form = await request.formData();
const body = String(form.get("body") ?? "").trim();
if (body) bus.emit("message", addMessage(body.slice(0, 500)));
return redirect("/", 303);
```

The `303` is the reason the form works with JavaScript disabled: the browser
follows it and re-renders the page from the database. Watch one button press in
the network panel and you see both halves, plus the live channel sitting open:

```text
POST  /api/messages   303   body=g'day
GET   /               200   the list, re-rendered from SQLite
GET   /api/events     200   text/event-stream — (pending)
```

## State on each side

Three places state can live, and the distinction matters more once there is a
server to get it wrong:

- **in the page**: whatever the current render is showing. It's gone on reload,
  which is fine for a disclosure widget and wrong for anything a user typed
- **in the session**: who this visitor is, across requests. A cookie carries an
  identifier; the data behind it stays on the server
- **in the database**: the facts the app is actually about. The guestbook's
  messages are here, which is why a reload still shows them

The question to ask of any new feature is which of the three its state belongs
in, and the crit-7 spec is blunt about the answer it cares about: create
something, and it's still there after a reload.

The live-update stream is a fourth thing that is easy to confuse with state.
`/api/events` is a long-lived response the browser reads with `EventSource`;
server-sent events are one-directional and plain HTTP, which makes them the
simplest live channel that works everywhere. It carries no state of its own ---
it tells other tabs that state changed, and they re-render.

## Deploy the same contract

The point of a plain form post is that it behaves identically on your machine
and on the deployed app. The one exception matters here because it fails in
exactly one place.

Astro checks that a form POST carries a same-origin `Origin` header, which is
CSRF protection you get for free. Behind Fly's proxy, TLS is terminated before
the request reaches your app, so an app that hasn't been told to trust
`x-forwarded-proto` decides it is serving `http`, disagrees with its own origin,
and answers `403` to every form in the app --- in production only. Naming the
deploy domain is what prevents it:

```ts
security: {
  allowedDomains: [{ hostname: "**.fly.dev", protocol: "https" }],
}
```

The lecture demo ships that line, and its CI probes the deployed app for both
halves of the behaviour: that a same-origin POST is accepted, and that a
cross-site one is still refused. Everything else about getting the app onto a
machine is [running in production](https://comp.anu.edu.au/courses/comp4020-agentic-coding-studio/topics/running-in-production/).
