Ship

Old Docs · HTTP
On this page · 6 sections

HTTP

Ship's portable HTTP surface is ordinary data under Http: requests, responses, and headers that can be inspected, tested, transformed, and eventually adapted to more than one runtime. Target-owned APIs remain separate.

type DeployEvent = {
  event: String;
}

let build_request = pure fn(url: String) -> Http.Request,
  exn [ :encode(Wire.EncodeError) ]
{
  let request = Http.Request.post_json(url, DeployEvent { event = "deploy"; });
  return Http.Request.with_header(request, "accept", "application/json");
}

let not_ready = fn() -> Http.Response {
  return Http.Response.server_error("not ready");
}

Portable Values

  • Http.Header is { name: String; value: Bytes; }. make is the String convenience constructor; make_bytes and value preserve arbitrary field octets, while value_string validates UTF-8 explicitly.

  • Http.Request stores method, url, headers, and body.

  • Http.Response stores status, headers, and body.

    Bodies are Bytes, because HTTP is a wire protocol. String helpers such as with_string, string, html, and not_found convert String at the boundary with Bytes.of_string. JSON helpers use Json.encode, so encoding failures stay in the normal error channel:

let response = pure fn(user: User) -> Http.Response,
  exn [ :encode(Wire.EncodeError) ]
{
  return Http.Response.json(user);
}

These modules are not host APIs. They do not perform network I/O or read browser state.

Native Protocol Boundary

ship-http is a dependency-free sans-I/O HTTP/1.0 and HTTP/1.1 engine. It parses arbitrary fragmentation and owns request-target forms, persistence, fixed and chunked framing, trailers, informational responses, 100-continue, pipelining, close-delimited responses, and upgrade handoff. The same validated data model serializes outgoing messages.

The parser follows a strict RFC 9110/9112 profile. Duplicate Content-Length, Transfer-Encoding plus Content-Length, non-final or unsupported transfer codings, whitespace before field colons, obsolete folded fields, bare line endings, overflow, and premature EOF are protocol errors. There is no permissive fallback.

Sockets and lifecycle policy live above it: ship-net owns epoll/kqueue, timers, wakeups, and cancellation; ship-http-server owns bounded admission, ordered response queues, streaming backpressure, phase deadlines, and graceful drain. Request heads reach the bounded worker pool before their bodies finish; body chunks and trailers cross a bounded pull channel, so an application adapter may collect with an explicit limit without making buffering a server invariant.

Target Surfaces

api Name(Http) declares route and wire contracts. Browser programs can bind those contracts with client TodoClient : TodoApi { ... }; generated client facades lower to browser command data interpreted by the browser runtime.

Browser.Fetch is the lower-level browser-only command API. It remains separate from portable Http.Request/Http.Response values because browser program updates emit commands rather than awaiting network operations inline.

Native servers

server(Native) implements an api Name(Http) contract with a checked nested handler record:

server(Native) {
  api NotesApi;

  impl NotesApi {
    return {
      notes = {
        create = fn(request) {
          return Notes.create(request.body);
        };
      };
    };
  }
}

The checker derives each handler request from the route's typed path, query, and body, requires complete group/route coverage, and checks the response against the API contract. The native adapter consumes the same checked HttpRouteSemantics for JSON, ServerHTML, finite Datastar, and request-owned Datastar streams. Handlers remain ordinary Ship functions.

The route fact also closes over request ingress. A typed body comes from the HTTP body for ordinary routes and Datastar POST/PUT/PATCH actions. Datastar GET/DELETE actions receive their typed body from the protocol-owned query payload parameter. Bodyless routes have no request payload ingress. Native server adapters materialize bytes from this fact; they do not infer ingress from method strings or response representations.

Path, query, and body decode failures are request failures and return HTTP 400. Unhandled handler errors, provider failures, and response rendering failures remain classified server failures and return HTTP 500 unless a route contract defines a more specific response.

Generated stylesheets, the pinned Datastar module, and bound browser client artifacts are bundled as native server assets. Development may still put Vite in front for HMR; production native binaries do not depend on Node.

Server-driven pages use the separate ServerHTML and Datastar path. API-derived route facts construct checked Datastar actions without duplicating methods or URLs; the first action constructor accepts Unit path/query routes and a signal scope matching the body product. Finite handlers return typed HTML/patch plans; long-running actions use request-owned ordered streams that end and clean up with the request.

Outbound HTTP

Outbound network access is the explicit HttpClient service:

let notify = fn(url: String) -> Http.Response,
  exn [ :http(Http.ClientError) ],
  eff HttpClient
{
  return HttpClient.send(Http.Request.post_string(url, "deployed"));
}

The Native runtime provides the explicit HttpClient.NativeHttpClient. One runtime owns a long-lived origin-keyed client pool. It accepts every valid token method, preserves ordered repeated byte headers and absent versus empty bodies, treats HTTP status as a successful response, and validates HTTPS directly through Rustls.

The Rust-owned client boundary also accepts replayable request body sources. Known lengths use Content-Length; unknown lengths and sources with trailers use chunked framing. Reads, writes, TLS, cancellation, and redirects retain their exact failure phase and conservatively report whether request bytes may have reached the peer.

Transport failures raise :http(Http.ClientError). The payload carries a closed kind, operation phase, request_may_have_been_sent, and a diagnostic message. Redirects are explicit client policy; they are never inferred from status in portable Http.Response.

provide { HttpClient = HttpClient.NativeHttpClient; } {
  return HttpClient.send(request);
}

send is the bounded convenience operation. Streaming callers scope an opaque response handle:

with response = HttpClient.open_response(request) {
  print(Int.to_string(Http.IncomingResponse.status(response)));
  match Http.IncomingResponse.read(response) {
    | None -> print("complete")
    | Some(chunk) -> consume(chunk)
  }
};

Http.IncomingResponse implements With; leaving the block closes or returns the pooled connection. Chunk reads and trailers are valid only through that scoped handle. send collects through the same transport primitive and raises :body_limit instead of allocating without a bound.

Test, browser, Worker, and edge runtimes should provide their own implementations deliberately instead of receiving an implicit polyfill.

Adapters

Adapters between target host values and portable HTTP values should be explicit helpers when their runtime behavior is designed:

  • Browser adapters must preserve the command/outcome model instead of turning program update functions into direct network calls.

  • Native/server adapters should live with the server/runtime surface that owns sockets, streaming, timeouts, and cancellation.

    Portable library code should use Http.Request, Http.Response, and HttpClient; target entry points choose an explicit provider.