Skip to main content

docs/handlers.md

# Writing Handlers

Handlers implement the `webtransport_handler` behaviour. The session process
calls your handler's callbacks when events occur (stream data, datagrams,
stream close, etc.).

## Minimal Handler

```erlang
-module(my_handler).
-behaviour(webtransport_handler).

-export([init/3, handle_stream/4, handle_datagram/2,
         handle_stream_closed/3, terminate/2]).

init(_Session, _Req, _Opts) ->
    {ok, #{}}.

handle_stream(Stream, Type, Data, State) ->
    Actions = case Type of
        bidi -> [{send, Stream, <<"echo: ", Data/binary>>}];
        uni  -> []
    end,
    {ok, State, Actions}.

handle_datagram(Data, State) ->
    {ok, State, [{send_datagram, <<"echo: ", Data/binary>>}]}.

handle_stream_closed(_Stream, _Reason, State) ->
    {ok, State}.

terminate(_Reason, _State) ->
    ok.
```

## Callbacks

All callbacks receive the handler state and return `{ok, NewState}`,
`{ok, NewState, Actions}`, or `{stop, Reason, NewState}`.

### `init/3` (required)

Called when a session is established.

```erlang
init(Session, Request, Opts) ->
    {ok, State} | {ok, State, Actions} | {error, Reason}
```

- `Session` -- the session pid, used for `webtransport:open_stream/2`, etc.
- `Request` -- `#{path := binary(), authority := binary(), headers => [{binary(), binary()}]}`
- `Opts` -- the `handler_opts` map from the listener or connect call

`init/2` is a back-compat shim. It is called only when `init/3` is not
exported and loses the `Opts` argument.

### `handle_stream/4` (required)

Called when data arrives on a stream.

```erlang
handle_stream(Stream, Type, Data, State) ->
    {ok, State} | {ok, State, Actions} | {stop, Reason, State}
```

- `Stream` -- stream ID (non-negative integer)
- `Type` -- `bidi` or `uni`
- `Data` -- binary payload

### `handle_stream_fin/4` (optional)

Called when data arrives with the FIN flag (last data on the stream). If not
exported, `handle_stream/4` is called instead.

```erlang
handle_stream_fin(Stream, Type, Data, State) ->
    {ok, State} | {ok, State, Actions} | {stop, Reason, State}
```

### `handle_datagram/2` (required)

Called when an unreliable datagram arrives.

```erlang
handle_datagram(Data, State) ->
    {ok, State} | {ok, State, Actions} | {stop, Reason, State}
```

### `handle_stream_closed/3` (required)

Called when a stream closes or is reset by the peer.

```erlang
handle_stream_closed(Stream, Reason, State) ->
    {ok, State} | {stop, Reason, State}
```

`Reason` is one of:
- `normal` -- clean close
- `{reset, ErrorCode}` -- peer aborted the stream
- `{stop_sending, ErrorCode}` -- peer requested we stop sending
- `{error, Term}` -- transport-level error

### `handle_info/2` (optional)

Called for any Erlang message not handled by the session state machine. Use
this to receive messages from other processes (timers, database replies, etc.)
and return actions.

```erlang
handle_info(Info, State) ->
    {ok, State} | {ok, State, Actions} | {stop, Reason, State}
```

### `handle_action_failed/3` (optional)

Called when an action returned by a callback fails to dispatch (e.g. sending
to an unknown stream). Default behaviour: log via `logger:warning` and
continue.

```erlang
handle_action_failed(Action, Reason, State) ->
    {ok, State} | {stop, Reason, State}
```

### `origin_check/2` (optional)

Called before `init/3` on server-side CONNECT requests. Return `accept` or
`{reject, Status, Reason}` to refuse a session before it starts.

```erlang
origin_check(Headers, Opts) ->
    accept | {reject, 400..599, binary()}
```

When not exported, the default behaviour is:

- **Requests with an `origin` header** (browser clients): rejected with 403.
  The [spec](https://datatracker.ietf.org/doc/html/draft-ietf-webtrans-http3-15#section-3.2)
  requires server-side origin verification.
- **Requests without an `origin` header** (non-browser clients): accepted.

Implement this callback to allow browser origins:

```erlang
origin_check(Headers, _Opts) ->
    case proplists:get_value(<<"origin">>, Headers) of
        <<"https://myapp.example.com">> -> accept;
        _ -> {reject, 403, <<"origin not allowed">>}
    end.
```

### `terminate/2` (required)

Called when the session ends.

```erlang
terminate(Reason, State) -> term()
```

`Reason` is one of:
- `normal` -- clean shutdown
- `{closed, ErrorCode, Message}` -- peer sent `CLOSE_SESSION`
- `{error, Term}` -- error
- `Term` -- other

## Actions

Callbacks can return a list of actions as the third element of the return tuple:

```erlang
handle_stream(Stream, bidi, Data, State) ->
    {ok, State, [
        {send, Stream, <<"echo: ", Data/binary>>},
        {send_datagram, <<"got data">>}
    ]}.
```

| Action | Description |
|--------|-------------|
| `{send, Stream, Data}` | Send data on a stream |
| `{send, Stream, Data, fin}` | Send data and half-close the stream |
| `{send_datagram, Data}` | Send an unreliable datagram |
| `{open_stream, bidi \| uni}` | Open a new stream |
| `{close_stream, Stream}` | Half-close a stream (send FIN) |
| `{reset_stream, Stream, ErrorCode}` | Abort a stream with an error code |
| `{stop_sending, Stream, ErrorCode}` | Ask the peer to stop sending |
| `drain_session` | Signal that no new streams will be opened |
| `{close_session, ErrorCode, Reason}` | Close the session |

## Passing Data to Handlers

Use `handler_opts` to pass configuration, owner pids, or context into your
handler's `init/3`:

```erlang
%% Server side
webtransport:start_listener(my_server, #{
    transport => h3,
    port => 4433,
    certfile => "cert.pem",
    keyfile => "key.pem",
    handler => my_handler,
    handler_opts => #{db_pool => my_pool, max_rooms => 50}
}).
```

```erlang
%% In the handler
init(Session, _Req, #{db_pool := Pool, max_rooms := Max}) ->
    {ok, #{session => Session, pool => Pool, max_rooms => Max}}.
```

## Server-Initiated Streams

To open a stream from the server, spawn a helper process. Do not call
`webtransport:open_stream/2` from inside a callback -- the session process
would deadlock (it is a `gen_statem` and `open_stream` is a `call`).

```erlang
handle_info({push_data, Payload}, #{session := Session} = State) ->
    spawn(fun() ->
        {ok, Stream} = webtransport:open_stream(Session, bidi),
        webtransport:send(Session, Stream, Payload, fin)
    end),
    {ok, State}.
```

Or use the `{open_stream, bidi}` action and handle the new stream's ID in a
subsequent callback. Note: the action variant discards the stream ID, so use
the spawn approach when you need to send on the new stream immediately.