char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
aedf431
main
HTTP serving
serve from avril:std serves plaintext HTTP/1.1 over TCP or Unix sockets:
import { serve } from "avril:std" ; const server = serve ({ hostname :"127.0.0.1" , port :8000 }, ( request , info ) => { return Response . json ({ path :new URL ( request . url ). pathname , peer :info . remoteAddr . hostname , }); }); await server . finished ;
serve(handler) defaults to 127.0.0.1:8000. Bind to 0.0.0.0 or [::] explicitly
for access from other hosts. Port 0 selects an available port; server.addr
contains the actual TCP address. Binding is synchronous, so permission errors and
occupied ports throw from serve. onListen(addr) runs once after binding; avril
does not print a startup message.
CLI entry modules
avril serve owns the listener on behalf of a module that exports a default object
with a fetch(request, info) function:
import type { ServeModule } from "avril:std" ; export default { fetch ( request ) { return new Response ( new URL ( request . url ). pathname ); }, } satisfies ServeModule ;
avril serve-0 --hostname=127.0.0.1 --port=8000 app.tsavril serve-0 --unix=/tmp/avril.sock app.ts
TCP defaults to 127.0.0.1:8000; port 0 selects an available port. --unix cannot
be combined with --hostname or --port. The command prints its bound address to
stderr. It evaluates the actual entry module, including top-level await, before
validating its default export and binding. The object is preserved as this when
calling fetch.
The CLI listener uses host authority, not guest net or filesystem grants. Accepted
requests and WebSocket upgrades therefore work even with -0. This does not
grant guest code authority to use fetch, connect outbound WebSockets, bind other
listeners, or access files. Without -0, the ordinary CLI capability defaults still
apply. Package, import, environment-file, and permission options work as for run.
Workers never inherit the CLI listener exemption.
Ctrl+C (or SIGTERM on Unix) starts graceful shutdown. A second shutdown signal forcibly closes active connections, including upgraded WebSockets, and interrupts CPU-bound guest handlers. Unix socket cleanup runs when the listener stops. Unrelated guest timers and workers do not prevent the CLI from exiting after shutdown.
Unix listeners
const server = serve ({ path :"/tmp/avril.sock" }, ( request , info ) => { return Response . json ({ peer :info . remoteAddr . path }); });
Programmatic Unix listeners require filesystem write authority at the socket
path, not net or filesystem read authority. Relative paths resolve against the
runtime's working directory. path cannot be combined with hostname or port.
server.addr is { transport: "unix", path }; an unnamed peer has path: null.
HTTP URLs still use the Host header, with localhost as the HTTP/1.0 fallback.
An existing socket, file, or symlink is never removed to make room for a listener. Cleanup checks the socket's device/inode identity before unlinking on shutdown, preserving entries that have replaced it. Cleanup does not require a fresh guest write grant.
Linux pins the parent directory and created filesystem inode, using
/proc/self/fd for binding. Other Unix platforms use ordinary path-based binding
and best-effort identity-checked cleanup. That fallback can race with directory or
symlink changes between authorization, binding, and cleanup; it does not provide
Linux's filesystem race resistance. Non-Unix platforms do not support Unix listeners.
Requests and responses
Handlers may return a Response or a promise of one. Requests on different
connections run concurrently, and HTTP/1.1 connections support keep-alive.
request.url uses the client's Host header and the request target; do not trust it
as a canonical origin or assume it names the listener. Request headers are immutable.
info.remoteAddr contains the TCP or Unix peer, not an address from proxy headers.
Request and response bodies use ordinary web streams with bounded native queues and backpressure, without requiring whole-body buffering. For example:
serve ({ port :8000 }, ( request ) => new Response ( request . body ));
The request's signal aborts when the client disconnects or the server forcibly closes its connection. A handler that wants to abandon work should observe that signal. Request bodies should be consumed during handling, not retained beyond the response's lifetime. Canceling a request body stops JavaScript reads without canceling the response.
Responses obey HEAD and null-body status semantics, preserve multiple Set-Cookie
headers, and encode header values as HTTP bytes. HEAD responses discard and cancel
the supplied body without reading it. Hyper controls connection framing;
Connection, Transfer-Encoding, Upgrade, Trailer, Keep-Alive, and
Proxy-Connection response headers are rejected. Explicit Content-Length values
must be valid and match buffered bodies; streamed bodies must fulfill the declared
length. Custom status text is not used.
Thrown handler errors, rejected promises, and invalid or already-used responses
produce a generic 500 response. Without onError, the error is reported via the
global error event and printed to stderr unless canceled. Supply onError(error)
to return a custom response; if it fails, its error is reported and the generic 500
is used. Failures after response headers have been sent are also reported and close
the connection rather than attempting a second response.
File responses
serveFile(request, source, options?) from avril:std returns a Promise<Response>
for a regular file. The source may be a path, file URL, or open FileHandle.
It works with both avril serve and programmatic serve:
import { serveFile } from "avril:std" ; export default { fetch ( request ) { return serveFile ( request , new URL ( "./index.html" , import . meta. url ), { headers :{ "Cache-Control" :"public, max-age=60" }, }); }, };
Paths may be strings or file URLs; relative strings use the runtime's working
directory. Normal filesystem read authority is required, including with the CLI
listener. For example, avril serve -0 --allow-read=./index.html app.ts grants
access to that file without granting guest network authority.
Passing a FileHandle transfers ownership immediately by default: the handle and
its readable/writable streams become unusable, and the response owns cleanup,
including for HEAD and other bodyless responses. The handle must be readable,
open, and idle; outstanding native operations reject with errors.Busy without
consuming it. Invalid options and already-aborted requests also leave it intact.
Once ownership transfers, later preparation failures still close the file.
Use { consume: false } to keep a handle for repeated requests:
import { open , serve , serveFile } from "avril:std" ; const file = await open ( "./video.mp4" ); const server = serve ( request => serveFile ( request , file , { consume :false })); await server . finished ; file . close ();
Each response acquires its own native file reference before preparation, so closing
the original does not interrupt active responses. Serving ignores the handle's
current position and does not change its cursor; concurrent responses apply their
own ranges independently. The original opening filename supplies the MIME hint,
without reopening that path. Normal read authority is still checked on acquisition
and during streaming. consume defaults to true and has no effect on path sources.
Retained-handle serving is supported on Unix and Windows; other platforms reject
consume: false for handles with errors.NotSupported.
The helper supplies Content-Type from the filename (unknown extensions use
application/octet-stream), Content-Length, Accept-Ranges, Last-Modified, and a
weak ETag derived from size and modification time, when modification time is
available. These validators use the opened file's metadata, not a content hash.
Modification time's filesystem precision limits change detection.
options.headers adds response headers; Content-Type and ETag override the
defaults. A caller-supplied strong ETag must identify the exact file
representation. Length, range, and Last-Modified headers are controlled by the
helper.
Protocol handling and file reads run in Rust. Returning an untouched file body
from its request's handler lets the HTTP server stream it directly, without a
per-chunk JS round trip. Reading, cloning, or transforming the body uses ordinary
JS streams backed by the same native file reader. This currently uses buffered
I/O, not sendfile: Hyper's byte-buffer body interface does not expose a native
file-region transport.
GET streams the file with bounded reads and backpressure. HEAD returns the same full-file metadata without a body and ignores Range. If-Match and If-Unmodified-Since can produce 412; If-None-Match and If-Modified-Since can produce 304, with ETag conditions taking precedence over their date counterparts. Range supports a single byte interval, open-ended interval, or suffix, producing 206 or 416. If-Range requires a matching strong ETag or exact Last-Modified date; the default weak ETag cannot satisfy it. Malformed ranges, unsupported units, and multi-range requests are ignored, returning the full representation.
Missing files and non-regular paths return 404; methods other than GET and HEAD
return 405 with Allow: GET, HEAD. Permission and other filesystem errors reject
and follow the handler's normal error policy. File handles close on completion,
body cancellation, request abort, or failure; consume or cancel a response body
if you do not return it. Read authority is rechecked during streaming. The helper
reads from the opened handle but does not snapshot the contents: publish files by
atomic replacement rather than editing them in place. Growth does not extend a
response beyond its original length, and premature EOF errors the body.
This is not a directory server: it does not map request URLs to paths, generate
indexes, or enforce a document root. If you select files from an untrusted URL,
validate that selection and consider symlinks as well as .. traversal.
WebSocket upgrades
upgradeWebSocket accepts an original incoming request and returns an ordinary
WebSocket plus a special handshake response. Return that response from the same
request's handler:
import { upgradeWebSocket } from "avril:std" ; export default { fetch ( request ) { const { socket, response} = upgradeWebSocket ( request ); socket . onmessage = ( event ) => socket . send ( event . data ); return response ; }, };
This also works inside programmatic serve handlers and over Unix sockets.
The socket starts in CONNECTING; open fires after the 101 response has been sent.
Messages, binaryType, bufferedAmount, errors, and closing use the existing WebSocket
API. Incoming client frames must be masked; server frames are unmasked. Messages
are limited to 16 MiB, including fragments, with bounded read queues.
To select a subprotocol, pass { protocol: "chat" }; it must be a valid token offered
by the client. The HTTP/1.1 GET handshake, upgrade headers, version, nonce, and
subprotocol selection are validated before reserving an upgrade. Outbound WebSocket
capabilities are not required for accepted sockets. Check Origin and application
credentials yourself; accepting a socket does not authenticate its peer.
Constructed requests and clones cannot be upgraded. Preparing an upgrade twice, returning its response from another request, or reusing a completed response fails. If the upgrade response is not returned, the reserved socket receives error/close instead of keeping the server alive indefinitely. Handshake headers are immutable; permessage-deflate and other WebSocket extensions are not negotiated.
Shutdown
server.shutdown() stops accepting connections and waits for active responses,
including streamed bodies and accepted WebSockets. It is idempotent and returns
server.finished. Close accepted WebSockets yourself to complete graceful shutdown.
Calling it inside a handler is allowed, but do not await it there: shutdown is
waiting for that handler's response. An indefinitely running response can keep a
graceful shutdown pending indefinitely.
For immediate shutdown, pass an AbortSignal and abort it:
const controller = new AbortController (); const server = serve ({ port :8000 , signal :controller . signal }, handler ); controller . abort (); await server . finished ;
This also interrupts a graceful shutdown already in progress. An already-aborted
signal throws its reason without opening a listener. Listener failures reject
finished; individual malformed requests or broken connections do not terminate
the server. A worker's listeners close when the worker is terminated.
Permissions and scope
Programmatic TCP listening requires net authority for the requested hostname and
port. For port 0, both port 0 and the selected port are checked, so a
hostname-wide grant is usually appropriate. Accepting a connection and dispatching each request recheck
the listener's hostname and actual port, including on existing keep-alive
connections. Sending response headers, each HTTP body read/write, and preparing
WebSocket upgrades also recheck that authority. Unix listeners perform the equivalent
checks against write authority for their socket path. Worker listeners use the
worker's narrowed capabilities. Binding a wildcard address exposes the listener on
every matching interface; its grant must name that wildcard address.
TLS, HTTP/2, raw connections, CONNECT tunnels, and trailers are not implemented by this API. CONNECT receives 501. Use a reverse proxy for HTTPS.
1# HTTP serving 2 3`serve` from `avril:std` serves plaintext HTTP/1.1 over TCP or Unix sockets: 4 5``` ts 6import { serve } from "avril:std"; 7 8const server = serve({ hostname: "127.0.0.1", port: 8000 }, (request, info) => { 9return Response.json({ 10path: new URL(request.url).pathname, 11peer: info.remoteAddr.hostname, 12}); 13}); 14 15await server.finished; 16``` 17 18`serve(handler)` defaults to `127.0.0.1:8000`. Bind to `0.0.0.0` or `[::]` explicitly 19for access from other hosts. Port `0` selects an available port; `server.addr` 20contains the actual TCP address. Binding is synchronous, so permission errors and 21occupied ports throw from `serve`. `onListen(addr)` runs once after binding; avril 22does not print a startup message. 23 24## CLI entry modules 25 26`avril serve` owns the listener on behalf of a module that exports a default object 27with a `fetch(request, info)` function: 28 29``` ts 30import type { ServeModule } from "avril:std"; 31 32export default { 33fetch(request) { 34return new Response(new URL(request.url).pathname); 35}, 36} satisfies ServeModule; 37``` 38 39``` sh 40avril serve -0 --hostname=127.0.0.1 --port=8000 app.ts 41avril serve -0 --unix=/tmp/avril.sock app.ts 42``` 43 44TCP defaults to `127.0.0.1:8000`; port `0` selects an available port. `--unix` cannot 45be combined with `--hostname` or `--port`. The command prints its bound address to 46stderr. It evaluates the actual entry module, including top-level await, before 47validating its default export and binding. The object is preserved as `this` when 48calling `fetch`. 49 50The CLI listener uses host authority, not guest `net` or filesystem grants. Accepted 51requests and WebSocket upgrades therefore work even with `-0`. This does **not** 52grant guest code authority to use `fetch`, connect outbound WebSockets, bind other 53listeners, or access files. Without `-0`, the ordinary CLI capability defaults still 54apply. Package, import, environment-file, and permission options work as for `run`. 55Workers never inherit the CLI listener exemption. 56 57Ctrl+C (or SIGTERM on Unix) starts graceful shutdown. A second shutdown signal 58forcibly closes active connections, including upgraded WebSockets, and interrupts 59CPU-bound guest handlers. Unix socket cleanup runs when the listener stops. Unrelated 60guest timers and workers do not prevent the CLI from exiting after shutdown. 61 62## Unix listeners 63 64``` ts 65const server = serve({ path: "/tmp/avril.sock" }, (request, info) => { 66return Response.json({ peer: info.remoteAddr.path }); 67}); 68``` 69 70Programmatic Unix listeners require filesystem **write** authority at the socket 71path, not `net` or filesystem read authority. Relative paths resolve against the 72runtime's working directory. `path` cannot be combined with `hostname` or `port`. 73`server.addr` is `{ transport: "unix", path }`; an unnamed peer has `path: null`. 74HTTP URLs still use the Host header, with `localhost` as the HTTP/1.0 fallback. 75 76An existing socket, file, or symlink is never removed to make room for a listener. 77Cleanup checks the socket's device/inode identity before unlinking on shutdown, 78preserving entries that have replaced it. Cleanup does not require a fresh guest 79write grant. 80 81Linux pins the parent directory and created filesystem inode, using 82`/proc/self/fd` for binding. Other Unix platforms use ordinary path-based binding 83and best-effort identity-checked cleanup. That fallback can race with directory or 84symlink changes between authorization, binding, and cleanup; it does not provide 85Linux's filesystem race resistance. Non-Unix platforms do not support Unix listeners. 86 87## Requests and responses 88 89Handlers may return a `Response` or a promise of one. Requests on different 90connections run concurrently, and HTTP/1.1 connections support keep-alive. 91`request.url` uses the client's Host header and the request target; do not trust it 92as a canonical origin or assume it names the listener. Request headers are immutable. 93`info.remoteAddr` contains the TCP or Unix peer, not an address from proxy headers. 94 95Request and response bodies use ordinary web streams with bounded native queues 96and backpressure, without requiring whole-body buffering. For example: 97 98``` ts 99serve({ port: 8000 }, (request) => new Response(request.body)); 100``` 101 102The request's signal aborts when the client disconnects or the server forcibly 103closes its connection. A handler that wants to abandon work should observe that 104signal. Request bodies should be consumed during handling, not retained beyond 105the response's lifetime. Canceling a request body stops JavaScript reads without 106canceling the response. 107 108Responses obey HEAD and null-body status semantics, preserve multiple Set-Cookie 109headers, and encode header values as HTTP bytes. HEAD responses discard and cancel 110the supplied body without reading it. Hyper controls connection framing; 111`Connection`, `Transfer-Encoding`, `Upgrade`, `Trailer`, `Keep-Alive`, and 112`Proxy-Connection` response headers are rejected. Explicit Content-Length values 113must be valid and match buffered bodies; streamed bodies must fulfill the declared 114length. Custom status text is not used. 115 116Thrown handler errors, rejected promises, and invalid or already-used responses 117produce a generic 500 response. Without `onError`, the error is reported via the 118global `error` event and printed to stderr unless canceled. Supply `onError(error)` 119to return a custom response; if it fails, its error is reported and the generic 500 120is used. Failures after response headers have been sent are also reported and close 121the connection rather than attempting a second response. 122 123## File responses 124 125`serveFile(request, source, options?)` from `avril:std` returns a `Promise<Response>` 126for a regular file. The source may be a path, file URL, or open `FileHandle`. 127It works with both `avril serve` and programmatic `serve`: 128 129``` ts 130import { serveFile } from "avril:std"; 131 132export default { 133fetch(request) { 134return serveFile(request, new URL("./index.html", import.meta.url), { 135headers: { "Cache-Control": "public, max-age=60" }, 136}); 137}, 138}; 139``` 140 141Paths may be strings or file URLs; relative strings use the runtime's working 142directory. Normal filesystem read authority is required, including with the CLI 143listener. For example, `avril serve -0 --allow-read=./index.html app.ts` grants 144access to that file without granting guest network authority. 145 146Passing a `FileHandle` transfers ownership immediately by default: the handle and 147its readable/writable streams become unusable, and the response owns cleanup, 148including for HEAD and other bodyless responses. The handle must be readable, 149open, and idle; outstanding native operations reject with `errors.Busy` without 150consuming it. Invalid options and already-aborted requests also leave it intact. 151Once ownership transfers, later preparation failures still close the file. 152 153Use `{ consume: false }` to keep a handle for repeated requests: 154 155``` ts 156import { open, serve, serveFile } from "avril:std"; 157 158const file = await open("./video.mp4"); 159const server = serve(request => serveFile(request, file, { consume: false })); 160await server.finished; 161file.close(); 162``` 163 164Each response acquires its own native file reference before preparation, so closing 165the original does not interrupt active responses. Serving ignores the handle's 166current position and does not change its cursor; concurrent responses apply their 167own ranges independently. The original opening filename supplies the MIME hint, 168without reopening that path. Normal read authority is still checked on acquisition 169and during streaming. `consume` defaults to true and has no effect on path sources. 170Retained-handle serving is supported on Unix and Windows; other platforms reject 171`consume: false` for handles with `errors.NotSupported`. 172 173The helper supplies Content-Type from the filename (unknown extensions use 174`application/octet-stream`), Content-Length, Accept-Ranges, Last-Modified, and a 175weak ETag derived from size and modification time, when modification time is 176available. These validators use the opened file's metadata, not a content hash. 177Modification time's filesystem precision limits change detection. 178`options.headers` adds response headers; Content-Type and ETag override the 179defaults. A caller-supplied strong ETag must identify the exact file 180representation. Length, range, and Last-Modified headers are controlled by the 181helper. 182 183Protocol handling and file reads run in Rust. Returning an untouched file body 184from its request's handler lets the HTTP server stream it directly, without a 185per-chunk JS round trip. Reading, cloning, or transforming the body uses ordinary 186JS streams backed by the same native file reader. This currently uses buffered 187I/O, not `sendfile`: Hyper's byte-buffer body interface does not expose a native 188file-region transport. 189 190GET streams the file with bounded reads and backpressure. HEAD returns the same 191full-file metadata without a body and ignores Range. If-Match and 192If-Unmodified-Since can produce 412; If-None-Match and If-Modified-Since can produce 193304, with ETag conditions taking precedence over their date counterparts. Range 194supports a single byte interval, open-ended interval, or suffix, producing 206 or 195416. If-Range requires a matching strong ETag or exact Last-Modified date; the 196default weak ETag cannot satisfy it. Malformed ranges, unsupported units, and 197multi-range requests are ignored, returning the full representation. 198 199Missing files and non-regular paths return 404; methods other than GET and HEAD 200return 405 with `Allow: GET, HEAD`. Permission and other filesystem errors reject 201and follow the handler's normal error policy. File handles close on completion, 202body cancellation, request abort, or failure; consume or cancel a response body 203if you do not return it. Read authority is rechecked during streaming. The helper 204reads from the opened handle but does not snapshot the contents: publish files by 205atomic replacement rather than editing them in place. Growth does not extend a 206response beyond its original length, and premature EOF errors the body. 207 208This is not a directory server: it does not map request URLs to paths, generate 209indexes, or enforce a document root. If you select files from an untrusted URL, 210validate that selection and consider symlinks as well as `..` traversal. 211 212## WebSocket upgrades 213 214`upgradeWebSocket` accepts an original incoming request and returns an ordinary 215`WebSocket` plus a special handshake response. Return that response from the same 216request's handler: 217 218``` ts 219import { upgradeWebSocket } from "avril:std"; 220 221export default { 222fetch(request) { 223const { socket, response } = upgradeWebSocket(request); 224socket.onmessage = (event) => socket.send(event.data); 225return response; 226}, 227}; 228``` 229 230This also works inside programmatic `serve` handlers and over Unix sockets. 231The socket starts in CONNECTING; `open` fires after the 101 response has been sent. 232Messages, binaryType, bufferedAmount, errors, and closing use the existing WebSocket 233API. Incoming client frames must be masked; server frames are unmasked. Messages 234are limited to 16 MiB, including fragments, with bounded read queues. 235 236To select a subprotocol, pass `{ protocol: "chat" }`; it must be a valid token offered 237by the client. The HTTP/1.1 GET handshake, upgrade headers, version, nonce, and 238subprotocol selection are validated before reserving an upgrade. Outbound WebSocket 239capabilities are not required for accepted sockets. Check Origin and application 240credentials yourself; accepting a socket does not authenticate its peer. 241 242Constructed requests and clones cannot be upgraded. Preparing an upgrade twice, 243returning its response from another request, or reusing a completed response fails. 244If the upgrade response is not returned, the reserved socket receives error/close 245instead of keeping the server alive indefinitely. Handshake headers are immutable; 246permessage-deflate and other WebSocket extensions are not negotiated. 247 248## Shutdown 249 250`server.shutdown()` stops accepting connections and waits for active responses, 251including streamed bodies and accepted WebSockets. It is idempotent and returns 252`server.finished`. Close accepted WebSockets yourself to complete graceful shutdown. 253Calling it inside a handler is allowed, but **do not await it there**: shutdown is 254waiting for that handler's response. An indefinitely running response can keep a 255graceful shutdown pending indefinitely. 256 257For immediate shutdown, pass an `AbortSignal` and abort it: 258 259``` ts 260const controller = new AbortController(); 261const server = serve({ port: 8000, signal: controller.signal }, handler); 262controller.abort(); 263await server.finished; 264``` 265 266This also interrupts a graceful shutdown already in progress. An already-aborted 267signal throws its reason without opening a listener. Listener failures reject 268`finished`; individual malformed requests or broken connections do not terminate 269the server. A worker's listeners close when the worker is terminated. 270 271## Permissions and scope 272 273Programmatic TCP listening requires `net` authority for the requested hostname and 274port. For port `0`, both port `0` and the selected port are checked, so a 275hostname-wide grant is usually appropriate. Accepting a connection and dispatching each request recheck 276the listener's hostname and actual port, including on existing keep-alive 277connections. Sending response headers, each HTTP body read/write, and preparing 278WebSocket upgrades also recheck that authority. Unix listeners perform the equivalent 279checks against write authority for their socket path. Worker listeners use the 280worker's narrowed capabilities. Binding a wildcard address exposes the listener on 281every matching interface; its grant must name that wildcard address. 282 283TLS, HTTP/2, raw connections, CONNECT tunnels, and trailers are not implemented by 284this API. CONNECT receives 501. Use a reverse proxy for HTTPS.