# Serve a WebSocket chat room

URL: /docs/cookbook/websocket_server
Markdown: /docs/cookbook/websocket_server.md

> Upgrade a route to a WebSocket and pass each member's messages to the rest of the room.

Generated by `cargo run -p agent_docs` from `compiler/examples/`. Edit the example files, then regenerate.

Upgrade a route to a WebSocket and pass each member's messages to the rest of the room.

The route is an ordinary `Serve` handler. `WebSocket.handshake(req)` checks the opening
handshake without touching the connection, so a request that is not one is answered 426 with
`sec-websocket-version: 13`. `WebSocket.accept(req, options, member)` then upgrades it and runs
`member` with the `Socket`: the same `send`, `receive` and `close` a client has, closed with
1001 when `member` returns, fails or is cancelled. It returns the 101, and the server discards
what the handler returns for a connection it upgraded. When no upgrade happened it fails
`SocketError(Accept(reason))`: a browser page from an origin other than `chat.example.com` is
answered 403, and anything else, such as the `SAKURA_MAX_SOCKETS` limit, 503 with
`retry-after`. `origins` starts empty, which refuses every browser, since a browser sends the
victim's cookies with the upgrade; a client that sends no `origin` is admitted, and
authentication stays the handler's.

A `Socket` cannot be kept in handler state (`V6003`), so the room is a topic:
`WebSocket.subscribe(sock, "room")`, and `WebSocket.publish("room", message, Some(sock))`
writes to every other member without waiting, passing over one that is not reading. The runtime
has no idle timer and sends no pings: `timeout` around `receive` closes a member that has been
silent for five minutes. `max_message` caps each message at 64 KiB in both directions.

The manifest grants the listener's address; `accept` opens nothing, so it needs no entry of
its own. The tests run `Serve.scripted` around `WebSocket.scripted`, which plays the member, and
print `Get /chat -> 101 ` for the upgrade. No test opens a socket.

[Source project](https://github.com/icantcodefyi/sakuralang/tree/main/compiler/examples/websocket_server) · harness mode: `test`.

From the repository root, with this checkout's `sakura` on `PATH`:

```sh
cd compiler/examples/websocket_server
sakura check --json
sakura test --json
```

### Manifest and permissions [#manifest-and-permissions]

```toml
[package]
name = "websocket_server"
version = "0.1.0"
edition = "2026"

[permissions]
net = ["127.0.0.1:8080"]
```

### Source and tests [#source-and-tests]

`src/main.sak` includes all required imports and tests.

```sakura test id=cookbook-websocket_server project=websocket_server
import std/request.{Get, Request}
import std/response.{Response}
import std/router.{Router}
import std/serve.{Address, Serve, ServeError}
import std/task.{timeout}
import std/url.{Url}
import std/websocket.{
  Accept,
  Binary,
  Close,
  Closed,
  Data,
  Reply,
  Socket,
  SocketError,
  Text,
  Upgrade,
  WebSocket,
}

/// The chat route. A request that is not a WebSocket handshake is answered 426 with the version
/// the server speaks; a browser page from an origin other than the chat's own is 403; a server
/// at its socket limit is 503. An accepted member joins the `room` topic, and each text message
/// it sends goes to every other member. A member silent for five minutes is closed with 4000.
pub fn chat(req: Request) -> Response ! {Clock, Task, WebSocket} {
  match WebSocket.handshake(req) {
    Err(why) =>
      Response { ..Response.status(426, why), headers: [("sec-websocket-version", "13")] },
    Ok(_) => join(req),
  }
}

fn join(req: Request) -> Response ! {Clock, Task, WebSocket} {
  let options = Upgrade {
    ..WebSocket.upgrade(),
    origins: ["https://chat.example.com"],
    max_message: 65_536,
  }
  match try { WebSocket.accept(req, options, member) } {
    Ok(switched) => switched,
    Err(e) => match e.reason {
      Accept(why) if String.starts_with(why, "origin ") => Response.status(403, why),
      _ => Response { ..Response.status(503, "busy"), headers: [("retry-after", "1")] },
    },
  }
}

fn member(sock: Socket) -> Unit ! {Clock, Task, WebSocket, Fail<SocketError>} {
  WebSocket.subscribe(sock, "room")
  WebSocket.send(sock, Text("welcome"))
  var open = true
  while open {
    match try { timeout(Duration.seconds(300), fn() { WebSocket.receive(sock) }) } {
      Ok(Data(Text(s))) => { let _ = WebSocket.publish("room", Text(s), Some(sock)) },
      Ok(Data(Binary(_))) => {},
      Ok(Closed(_)) => { open = false },
      Err(_) => {
        WebSocket.close(sock, 4000, "idle")
        open = false
      },
    }
  }
}

pub fn router() -> Router<{Clock, Task, WebSocket}> { Router.new().get("/chat", chat) }

test "a member is welcomed, talks, and leaves at the close; the upgrade answers 101" {
  let script = [Reply(Text("welcome"), Text("hello room")), Close(1000, "bye")]
  with Serve.scripted([WebSocket.upgrade_request("/chat", [])]),
      WebSocket.scripted(script),
      Clock.virtual(),
      Task.deterministic(1) {
    expect_fail(
      fn() { Serve.listen(router(), Address.of("127.0.0.1", 8080)) },
      ServeError { reason: "no more requests" },
    )
  }
  expect_output(["Get /chat -> 101 "])
}

test "a plain GET is 426, a foreign browser origin 403" {
  let foreign = Request.with_header(
    WebSocket.upgrade_request("/chat", []),
    "origin",
    "https://evil.example",
  )
  let plain = Request.with_header(Request.of(Get, Url.from("/chat")), "host", "example.test")
  with Serve.scripted([plain, foreign]),
      WebSocket.scripted([]),
      Clock.virtual(),
      Task.deterministic(1) {
    let _ = try { Serve.listen(router(), Address.of("127.0.0.1", 8080)) }
  }
  expect_output([
    "Get /chat -> 426 upgrade does not name websocket",
    "Get /chat -> 403 origin https://evil.example is not accepted",
  ])
}
```

### Compiler provenance and regeneration [#compiler-provenance-and-regeneration]

Finish only when no holes remain, independent tests pass, the requested target
builds, and contracts/tests/permissions have not been weakened. Repeat the frozen
baseline check after the final edits. Report missing prerequisites and failing
checks explicitly; do not claim a skipped test passed.

These projects teach supported Sakura APIs on the JavaScript backend. Each
project is exercised offline by `cargo test -p sakura_cli --test examples`;
HTTP and LLM exchanges are synthetic committed cassettes, with no network or API keys.
Use Node 26 for execution, including its built-in SQLite implementation.

The initial API baseline is compiler revision
[5cae349516c21714d90d36e87f1cfd6debe980c6](https://github.com/icantcodefyi/sakuralang/commit/5cae349516c21714d90d36e87f1cfd6debe980c6).
CI reruns these projects against the compiler in the same checkout. Build that
checkout's CLI and run `sakura --version` and `sakura prompt` before adapting a recipe.
Sakura Lang is pre-1.0: read STABILITY.md before depending on an API. An installed
skill's frontmatter records its compiler version and build revision. These teaching examples are separate from the agent
evaluation tasks.

The source of each page is its example's `recipe.md`, `sakura.toml`,
`src/main.sak` and fixture files. Run `cargo run -p agent_docs` from `compiler/`
to regenerate the cookbook and skill reference. Edit those sources, not the rendered pages.
