# SurfWave v0

- **Status:** v0 (ADR-0055)
- **Related documents:** [Language spec v0](https://surface.solenovis.com/docs/language.md), [Component reference](https://surface.solenovis.com/docs/components.md), [Security](https://surface.solenovis.com/docs/security.md)
- **Changes:** 2026-11-06: a desktop app runs the `load` functions of the server in its own process, with no hooks. It keeps its cookies in its data folder, and `Wave.env` reads the environment of the app (2, 6). 2026-11-06: the server has the locale of each request, `x-wave-locale` and `Vary` (5, ADR-0132). 2026-11-05: page transitions on all targets, with transition names and a look of their own (7, ADR-0154). The spec marks them "Not in version 0". 2026-11-05: the server and the adapters have the default `HOST` and `--base`. A request outside the base path gets status 404, and the code of the server sees each path without the prefix (5, ADR-0139). 2026-11-05: the default of `HOST` is `127.0.0.1`, and the `Dockerfile` of `docker` and `fly` sets `HOST=0.0.0.0` (5, ADR-0109). `--base` works for the server target, the static target and the adapters (5, ADR-0139). The spec marks both "Not in version 0". 2026-11-04: the lookup of RFC 4647 finds the locale of `Accept-Language`, and after `Locale.set` the header `x-wave-locale` replaces `Accept-Language` (5, ADR-0132). 2026-11-04: each request has its own locale, and the page hydrates with it (5, ADR-0132).

SurfWave gives a project routes from files. A project with a `src/routes` folder has no `src/App.surf`. The compiler writes the root component.

## 1. Files

| File | Meaning |
|---|---|
| `Page.surf` | The page of the URL of its folder |
| `Layout.surf` | The frame of each page in its folder. It declares `slot default`, and it shows the page with `@slot default`. |
| `Error.surf` | The page for a failed `load` in its folder |
| `load.surfs` | The data of the folder: `load` for the page and `layout` for the layout |
| `load.server.surfs` | The same, on the server only. The browser reads `/__data.json`. |
| `Page@.surf` | A page with no layout around it |
| `server.surfs` | Endpoints: `pub async fn get(request: Request) -> Response`, also `post`, `put`, `patch` and `delete` |
| `actions.surfs` | Forms: `pub async fn name(request: Request) -> Result<T, Failure>` |

| Folder | Meaning |
|---|---|
| `tickets` | The fixed part `tickets` of the URL |
| `[id]` | A route parameter `id` |
| `[...rest]` | The rest of the URL, as the parameter `rest`. It can be empty. |
| `(app)` | A group: it can hold a layout, and it adds nothing to the URL |
| `[id=int]`, `[id=uuid]`, `[id=name]` | A parameter with a matcher. `name` is the function `name` of `params/name.surfs`. |
| `[[lang]]` | A parameter that a URL can leave out. Its value is then empty. |

A fixed part comes before a parameter, and a parameter comes before a rest. Two pages with one URL are a compile error. `surf routes` lists the routes.

## 2. Data

```surf
// src/routes/tickets/[id]/load.surfs
import { AppData } from "../../load.surfs";
import { ticketDetail } from "~/api.surfs";
import { TicketDetail } from "~/model.surfs";

pub type TicketData = { detail: TicketDetail, user: User }

pub async fn load(id: String, parent: AppData) -> Result<TicketData, String> {
    const detail = await ticketDetail(id)?;
    return .ok({ detail: detail, user: parent.user });
}
```

- `load` and `layout` must be `pub async fn`. They return `Result<T, String>` or `Result<T, Failure>`.
- A parameter has the name of a route parameter, or the name `parent`. `parent` is the data of the nearest outer layout with a `layout` function.
- `later` next to `load` gives a page a value that it does not wait for: `pub async fn later(id: String) -> Result<Slow, String>`. The page has `prop later: Slow? = none`. `later` takes route parameters. On a first visit, the server sends the page after `load`, and it sends the value in the same response. After a navigation, the value comes when it is ready. A failed `later` leaves the prop at `none` (ADR-0062).
- `load.server.surfs` takes `parent` too. When the browser asks for its data, the server runs the `load` functions of the outer layouts first, also for a function with no `parent`. The first failure stops the request, so a guard in a `layout` function holds for the data of each page in its folder. The server does not read a value of the browser (ADR-0109).
- A `load` function of the server runs only for a URL of its route. The hook `before` reads the path of the page, not `/__data.json`.
- The types of two `load` functions must have different names. The result type of a `load.server.surfs` file must be in a file that the browser can read.
- `Failure` is `{ status, message, redirect }`. `failure(status, message)`, `redirect(url)` and `notFound()` make one. A `String` error has status 500.

**The desktop target.** A desktop app has no server. So it runs each `load`, `layout` and `later` function of `load.server.surfs` in its own process, as a local call, as it does for a remote function (section 6). It sends no request for the data of a page.

- The desktop build has each file of the server that such a function imports: a `*.server.surfs` file, and a file in a `server` folder. It has no endpoint (`server.surfs`), no form action (`actions.surfs`) and no hooks (`hooks.server.surfs`).
- The `load` functions of the outer layouts run first, also for a function with no `parent`, as on a server. The first failure stops the navigation. So a guard in a `layout` function holds on the desktop target too.
- The hooks do not run, because a desktop app gets no requests. A guard in `before` holds only on a server. Put a guard that each target needs in a `layout` function. A desktop build of an app with hooks gives a warning.
- The app keeps its cookies as a browser does: in its data folder, until `maxAge` ends. A `maxAge` of 0 or less removes the cookie. A navigation reads the cookies that the app has at its start. So a cookie of `Wave.setCookie` counts from the next navigation, as in a browser, which sends a new cookie with the next request.
- `Wave.env` reads the environment of the process of the app, in each file. The compiler checks the names in a file that the browser also reads, as for the web (section 5).

## 3. Props

The router gives a page, a layout or an error page the props that it declares:

| Prop | Value |
|---|---|
| A field of the data | That field |
| `data` | The whole data |
| A route parameter | Its text |
| `path` | The path of the URL |
| `url` | The path and the query. It changes with each navigation. |
| `navigating` | True while a new route loads |
| `failure` | The `Failure`, on an error page |
| `form` | The result of the last action of the page, as JSON text. It is empty before an action. |

A layout prop that has the name of a field of the data of a page gets that field while the page shows. Give it the default `none`.

## 4. The module `surf:wave`

Import it with `import { Wave } from "surf:wave"`. `~/x.surfs` is a path from `src`.

| Function | Meaning |
|---|---|
| `Wave.go(url)`, `Wave.replace(url)`, `Wave.back()` | Navigation |
| `Wave.reload()` | Runs each `load` function of the route again |
| `Wave.path()`, `Wave.url()` | The URL |
| `Wave.query(name)`, `Wave.hasQuery(name)`, `Wave.queryOf(url, name)` | The query of the URL |
| `Wave.title(text)` | The title of the page. Call it in an `effect` of the page. |
| `Wave.notify(text)`, `on Wave.notified(text) { ... }` | A message from a page to a layout |

A `<Link href="/x">` with no `on:press` goes to the route.

The module also has the types and functions of the server:

| Name | Meaning |
|---|---|
| `Request` | `method`, `url`, `path`, `headers`, `cookies`, `form`, `params` (the route parameters of a `server.surfs` file) and `body`. The maps have `String` keys and values. `path` has one form (section 5). |
| `Response` | `status`, `headers` and `body` |
| `textResponse(body)`, `jsonResponse(body)`, `response(status, kind, body)` | A response. `kind` is the media type. |
| `redirectResponse(url)`, `methodNotAllowed()`, `failedResponse(failure)` | A response for these cases |
| `Failure`, `failure(status, message)`, `redirect(url)`, `notFound()` | The error of a `load` or of an action |

A form is an element with `role="form"`, `method="post"` and `action="/path?/name"`, where `name` is a function of `actions.surfs`. The value of each `Input` with a `name` is in `request.form`. A form works with no script. The server refuses a form from another site. [Build a full app](https://surface.solenovis.com/docs/full-app.md) has a complete example.

`href` of a `Link` and `action` of a form take a relative URL, `http`, `https`, `mailto` and `tel`. The element gets no attribute for another scheme, because a scheme such as `javascript:` runs a script in the page (ADR-0109).

## 5. The server

`surf build --target server` writes `build/server/<name>-server`. `HOST` is its address, and `PORT` is its port. `ORIGIN` is the address of the site behind a proxy. `API_ORIGIN` is the http address for a relative `Http` request, and for `/api/`. [Security](https://surface.solenovis.com/docs/security.md) lists each setting and each limit of the server. `surf build --target static` writes each page as a file.

- Without `HOST`, the server binds to `127.0.0.1`, so only its own machine reaches it (ADR-0109). A container needs `HOST=0.0.0.0`, and the `Dockerfile` of the adapters `docker` and `fly` sets it. The unit of `systemd` keeps the default, because a proxy on the same machine is in front of it.
- `surf build --base /docs/` serves the site at the base path `/docs/`, also with the server target, the static target and an adapter (ADR-0139). The base path is the prefix of each address. The page, the router, each `Link` and each address of a file get the prefix. Each request to the server code gets it too. These requests are the data of a page (`/__data.json`), a form action, a remote function (`/__remote/`), an endpoint and `/__version`. An `href` or an `action` that starts with `/` gets the prefix, and a relative `href` or an `href` with a scheme does not change. In the browser, a relative `Http` request with a path that starts with `/` gets the prefix too, because it goes to an endpoint or to `/api/`. `Wave.base()` gives the prefix without the last `/`, also on the server.
  - With the server target, the static target and an adapter, the base path starts and ends with `/`. Its parts have only letters, digits, `-`, `.`, `_` and `~`.
  - The root of the site is the base path, for example `/docs/`. The server sends status 308 from `/docs` to `/docs/`. A request outside the base path gets status 404, before a hook runs.
  - The code of the server sees each path without the prefix. A hook, a `load`, an action, an endpoint and a remote function read `/about` for the address `/docs/about`. So the code of an app does not change with its place on the host. A redirect of this code names a path without the prefix, and the server adds the prefix to the header `location`.
  - The static target writes each page at the root of its folder, for example `about/index.html`. The host serves the folder at the base path.
  - Not in version 0: a picture of `src/assets` reads its file relative to the address of the page. So on a page below the root of the site, a path such as `assets/logo.png` names no file. An example of such a page is `/docs/notes/1`. A web app with no routes gives the base path only to its script and its stylesheet, not to a `Link`, a form or an `Http` request.

Each path has one form before a hook runs: no empty part, no `/` at the end, and a `%` escape only for a byte that needs it. A path in another form gets status 308 to the one form. A part `.` or `..` gets status 400. A part with `/` or `\`, also in the form `%2F` or `%5C`, gets status 400, because a rest parameter joins its parts with `/` (ADR-0109). So a route parameter has no `/`, and the parts of `request.path` are the parts of the router.

The script uses the nodes of the HTML (ADR-0060). The app builds no second page, so focus, the scroll offsets and typed text stay. A value that differs on the server changes to the value of the browser. A block with other nodes on the server gets new nodes. A press before the script runs does nothing, but a link and a form work.

- `src/hooks.server.surfs` can have `pub async fn before(request: Request) -> Response?` and `pub async fn after(request: Request, response: Response) -> Response`. For `/__data.json`, `request` has the path of the page.
- `pub const ssr = false`, `csr = false`, `prerender = false` and `entries = ["/items/1"]` in `load.surfs` apply to the folder and to its subfolders.
- A file with the name `*.server.surf`, and a file in a `server` folder, are not in the script of the page.
- `Wave.cookie(name)`, `Wave.setCookie(name, value, maxAge)` and `Wave.env(name)` read and write the request, in a `load`, an action, an endpoint, a hook and a remote function. A cookie has `HttpOnly`, `SameSite=Lax` and `Path=/`, and `Secure` except on this machine. In the browser, `env` gives only what the page sets in `surfEnv`.
- In a file that the browser also reads, the argument of `Wave.env` must be a name in quotes that starts with `PUBLIC_`. Another argument is a compile error (ADR-0109). A file of the server can read each variable: `*.server.surfs`, `server.surfs`, `actions.surfs`, `*.remote.surfs`, and a file in a `server` folder.
- A relative `Http` request goes to `API_ORIGIN` with the `cookie` and `authorization` headers of the client. An absolute URL gets neither. The server has `http:` only.
- `Locale` (language spec, section 14) takes a locale for each request. The server takes the tags of the `Accept-Language` header in the order of their weights, and finds an item of the list `locales` of `surf.toml` with the lookup of RFC 4647 (language spec, section 14). So `de-CH` fits `de`. Without a fit, it is the first item of the list. A `load`, an action, an endpoint, a hook, a remote function and the page that the server sends use this locale. The page hydrates with the same locale: the element of the app has the tag in `data-locale`, and the script takes it. `Locale.set` in the browser changes the locale after that. The server formats with the ICU of the system, as the native targets do. So a few texts can differ from the texts of the browser, and they change at hydration as each other value that differs does. A page of `surf build --target static` has no request, so it has no `data-locale`, and the browser chooses the locale with its languages.
- After `Locale.set` in the browser, the client sends the header `x-wave-locale` with each data request (`/__data.json`), each action and each remote call (ADR-0132). Its value is the tag of `Locale.current`. For that request, the server uses this tag in place of `Accept-Language`, with the same rule of the fit. A new load of the page has no such header, because `Locale.set` does not save the locale.
- A response that depends on the locale has the header `Vary: Accept-Language, x-wave-locale`, so a cache keeps one copy for each locale (ADR-0132). A response depends on the locale when its code reads `Locale`. Each page reads it, because the page has the tag in `data-locale`.
- A server of an app with `Locale` loads ICU when it starts. Without ICU, the server stops at its start with an error that names the package (ADR-0132). This applies to a system and to the image of a container. The `Dockerfile` of an adapter must then install the package of ICU of its image.
- A server has no Rust crates of its own: an extern Rust file has the standard library and the runtime. A Surf package (ADR-0071) brings `.surf` files, not crates. A database stays behind an API, and `API_ORIGIN` is its address.

`surf build --adapter <name>` chooses the target, and it writes the files of a host (ADR-0064):

| Adapter | Target | Files |
|---|---|---|
| `docker` | server | `Dockerfile`, `.dockerignore`. The `Dockerfile` sets `HOST=0.0.0.0` |
| `fly` | server | The files of `docker`, and `fly.toml` |
| `systemd` | server | `<name>.service`, with the default `HOST` |
| `netlify` | static | `netlify.toml` |
| `vercel` | static | `vercel.json` |
| `cloudflare` | static | `web/_redirects` |
| `github-pages` | static | `web/.nojekyll` |

The files of `netlify`, `vercel` and `cloudflare` send each unknown URL to `404.html`. With `--base /docs/`, the rule names the prefix: `cloudflare` writes `/docs/* /docs/404.html 200` (ADR-0064, ADR-0139).

## 6. Remote functions

A public `async` function of a `*.remote.surf` file runs on the server, and a component calls it as a local function (ADR-0063):

```surf
// src/todos.remote.surfs
import { Todo } from "./todo.surfs";

pub async fn addTodo(text: String) -> Result<Todo, String> {
    return .ok(await saveTodo(text));
}
```

```surf
import { addTodo } from "./todos.remote.surfs";
```

- The result must be `Result<T, String>`. The error of the function, or a failed request, is `.err(text)`.
- The types of the parameters and of the result come from another file, because the browser reads them too.
- A function that is not public stays on the server.
- `hooks.server.surfs` runs for each call, with the path `/__remote/<file>/<name>`. A guard of a page does not cover a remote function, so check the session in the function or in the hook.
- The file needs `--target server`. On the desktop target, it is a local call, with no hooks. Its cookies follow the rules of a desktop app (section 2).

## 7. Navigation

| Function or event | Meaning |
|---|---|
| `Wave.depends(key)`, `Wave.invalidate(key)` | A layout `load` that read `key` runs again |
| `Wave.hold(true)`, `on Wave.blocked(url)` | A navigation stops, and the app can ask the user |
| `on Wave.leaving(url)`, `on Wave.arrivedAt(url)` | The start and the end of a navigation |
| `Wave.pushState(url, value)`, `on Wave.shallow(value)` | A URL with no new page |
| `Wave.snapshot(text)`, `Wave.restored()` | The state of a page for the back button |
| `Wave.animateRoutes(true)` | Page transitions between pages |
| `await Wave.checkUpdate()` | True when the server has a new build |
| `Wave.preload(url)`, `Wave.base()` | The code and the data of a route now, and the folder of the app, for example `/docs` for `--base /docs/` (ADR-0139) |

`<Link>` takes `preload="off"` or `"tap"`, `reload`, `replace`, `noScroll` and `keepFocus`.

**Page transitions** (ADR-0154).

- `Wave.animateRoutes(true)` turns on the page transitions on all targets. `Wave.animateRoutes(false)` turns them off. They are off by default.
- A navigation that changes the path of the URL has a page transition. This includes a `Link`, `Wave.go`, `Wave.replace`, `Wave.back` and the back and forward buttons. The first page, a change of the query only, `Wave.pushState` and `Wave.reload` have no page transition.
- The transition starts when the data of the new page is ready, just before the router shows the new page. The picture of the old page shows the old page at that time.
- By default, the picture of the old page and the new page cross-fade in 250 ms with `ease`, as in CSS View Transitions. The two pictures blend with `plus-lighter`, so a part that does not change stays the same.
- An element with `view-transition-<name>` moves from its old box to its new box (layout spec 5.10). It is not a part of the picture of the page.
- `[view-transition]` in `src/style.toml` gives the look. `old` and `new` name `keyframes` for the picture of the old page and of the new page. `ms` and `ease` give the time and the easing of each part of the transition, also of the moves of the names. `[view-transition.back]` gives the look of a navigation back. An unknown name is a compile error. A navigation back has the type `back` of CSS View Transitions.

  ```toml
  [view-transition]
  ms = 300
  ease = "ease-out"
  old = "fade-out"
  new = "rise"

  [view-transition.back]
  old = "fade-out"
  new = "fall"
  ```

- During the transition, the new page is live. It gets each press and each key, and the accessibility tree is the tree of the new page. The pictures get no press. The focus follows the rules of the router.
- With the setting of reduced motion on, a navigation shows the new page at once.
- The web uses `document.startViewTransition`. A browser without it shows the new page at once. The native targets keep a picture of the window and of each named element, and they paint the transition over the new page. Only an app that calls `Wave.animateRoutes` has this code.
- A `Transition` around `@slot default` of a layout does not animate a new page. A new page is not a block of the layout (component reference 4.15).
