Skip to content

0.3.0

Choose a tag to compare

@zaiev zaiev released this 11 Oct 22:30
· 115 commits to main since this release

Spindle types its time: every instant is a Ptime.t and every duration an Mtime.Span.t, alongside stricter HTTP reading, safer static files and many fixes.

Upgrading

Instants

  • Every instant is a Ptime.t, where it was an int of milliseconds. Spindle.now is a Ptime.t Dep.t, as is Request.now.
  • Spindle.epoch_ms → Spindle.clock_now. The ~now of serve, Server.run, Server.serve_on and Request.make is unit -> Ptime.t.
  • Test.call, Test.Browser.call, Test.events and Test.websocket take ?now:Ptime.t (Ptime.epoch by default).
  • Session.entry: created_ms, seen_ms, expires_ms → created, seen, expires; Session functions and Cookie.Named.unseal take ~now:Ptime.t.
  • Codec.instant is a Ptime.t Codec.t; Conditional's validators, and a file's Last-Modified, hold a Ptime.t.
  • spindle.http: Date.parse is now:Ptime.t -> string -> Ptime.t option; Write.date takes a Ptime.t; a trace span holds start and duration.
  • Spindle_postgres.instant is gone: use Rowtype.instant.
  • A file's entity tag is made to the nanosecond, so it may change once.

Durations

  • Every duration is an Mtime.Span.t: ~send_timeout_s:60. → ~send_timeout:Mtime.Span.(60 * s). Defaults are unchanged.
  • serve, Server.run, Server.serve_on: ?head_timeout_s, ?idle_timeout_s, ?body_timeout_s, ?send_timeout_s, ?linger_s, ?drain_s drop _s; zero is refused.
  • Returns.events, Returns.websocket, Response.events, Websocket.run_server and run_client take ?keep_alive as a span.
  • Response.stream's keep-alive is a record { quiet; filler }, where it was a pair.
  • Spindle_client.create / run: ?timeout_s, ?idle_s → ?timeout, ?idle_timeout; calls take ?timeout, stream/events ?read_timeout, websocket ?keep_alive.
  • Alarm.create ?slack_ms → ?slack; Alarm.set ~in_ms → ~after.
  • Rate.create ~per_s → ~per; Session.create ~idle_s ~absolute_s → ~idle ~absolute.
  • spindle_postgres's timeouts and waits (?statement_timeout, ?connect_timeout, ?wait, ?max_lifetime, ?idle_check, …) are spans.

Numbers on the wire stay seconds, named so

  • Cookie.make ?max_age → ?max_age_s; Refusal.busy ?retry_after → ?retry_after_s.
  • Refusal.rate_limited ~retry_after:30 () → Refusal.rate_limited ~retry_after_s:30.
  • Event.retry and Event_stream.retry take ~ms; a read event's retry → retry_ms.
  • Cookie.signed and Cookie.encrypted require ~max_age_s; without one a cookie was accepted forever.

Dependencies

  • OCaml 5.5 or later is required.
  • spindle needs wiretype 0.3.0 and ptime; spindle_postgres needs rowtype 0.4.0 and postgres-eio 0.3.0.
  • If you use wiretype directly: instant is a Ptime.t, duration an Mtime.Span.t, dict takes ~key and ~value.

Errors

  • Spindle_client: Timed_out → Timeout.
  • Body.error: Busy → Over_budget; in Spindle_http.Framing, `Broken → Unreadable. Still 503 busy.
  • spindle_postgres: `Closed → `Connection `Closed; `Busy → `Pool `Busy, and a closed pool is `Pool `Closed at once.
  • spindle_postgres logs on the source spindle_postgres, was spindle.postgres.
  • A fiber cancelled during Pool.use, query or transaction stops at once; its connection is remade.
  • In spindle.client, a Body.read after a failed one answers that failure again; over HTTP it waited again.

Reading requests

  • A query is read as a form: ?q=a,b is one value a,b, not split at commas; ?q is the empty value, not absent.
  • A path segment, query parameter, header or cookie that is not UTF-8 is refused 400 at its place.
  • A header sent on several lines is one field: list codecs join the lines; any other codec refuses it as malformed.
  • A path or * target with an empty Host is refused 400.
  • Sec-Fetch-Site is read as a Structured Field token: same-origin;x=1 is same-origin, Same-Origin is not.

Routes and answers

  • A handler answering a framework code, e.g. Refusal.not_found, declares it in ~refuses; Test.call raises on an undeclared one.
  • Two dependencies reading one input are one OpenAPI parameter; reading it with different codecs makes App.make refuse.
  • A JSON request body is "required": true unless every member is optional.
  • A plain OPTIONS is 204 with Allow, and OPTIONS * is 204, where they were 405 or 404; a 405's Allow lists OPTIONS.
  • Response.make at 204 or 304 sends no content type unless given one.
  • A graceful stop waits, within drain, for a stream of known length, such as a file, to finish.

Static files and limits

  • Files serves HTML, SVG and XML as attachments with Content-Security-Policy: sandbox; ~inline_active:true shows them inline, still sandboxed.
  • Static skips dot-names unless ~dotfiles:true; .well-known is read only at the root.
  • Rate.limit keys an IPv6 address by its /64, and an IPv4-mapped one as IPv4.
  • Compress.make raises for a ~level outside 1–9 or a negative ~min_bytes; Rate.create for ~limit or ~burst below one, or a zero ~per.

CLI and spindle.http

  • Spindle_cli.run ~name ~app () → Eio_main.run @@ fun env -> Spindle_cli.run ~name ~app env; spindle_cli no longer links eio_main.
  • Spindle_http.Field.line and Field.unfold are gone.

New

  • Spindle.until ~now deadline is the span until an instant, zero once passed: Alarm.set ~after:(Spindle.until ~now deadline).
  • Spindle_http.Instant reads an instant from a clock and adds a span to one.
  • Spindle.client_gone resolves once the client has gone; Broadcast.next ~until answers None then.
  • Codec.custom ?list:true reads a header of your own sent on several lines as one list.
  • Spindle_postgres.error_to_string words any error for a log, with no values in it.
  • A postgres statement or idle-transaction bound is rounded up to whole ms; a span of zero lifts it.
  • Static and Files send X-Content-Type-Options: nosniff on every file.

Fixed

  • A client that stops reading no longer holds a connection forever: its last bytes get send_timeout.
  • Spindle_client.websocket returns once a send answers Lost, where it could wait forever.
  • Spindle_client.stream takes an answer whose head arrived just as its deadline passed.
  • Spindle_client reads a quoted Keep-Alive: timeout="5".
  • Response.stream ~length below zero answers 500, where it wrote Content-Length: -1.
  • A session cookie's Max-Age is the session's absolute limit rounded up, so the cookie outlives the session.
  • Server.run ~host reads localhost in any case as both loopback addresses.
  • On Linux, localhost fails as in use when another process holds the port on [::1].
  • Server.run checks its limits before it binds and calls ready.
  • A raising WebSocket handler is logged at error with what was raised and where, not info.
  • A chunked body's extensions are checked (RFC 9112 §7.1.1), a server's and a client's.
  • A refused request's log line no longer quotes the bytes it was sent.
  • Trace: a cancelled span ends with its status unset, not as an error.
  • Websocket: TLS failing under an open socket ends it as Lost, where wss raised.
  • Websocket: a send cancelled mid-frame ends the socket as Lost, where later sends corrupted the frame.
  • spindle_postgres: Pool.check fails once the pool is closed, where /readyz answered 200.
  • spindle_postgres: Session.schema works for table names over 52 bytes.
  • spindle_postgres: Session quotes its table's name, so user or order works.
  • spindle_postgres: an exhausted pool's 503 is logged as a warning, not an error.
  • spindle_postgres: a cancelled Pool.run passes the cancellation on, not Fun.Finally_raised.
  • spindle_postgres lists postgres-eio as a dependency, so installing from source no longer warns.
  • spindle_cli: api check names every stale or missing file, and --help lists the right exit statuses.
  • Test.app's refusal of a Files.directory route says to use Files.route.
  • The examples atop Spindle and Spindle.Returns type-check.