Repository navigation
0.3.0
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 anintof milliseconds.Spindle.nowis aPtime.t Dep.t, as isRequest.now. Spindle.epoch_ms→Spindle.clock_now. The~nowofserve,Server.run,Server.serve_onandRequest.makeisunit -> Ptime.t.Test.call,Test.Browser.call,Test.eventsandTest.websockettake?now:Ptime.t(Ptime.epochby default).Session.entry:created_ms,seen_ms,expires_ms→created,seen,expires;Sessionfunctions andCookie.Named.unsealtake~now:Ptime.t.Codec.instantis aPtime.t Codec.t;Conditional's validators, and a file'sLast-Modified, hold aPtime.t.spindle.http:Date.parseisnow:Ptime.t -> string -> Ptime.t option;Write.datetakes aPtime.t; a trace span holdsstartandduration.Spindle_postgres.instantis gone: useRowtype.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_sdrop_s; zero is refused.Returns.events,Returns.websocket,Response.events,Websocket.run_serverandrun_clienttake?keep_aliveas 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.retryandEvent_stream.retrytake~ms; a read event'sretry→retry_ms.Cookie.signedandCookie.encryptedrequire~max_age_s; without one a cookie was accepted forever.
Dependencies
- OCaml 5.5 or later is required.
spindleneeds wiretype 0.3.0 and ptime;spindle_postgresneeds rowtype 0.4.0 and postgres-eio 0.3.0.- If you use wiretype directly:
instantis aPtime.t,durationanMtime.Span.t,dicttakes~keyand~value.
Errors
Spindle_client:Timed_out→Timeout.Body.error:Busy→Over_budget; inSpindle_http.Framing,`Broken→Unreadable. Still503 busy.spindle_postgres:`Closed→`Connection `Closed;`Busy→`Pool `Busy, and a closed pool is`Pool `Closedat once.spindle_postgreslogs on the sourcespindle_postgres, wasspindle.postgres.- A fiber cancelled during
Pool.use,queryortransactionstops at once; its connection is remade. - In
spindle.client, aBody.readafter a failed one answers that failure again; over HTTP it waited again.
Reading requests
- A query is read as a form:
?q=a,bis one valuea,b, not split at commas;?qis the empty value, not absent. - A path segment, query parameter, header or cookie that is not UTF-8 is refused
400at 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 emptyHostis refused400. Sec-Fetch-Siteis read as a Structured Field token:same-origin;x=1is same-origin,Same-Originis not.
Routes and answers
- A handler answering a framework code, e.g.
Refusal.not_found, declares it in~refuses;Test.callraises on an undeclared one. - Two dependencies reading one input are one OpenAPI parameter; reading it with different codecs makes
App.makerefuse. - A JSON request body is
"required": trueunless every member is optional. - A plain
OPTIONSis204withAllow, andOPTIONS *is204, where they were405or404; a405'sAllowlistsOPTIONS. Response.makeat204or304sends 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
Filesserves HTML, SVG and XML as attachments withContent-Security-Policy: sandbox;~inline_active:trueshows them inline, still sandboxed.Staticskips dot-names unless~dotfiles:true;.well-knownis read only at the root.Rate.limitkeys an IPv6 address by its /64, and an IPv4-mapped one as IPv4.Compress.makeraises for a~leveloutside 1–9 or a negative~min_bytes;Rate.createfor~limitor~burstbelow 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_clino longer linkseio_main.Spindle_http.Field.lineandField.unfoldare gone.
New
Spindle.until ~now deadlineis the span until an instant, zero once passed:Alarm.set ~after:(Spindle.until ~now deadline).Spindle_http.Instantreads an instant from a clock and adds a span to one.Spindle.client_goneresolves once the client has gone;Broadcast.next ~untilanswersNonethen.Codec.custom ?list:truereads a header of your own sent on several lines as one list.Spindle_postgres.error_to_stringwords 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.
StaticandFilessendX-Content-Type-Options: nosniffon every file.
Fixed
- A client that stops reading no longer holds a connection forever: its last bytes get
send_timeout. Spindle_client.websocketreturns once a send answersLost, where it could wait forever.Spindle_client.streamtakes an answer whose head arrived just as its deadline passed.Spindle_clientreads a quotedKeep-Alive: timeout="5".Response.stream ~lengthbelow zero answers500, where it wroteContent-Length: -1.- A session cookie's
Max-Ageis the session's absolute limit rounded up, so the cookie outlives the session. Server.run ~hostreadslocalhostin any case as both loopback addresses.- On Linux,
localhostfails as in use when another process holds the port on[::1]. Server.runchecks its limits before it binds and callsready.- A raising WebSocket handler is logged at
errorwith what was raised and where, notinfo. - 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 asLost, wherewssraised.Websocket: a send cancelled mid-frame ends the socket asLost, where later sends corrupted the frame.spindle_postgres:Pool.checkfails once the pool is closed, where/readyzanswered200.spindle_postgres:Session.schemaworks for table names over 52 bytes.spindle_postgres:Sessionquotes its table's name, souserororderworks.spindle_postgres: an exhausted pool's503is logged as a warning, not an error.spindle_postgres: a cancelledPool.runpasses the cancellation on, notFun.Finally_raised.spindle_postgreslists postgres-eio as a dependency, so installing from source no longer warns.spindle_cli:api checknames every stale or missing file, and--helplists the right exit statuses.Test.app's refusal of aFiles.directoryroute says to useFiles.route.- The examples atop
SpindleandSpindle.Returnstype-check.