# http

> Expose functions as HTTP endpoints — registers the `http` trigger type and serves matched routes.

| field | value |
|-------|-------|
| version | 0.22.0 |
| type | binary |
| license | Apache-2.0 |
| repo | https://github.com/iii-hq/workers |
| supported_targets | x86_64-apple-darwin, aarch64-apple-darwin, x86_64-pc-windows-msvc, aarch64-pc-windows-msvc, x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-unknown-linux-musl, armv7-unknown-linux-gnueabihf |
| author | iii |

## installation

```sh
iii trigger compose::add worker=http@0.22.0
```

## configuration

```yaml
- concurrency_request_limit: 1024
  cors:
    allowed_methods:
      - GET
      - POST
      - PUT
      - DELETE
      - OPTIONS
    allowed_origins:
      - *
  default_timeout: 30000
  host: 127.0.0.1
  port: 3111
  webhook_listener: null
```

## dependencies

- `configuration` @ `latest`

## readme

# http

Exposes registered functions as HTTP endpoints. Any function bound to an
`http` trigger becomes a route — external clients call it with a plain HTTP
request instead of going through the iii SDK, which is what you want for
webhooks, browser clients, or any caller outside the engine's WebSocket
protocol. Routing, path params, per-route/global middleware, conditional
execution, CORS, and chunked streaming responses are all handled by this
worker; the function just receives an `HttpRequest` and returns a value.

## Install

```bash
iii trigger compose::add worker=http
```

`iii trigger compose::add` resolves the worker and its dependencies, writes
exact declarations to `worker-compose.yaml`, and reconciles the Compose project.

## Quickstart

Register a function and bind it to this worker's trigger type (`http` — see
[Trigger type](#trigger-type)) with `api_path` and `http_method`:

```rust
use iii_sdk::protocol::RegisterTriggerInput;
use iii_sdk::{InitOptions, RegisterFunction, errors::Error, register_worker};
use iii_http::types::HttpRequest;
use serde_json::{json, Value};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let iii = register_worker("ws://localhost:49134", InitOptions::default());

    iii.register_function(
        "orders::get",
        RegisterFunction::new_async(|req: HttpRequest| async move {
            Ok::<Value, Error>(json!({
                "status_code": 200,
                "body": { "order_id": req.path_params.get("id") },
            }))
        }),
    );

    iii.register_trigger(RegisterTriggerInput {
        trigger_type: "http".to_string(),
        function_id: "orders::get".into(),
        config: json!({ "api_path": "/orders/:id", "http_method": "GET" }),
        metadata: None,
    })?;

    tokio::signal::ctrl_c().await?;
    Ok(())
}
```

```bash
curl http://localhost:3111/orders/42
# {"order_id":"42"}
```

The function's return value becomes the response: `status_code` (default
200), `headers`, and `body`. Path segments prefixed with `:` (e.g. `:id`)
land in `req.path_params`; query string and headers arrive as
`req.query_params` / `req.headers`.

## Configuration

| Field | Default | Description |
|---|---|---|
| `port` | `3111` | TCP port the HTTP server binds to. `0` binds an OS-assigned ephemeral port. |
| `host` | `0.0.0.0` | Host/interface to bind. |
| `webhook_listener` | `null` (disabled) | Optional restricted listener; only explicitly opted-in routes. |
| `webhook_listener.host` | `127.0.0.1` | Restricted listener interface; does not inherit `host`. |
| `webhook_listener.port` | `3112` | Restricted listener port; separate from the normal/admin listener on 3111. |
| `default_timeout` | `30000` (ms) | Per-request timeout; on expiry the server returns `504`. |
| `cors.allowed_origins` | `[]` (permissive) | Allowed CORS origins. An empty list allows any origin. |
| `cors.allowed_methods` | `[]` (permissive) | Allowed CORS methods. An empty list allows any method. |
| `concurrency_request_limit` | `1024` | Maximum in-flight requests; requests over the limit wait for a slot. |
| `middleware[]` | `[]` | Global middleware, each `{ function_id, phase, priority }`, run on every route in ascending `priority` order before the handler. |

Configuration is owned by the `configuration` worker — edit it from the
console (**Configuration → Workers → http**) or seed it once via
`--config <file>.yaml` on first boot. `middleware` and `default_timeout`
hot-reload without a restart, as do `cors` and `concurrency_request_limit`.
Changing `host`/`port` or `webhook_listener` binds the new addresses before
stopping the old listeners. All required binds must succeed; otherwise both
old listeners and their live configuration remain unchanged. Set
`webhook_listener: null` to disable it live. Shutdown drains both listeners
and serializes with reload; a late reload cannot restart a stopped server.

## Trigger type

This worker always registers the `http` trigger type. Bind a function to it
with:

| Field | Required | Default | Description |
|---|---|---|---|
| `api_path` | yes | — | Route path, e.g. `/orders/:id`. Segments prefixed with `:` are extracted into `path_params`. |
| `http_method` | no | `GET` | HTTP method to match. |
| `public_webhook` | no | `false` | Also expose this route on the restricted listener. Omitted/false routes are normal-listener only. |
| `condition_function_id` | no | — | Function invoked first; if it returns a falsy value the request is rejected with `422`. |
| `middleware_function_ids` | no | `[]` | Per-route middleware, invoked before the handler in list order, in addition to any global middleware. |

Functions can stream their response: write to `req.response` (a
`StreamChannelRef`) with a `ChannelWriter` to send `set_status` /
`set_headers` control frames and body chunks for a chunked HTTP response.
Returning a non-null value instead yields a regular buffered response built
from `{ status_code, headers, body }`.

### Restricted webhook listener

Enable the optional listener in the existing `http` configuration:

```yaml
host: 127.0.0.1
port: 3111
webhook_listener:
  host: 127.0.0.1
  port: 3112
```

Register an explicit opt-in using the **same** `http` trigger provider:

```json
{"api_path":"/webhooks/github","http_method":"POST","public_webhook":true}
```

Only that opted-in route is eligible on 3112; it remains available on 3111.
Do not expose 3111 through a public proxy/tunnel. The restricted listener
mounts no Console, engine, or admin endpoints. Its private paths return 404
(including OPTIONS); methods not opted in for an eligible path return 405
with only public methods in `Allow`. Admission runs before CORS. It performs
no URL decoding, repeated/trailing-slash normalization, HEAD-to-GET aliasing,
or method-override handling. Parameter routes are still supported: opting
one in deliberately exposes every value matched by its parameter segments,
never a more specific private handler. Existing normal-listener behavior is
unchanged. Route removal or opt-in revocation applies to subsequent requests.

Both listeners share the route registry, configuration and layered router;
global/per-route middleware, conditions, timeouts, concurrency and the 16 MiB
body limit still apply. A bind collision, including using the normal address
for the restricted listener, fails safely rather than combining them.

**Signature verification:** read `HttpRequest.request_body` using the SDK
`ChannelReader`; it contains the exact received bytes (UTF-8, JSON whitespace,
newlines and binary data included). Never compute HMAC from `body`, which is
only a parsed JSON convenience projection. Header names follow HTTP's
case-insensitive/lowercase representation; ASCII values such as
`x-hub-signature-256` and `x-github-delivery` pass through unchanged. The
existing header map is not a raw HTTP-header archive: duplicate headers and
non-text values are not represented byte-for-byte. This listener does not
validate signatures itself; the opted-in handler must authenticate deliveries.

### Local tests

`tests/webhook_listener.rs` uses the shared harness's loopback fake engine
and real SDK binary channels, without any external service or skipped tests:

```bash
CARGO_TARGET_DIR=target-http-local cargo test --locked --test webhook_listener
cargo fmt --all -- --check
CARGO_TARGET_DIR=target-http-local cargo clippy --all-targets --all-features --locked -- -D warnings
```

Other `e2e_*` suites retain their existing connect-or-skip engine requirement.

### Requires removing the legacy built-in HTTP service

The legacy built-in HTTP service also owns the `http` trigger type. Two owners
of the same trigger type on one engine collide — whichever registers last
wins — so this worker requires it to be absent: omit it from the
engine's `config.yaml` (a config that doesn't list a worker won't run it).

On boot, this worker queries the engine for connected workers and refuses to
start with a clear error if the legacy built-in is still active, so a stale config
fails loudly instead of silently racing the built-in worker for ownership of
`http`.

## skills

This version ships 1 skill file for agents, also browsable on the worker page:

- [`SKILL.md`](https://workers.iii.dev/workers/http?tab=skills&skill=SKILL.md&version=0.22.0)

### SKILL.md

# http

The `http` worker exposes registered functions as HTTP endpoints, turning a
function into a route that browsers, webhooks, and external clients can call
without using the iii SDK. It exposes no callable `http::*` functions; its
entire surface is the `http` trigger type, bound through a worker SDK trigger
registration such as `iii.registerTrigger({ type: 'http', function_id, config })`.

Install it with `iii trigger compose::add worker=http`. The legacy in-engine HTTP service must not
run on the same engine because it also owns the `http` trigger type. Remove the
legacy built-in from the engine config before starting this worker; the standalone
worker refuses to boot when the builtin is active.

Incoming requests are matched by HTTP method and path. The invoked handler
receives method, path, headers, query params, extracted `:path` params, parsed
body, trigger metadata, and request/response stream channels. Returning a JSON
value yields a buffered response; writing to the response channel yields a
chunked streaming response.

## When to Use

- A webhook, browser, mobile app, or third-party service needs to call an iii
  function over plain HTTP.
- You need route matching with path params such as `/orders/:id`.
- A route should have per-route middleware or a condition function before the
  handler runs.
- You need CORS, HTTP status codes, headers, JSON bodies, or chunked streaming
  responses at the edge of the engine.
- Several external routes should dispatch into normal iii functions while
  keeping their handler code in workers you control.

## Boundaries

- No callable functions are exposed. Never invoke `http::*`; bind the `http`
  trigger type to one of your own functions.
- This is inbound HTTP. For outbound network requests from an agent or worker,
  use `web::fetch` instead.
- This is not a scheduler or event queue. For clock-based execution, use
  `cron`; for data-change reactions, use the relevant state, stream, queue, or
  database worker.
- The legacy in-engine HTTP service cannot run beside this standalone worker on
  the same engine because both own the `http` trigger type.
- Route shapes with the same method and equivalent path structure conflict:
  `/orders/:id` and `/orders/:order_id` are the same shape.

## Reactive triggers

Bind an `http` trigger when a handler should run automatically for each
matching HTTP request. The handler runs through the engine as a normal function
invocation, and the worker turns the handler's return value or stream writes
back into an HTTP response.

Reach for it when:

- A public or internal API endpoint should call a registered function.
- A webhook provider should invoke an iii workflow without a custom server.
- Middleware should authenticate, enrich, or reject requests before the route
  handler runs.
- A handler should stream chunks to the client instead of returning one
  buffered body.

### How to bind

1. Register a handler: `iii.registerFunction('orders::get', handler)`.
2. Register the trigger:

```typescript
iii.registerTrigger({
  type: 'http',
  function_id: 'orders::get',
  config: {
    api_path: '/orders/:id',
    http_method: 'GET',
    // condition_function_id and middleware_function_ids are optional.
  },
})
```

`api_path` is required. `http_method` defaults to `GET`. Path segments prefixed
with `:` are extracted into `path_params`; query string values arrive in
`query_params`; request headers arrive in `headers`.

Trigger config:

| Field | Required | Default | Description |
|---|---|---|---|
| `api_path` | yes | - | Route path such as `/orders/:id`; `:name` segments become path params. |
| `http_method` | no | `GET` | HTTP method to match. |
| `condition_function_id` | no | - | Function invoked before middleware and handler; a falsy result rejects the request with `422`. |
| `middleware_function_ids` | no | `[]` | Per-route middleware functions, run before the handler in list order. |

Handler return values become buffered responses with `status_code`, `headers`,
and `body`; `status_code` defaults to `200` and `body` defaults to an empty
object. For streaming, write `set_status` and `set_headers` control messages
plus body chunks to the request's `response` channel; return `null` or no final
buffered body from the handler.

Configure the server through the central configuration entry for worker
`http`:

```yaml
port: 3111
host: 0.0.0.0
default_timeout: 30000
concurrency_request_limit: 1024
cors:
  allowed_origins: []
  allowed_methods: []
middleware: []
```

Global `middleware` runs on every route in ascending `priority` order. Per-route
`middleware_function_ids` run after global middleware and before the handler.
`middleware` and `default_timeout` hot-reload; `port`, `host`, CORS, and
`concurrency_request_limit` take effect on the next restart.

## api reference

```json
{
  "functions": [
    {
      "description": "Returns this HTTP instance's configuration entry ID, without its value.",
      "metadata": {
        "internal": true
      },
      "name": "http::configuration-id",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "title": "ConfigurationIdentityRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "id": {
            "type": "string"
          }
        },
        "required": [
          "id"
        ],
        "title": "ConfigurationIdentityResponse",
        "type": "object"
      }
    }
  ],
  "triggers": []
}
```
