char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somallow passing a FileHandle straight to serveFile (+ non-consumption)aedf431

main
13.5 KiB284 linesraw

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.ts
avril 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) => {
9  return Response.json({
10    path: new URL(request.url).pathname,
11    peer: 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 {
33  fetch(request) {
34    return 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) => {
66  return 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 {
133  fetch(request) {
134    return serveFile(request, new URL("./index.html", import.meta.url), {
135      headers: { "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 {
222  fetch(request) {
223    const { socket, response } = upgradeWebSocket(request);
224    socket.onmessage = (event) => socket.send(event.data);
225    return 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.