From 4b5c1ccab2b7abc01c3e9552ba3bdf8ee384ec72 Mon Sep 17 00:00:00 2001 From: Michael Emperador Date: Sun, 23 Aug 2026 16:14:21 -0500 Subject: [PATCH 1/2] Revert "chore: move public documentation out of WebSocket PR" This reverts commit e1de8a9756a957374d53cc31afda1eba24d7f343. --- README.md | 120 +++++++++++++++++--------------- guide/adapters.md | 49 +++++++++++++ guide/api-reference.md | 97 ++++++++++++++++++++++++++ guide/configuration.md | 61 ++++++++++++++++ guide/development.md | 59 ++++++++++++++++ guide/errors-observability.md | 57 +++++++++++++++ guide/getting-started.md | 66 ++++++++++++++++++ guide/index.md | 29 ++++++++ guide/platforms-kernels.md | 40 +++++++++++ guide/protocol-roadmap.md | 31 +++++++++ guide/requests-and-responses.md | 68 ++++++++++++++++++ guide/routing.md | 66 ++++++++++++++++++ guide/runtime-lifecycle.md | 78 +++++++++++++++++++++ guide/websockets.md | 70 +++++++++++++++++++ tests/test_documentation.py | 78 +++++++++++++++++++++ 15 files changed, 913 insertions(+), 56 deletions(-) create mode 100644 guide/adapters.md create mode 100644 guide/api-reference.md create mode 100644 guide/configuration.md create mode 100644 guide/development.md create mode 100644 guide/errors-observability.md create mode 100644 guide/getting-started.md create mode 100644 guide/index.md create mode 100644 guide/platforms-kernels.md create mode 100644 guide/protocol-roadmap.md create mode 100644 guide/requests-and-responses.md create mode 100644 guide/routing.md create mode 100644 guide/runtime-lifecycle.md create mode 100644 guide/websockets.md create mode 100644 tests/test_documentation.py diff --git a/README.md b/README.md index 3f9b5c3..73b5744 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,64 @@ # SmallServer -SmallServer is a SmallOS-native web framework in early development. It provides -a bounded HTTP/1.1 server, static async routing for GET, POST, PUT, PATCH, and -DELETE, and explicit escape hatches for blocking and asyncio-native libraries. +SmallServer is a SmallOS-native web framework for Python 3.10+. It serves +bounded HTTP/1.1 requests, exact and timeout-bounded regex routes, optional +RFC 6455 WebSockets, explicit runtime lifecycle control, and third-party +execution adapters. -## Current scope +```python +from smallserver import Response, SmallServer -The current package provides an HTTP/1.1 baseline over a SmallOS runtime. It can: +app = SmallServer() -- register static async routes for GET, POST, PUT, PATCH, and DELETE; -- dispatch an already-created `Request` to a handler; -- return deterministic `Response` values, including HTTP/1.1 bytes; -- return 404 for an unknown path and 405 with `Allow` for a known path using - the wrong method. -- bind a non-blocking TCP listener, accept bounded concurrent connections, and - wait for read/write readiness through SmallOS; -- parse one `Content-Length` HTTP/1.1 request per connection and close after - its response. -Keep-alive/pipelining, TLS, path parameters, and HTTP/2 are not implemented yet. +@app.get("/health") +async def health(request): + return Response.json({"status": "ok"}) -## Install for development -```bash -python3 -m pip install -r requirements.txt -python3 -m pip install -e . -python3 -m unittest discover -s tests -v +if __name__ == "__main__": + app.listen(host="127.0.0.1", port=8000) ``` -SmallOS is installed from the canonical `master` branch in `requirements.txt`. -It owns scheduling, socket readiness, and foreign execution adapters. +Install the canonical SmallOS master dependency and this package, then run the +demo: -## Run the demo - -The included demo starts a task API at `http://127.0.0.1:8000`. Common -application code does not need to import or configure SmallOS. - -```bash +```console python3 -m pip install -r requirements.txt +python3 -m pip install -e . python3 demo.py ``` -Leave the process running and exercise GET, POST, PUT, PATCH, and DELETE from a -browser or HTTP client. Press Ctrl-C for deterministic cleanup without a -traceback. The static `/tasks` path is intentional: path parameters arrive -with a later milestone. +Application code can use blocking `app.listen()` without importing SmallOS. +Advanced applications can supply their own runtime, schedule without starting +it, and own adapters for blocking or asyncio-native libraries. -## Bind a server +## Optional features -Create the application and call blocking `listen()`. It lazily creates a -SmallOS runtime with the Unix kernel, while SmallOS remains the scheduler and -owner of socket readiness. `port=0` asks the operating system for an available -port, which is useful in tests and local tooling. +Static routing and HTTP-only imports need neither optional protocol package. +Install only the feature an application serves: -```python -from smallserver import Response, SmallServer +```console +python3 -m pip install -e '.[regex-routes]' +python3 -m pip install -e '.[websocket]' +``` -app = SmallServer() +Regex routes use bounded full-path matching after exact static lookup. +WebSocket routes use a separate static route table, so an ordinary `GET` and a +WebSocket Upgrade may coexist at one path. -@app.get("/health") -async def health(request): - return Response.json({"status": "ok"}) +```python +from smallserver import WebSocket -app.listen(host="127.0.0.1", port=8000) + +@app.websocket("/echo", origins={"https://app.example.com"}) +async def echo(socket: WebSocket) -> None: + await socket.accept() + async for message in socket: + if message.is_text: + await socket.send_text(message.text) + else: + await socket.send_bytes(message.bytes) ``` ### Configure the managed SmallOS runtime @@ -95,6 +91,8 @@ configure it directly with `SmallOS(config=...)`; SmallServer rejects `task_capacity` must reserve at least `max_connections + 2` task slots for the listener and shutdown-control tasks, and both server task priorities must be below `priority_levels`. +Configuring a regex route-error observer adds one dedicated SmallOS task, so +that mode requires at least `max_connections + 3` slots. Managed `listen()` blocks and catches Ctrl-C after closing its listener, wakeup channel, connections, and server tasks. It returns the closed `ServerHandle`, @@ -102,18 +100,28 @@ whose cached `address` and `port` remain available for diagnostics. Each current connection accepts one request and sends a `Connection: close` response. -If the runtime exits normally but cleanup is incomplete, `listen()` raises -`ServerFinalizationError`; retain it and call `retry_cleanup()` until it -succeeds. If runtime startup raises while cleanup is incomplete, ordinary -failures are wrapped by `ServerStartupError`; `KeyboardInterrupt` and -`SystemExit` keep their identity and expose that cleanup owner as `__cause__`. -Until cleanup succeeds, the application rejects another listener invocation. - -## Advanced runtime control - -Supply a configured runtime when the application needs to coordinate other -SmallOS tasks. A supplied runtime is never reconfigured or destroyed, and -`start=False` schedules the server without starting it: +## Documentation + +- [Guide index](guide/index.md) +- [Getting started](guide/getting-started.md) +- [Routing](guide/routing.md) +- [WebSockets](guide/websockets.md) +- [Requests and responses](guide/requests-and-responses.md) +- [Runtime and lifecycle](guide/runtime-lifecycle.md) +- [Configuration](guide/configuration.md) +- [Third-party adapters](guide/adapters.md) +- [Errors and observability](guide/errors-observability.md) +- [Platforms and kernels](guide/platforms-kernels.md) +- [API reference](guide/api-reference.md) +- [Protocol roadmap](guide/protocol-roadmap.md) +- [Development](guide/development.md) + +See [`demo.py`](demo.py) for all five HTTP methods and a WebSocket route, +[`examples/websocket_echo.py`](examples/websocket_echo.py) for a bounded echo +server, [`examples/manual_runtime.py`](examples/manual_runtime.py) for +caller-owned SmallOS startup, and +[`examples/adapters_demo.py`](examples/adapters_demo.py) for blocking and +asyncio escape hatches. ```python from SmallPackage import SmallOS, Unix diff --git a/guide/adapters.md b/guide/adapters.md new file mode 100644 index 0000000..2bc91b1 --- /dev/null +++ b/guide/adapters.md @@ -0,0 +1,49 @@ +# Third-party adapters + +SmallServer handlers run on the SmallOS scheduler and must not call blocking +functions or drive a second event loop directly. SmallOS supplies explicit +escape hatches: + +- `ThreadAdapter` for blocking or thread-affine callables; +- `AsyncioAdapter` for coroutine-based libraries on a persistent asyncio loop. + +The application creates, bounds, and shuts down these adapters. SmallServer +does not create adapter workers as a side effect of `listen()`. + +```python +from SmallPackage.adapters.errors import AdapterError +from SmallPackage.adapters.threads import ThreadAdapter +from smallserver import AdapterRegistry, Response, http_error_from_adapter + +services = AdapterRegistry(blocking=ThreadAdapter(max_workers=2, max_pending=8)) + + +async def handler(request): + try: + result = await services.call("blocking", str.upper, "smallserver") + except AdapterError as exc: + raise http_error_from_adapter(exc) + return Response.text(result) + + +services.shutdown() +``` + +In a real application, keep the registry alive around the complete runtime +lifecycle; do not shut it down immediately after defining a handler. The +context manager calls `shutdown()` automatically and cancels pending adapter +work when its body exits with an exception. + +`AdapterRegistry` accepts named, user-created adapters that provide `call()` +and `shutdown()`. It rejects duplicate names and duplicate adapter objects, +delegates calls, exposes stable registration order through `names()` and +`items()`, and shuts adapters down in reverse registration order. + +`http_error_from_adapter()` intentionally sanitizes adapter failures: +capacity, unavailable, closed, and cancelled conditions become generic 503 +responses; other adapter failures become a generic 500. Log internal causes in +application-controlled telemetry if needed, but do not expose them to clients. + +See [`examples/adapters_demo.py`](../examples/adapters_demo.py) for a runnable +SQLite and asyncio example and [`examples/manual_runtime.py`](../examples/manual_runtime.py) +for the surrounding runtime lifecycle. diff --git a/guide/api-reference.md b/guide/api-reference.md new file mode 100644 index 0000000..3dcabd2 --- /dev/null +++ b/guide/api-reference.md @@ -0,0 +1,97 @@ +# API reference + +This page summarizes the public names exported by `smallserver`. Signatures +omit overload detail where prose is clearer. + +## Application + +### `SmallServer(regex_config=None, *, route_error_observer=None, websocket_config=None)` + +- `get(path)`, `post(path)`, `put(path)`, `patch(path)`, `delete(path)` — route + decorators for one supported method. +- `route(path, methods)` — atomic multi-method route decorator. +- `get_regex`, `post_regex`, `put_regex`, `patch_regex`, `delete_regex`, and + `route_regex` — optional timeout-bounded full-path route decorators. +- `websocket(path, *, origins=None, subprotocols=())` — static WebSocket route. +- `async dispatch(request)` — dispatch an existing `Request`. +- `listen(host="127.0.0.1", port=8000, config=None, *, runtime=None, start=None)` + — managed blocking lifecycle or caller-owned scheduling/startup. +- `serve(runtime, host="127.0.0.1", port=8000, config=None)` — schedule against + a caller-owned runtime and return immediately. + +## HTTP values + +### `Headers(values=None)` + +Immutable, case-insensitive mapping with `items()` and `get()`. + +### `Request(method, path, headers, body=b"", version="HTTP/1.1", ...)` + +Frozen request value with validated method, routed path, headers, byte body, +raw target, query string, immutable path parameters, and route pattern. + +### `Response(status=200, body=b"", headers=Headers())` + +Frozen response value. `Response.text()`, `Response.json()`, and `to_http1()` +provide common construction and serialization paths. + +## Server lifecycle + +### `ServerConfig(...)` + +Frozen finite-limit configuration. See [Configuration](configuration.md). + +### `ServerHandle` + +Read-only properties: `address`, `port`, `closed`, `failure`, `finished`, +`cleanup_errors`, and `owned_connection_count`. + +Operations: `close()`, `async close_from_task(task)`, and `finalize()`. + +## Regex routing + +- `RegexRouteConfig` — finite route, pattern, capture, path, and timeout limits. +- `RegexRoutesUnavailable` — the optional matching engine is missing. +- `RouteMatchTimeout` and `RoutePathTooLarge` — bounded matching failures. +- `RouteErrorEvent` — sanitized event sent to the optional observer. + +## WebSockets + +- `WebSocketConfig` — finite frame, message, mailbox, connection, and deadline + limits. +- `WebSocket` — `accept`, `reject`, receive/send methods, `ping`, `close`, + iteration, `request`, and negotiated `subprotocol`. +- `WebSocketMessage` — complete typed text or binary message. +- `WebSocketDisconnect`, `WebSocketStateError`, and `WebSocketCapacityError` — + application-visible lifecycle and capacity outcomes. +- `WebSocketUnavailable` — the optional `wsproto` engine is missing. + +See [WebSockets](websockets.md) for handshake and completion semantics. + +## Adapters + +### `AdapterRegistry(**adapters)` + +Methods: `register`, `get`, `call`, `names`, `items`, and `shutdown`. It also +implements a context manager and exposes `closed`. + +### `http_error_from_adapter(exc)` + +Convert a SmallOS `AdapterError` to a sanitized `HTTPError`. + +### `AdapterShutdownError` + +Raised after registry shutdown attempts every adapter but one or more fail. +Its `failures` tuple contains `(name, exception)` entries. + +## Errors + +- `HTTPError(status, detail="")` +- `ServerConfigurationError` +- `ServerStartupError` +- `ServerFinalizationError` + +Cleanup errors expose `cleanup_errors`, `cleanup_complete`, `retry_cleanup()`, +and `finalize()`. `ServerStartupError` additionally exposes `primary_error`. + +Public typing information is shipped through `smallserver/py.typed`. diff --git a/guide/configuration.md b/guide/configuration.md new file mode 100644 index 0000000..7200540 --- /dev/null +++ b/guide/configuration.md @@ -0,0 +1,61 @@ +# Configuration + +Pass a `ServerConfig` to `listen()` or `serve()` to tune finite listener, +parser, and scheduling limits. + +```python +from smallserver import ServerConfig, SmallServer + +app = SmallServer() +config = ServerConfig( + max_connections=50, + max_header_bytes=16 * 1024, + max_header_count=64, + max_body_bytes=512 * 1024, + receive_chunk_bytes=8 * 1024, + listener_priority=1, + connection_priority=2, + accept_batch_size=16, +) +``` + +| Setting | Default | Purpose | +| --- | ---: | --- | +| `max_connections` | 100 | Maximum connection streams still owned by the server. | +| `max_header_bytes` | 16 KiB | Maximum HTTP/1.1 request-head bytes. | +| `max_header_count` | 100 | Maximum number of request header fields. | +| `max_body_bytes` | 1 MiB | Maximum `Content-Length` and buffered request body. | +| `receive_chunk_bytes` | 8 KiB | Bytes requested from the transport per read. | +| `listener_priority` | 1 | SmallOS listener and close-watcher task priority. | +| `connection_priority` | 2 | SmallOS connection-task priority. | +| `accept_batch_size` | 16 | Accepts before the listener explicitly yields. | +| `max_request_target_bytes` | 8 KiB | Maximum origin-form request-target bytes. | +| `max_route_error_events` | 16 | Bounded sanitized observer-event queue. | + +Every field must be a positive integer; booleans are rejected. The public port +must be an integer from 0 through 65535. `port=0` delegates port selection to +the kernel. + +At connection capacity, the listener waits on a scheduler signal instead of +accepting and discarding more streams. Connections whose close failed still +count against the limit because the server continues to own them. A close +failure is fatal to further acceptance and remains visible for cleanup retry. + +Limits are per `ServerHandle`. They bound HTTP input and framework-owned +connections, but they do not limit memory allocated by your handlers, response +bodies, adapter queues, or downstream libraries; configure those separately. + +## Regex routing limits + +Pass `RegexRouteConfig` to `SmallServer(regex_config=...)`. It bounds path +bytes, pattern length, route count, named captures, individual match time, and +total matching time. Regex configuration is validated without importing the +optional engine; registration imports it lazily. + +## WebSocket limits + +Pass `WebSocketConfig` to `SmallServer(websocket_config=...)`. Its positive, +finite settings bound frame and reassembled-message bytes, inbox/outbox counts +and bytes, read/write chunks, WebSocket connection count, and handshake, idle, +Pong, write, and close deadlines. `max_frame_payload_bytes` cannot exceed +`max_message_bytes`. See [WebSockets](websockets.md) for operational behavior. diff --git a/guide/development.md b/guide/development.md new file mode 100644 index 0000000..b51b5c0 --- /dev/null +++ b/guide/development.md @@ -0,0 +1,59 @@ +# Development + +## Set up + +Use Python 3.10 or newer and install the canonical SmallOS master checkout plus +SmallServer in editable mode: + +```console +python3 -m pip install -r requirements.txt +python3 -m pip install -e . +``` + +Install both optional test surfaces with +`python3 -m pip install -e '.[regex-routes,websocket]'` when validating the +complete feature set. Also run the suite without extras to keep HTTP-only +imports lazy. + +For reproducible validation, put the canonical SmallOS checkout at the front of +`PYTHONPATH` rather than relying on an unrelated installed package named +`SmallPackage`. + +## Validate + +```console +python3 -m unittest discover -s tests -v +python3 -m compileall -q smallserver demo.py examples tests +git diff --check +``` + +The suite covers routing, HTTP values and parsing, WebSockets, adapters, +lifecycle failure ownership, kernel transport behavior, and real loopback +serving when the local environment permits binds. Documentation tests verify +the tracked guide set, relative Markdown links, and Python code-block syntax. + +Run the examples when their platform requirements are available: + +```console +python3 demo.py +python3 examples/adapters_demo.py +python3 examples/manual_runtime.py +python3 examples/websocket_echo.py +``` + +The three network examples block until shutdown. `adapters_demo.py` completes +on its own and demonstrates SQLite thread affinity and a persistent asyncio +loop. + +## Contribution boundaries + +- Keep framework networking behind SmallOS kernel abstractions. +- Preserve finite parsing, connection, and adapter limits. +- Keep the HTTP core independent of `asyncio`. +- Add lifecycle tests for partial acquisition and cleanup failure paths. +- Update the README and focused guide page when a public API changes. +- Extend [Protocol roadmap](protocol-roadmap.md) docs on the feature branch that + implements a protocol; do not describe planned APIs as present. + +The ignored `docs/` and `skills/` trees support local agent workflows. Public, +versioned user documentation belongs in `README.md` and `guide/`. diff --git a/guide/errors-observability.md b/guide/errors-observability.md new file mode 100644 index 0000000..9b1bb5d --- /dev/null +++ b/guide/errors-observability.md @@ -0,0 +1,57 @@ +# Errors and observability + +SmallServer separates expected HTTP responses, configuration mistakes, +runtime failures, and incomplete cleanup ownership. + +## Handler-facing errors + +Raise `HTTPError(status, detail)` for an expected 4xx or 5xx response. Status +must be between 400 and 599. The detail becomes a plain-text response; do not +put secrets or raw downstream exceptions in it. + +The network server converts ordinary handler exceptions into a generic 500. +`app.dispatch()` only catches `HTTPError`, so direct dispatch in tests preserves +programming errors. + +## Configuration errors + +`ServerConfigurationError` reports a runtime or kernel capability that cannot +support the requested lifecycle. Type and value mistakes generally raise +`TypeError` or `ValueError` before binding. + +## Startup and finalization ownership + +`ServerStartupError` means startup failed and one or more acquired resources +could not yet be released. Its `primary_error` is the original failure; +`cleanup_errors` contains the current cleanup failures. Retain the exception +and call `retry_cleanup()` or `finalize()` until it returns `True`. + +`ServerFinalizationError` means a started runtime returned normally but server +cleanup remains incomplete. It exposes the same `cleanup_errors`, +`cleanup_complete`, `retry_cleanup()`, and `finalize()` contract. + +`KeyboardInterrupt` and `SystemExit` retain their identity. If rollback is +incomplete, their `__cause__` is the `ServerStartupError` cleanup owner. An +abandoned incomplete cleanup error makes one best-effort retry and emits a +`ResourceWarning` if ownership remains. + +## ServerHandle state + +Observe these stable properties: + +- `address` and `port`: cached bind result; +- `closed`: shutdown has been requested; +- `finished`: all server-owned cleanup is complete; +- `failure`: first fatal listener or connection-cleanup failure, if any; +- `cleanup_errors`: current failures for still-owned resources; +- `owned_connection_count`: active and retained connection streams. + +SmallServer does not provide a logging backend, metrics registry, or tracing +system. Applications should report sanitized handle state and +their own handler/adapter telemetry without reaching into private attributes. + +Regex matching timeouts may be reported through `route_error_observer`. Its +dedicated SmallOS task receives bounded, traceback-free `RouteErrorEvent` +values containing only an opaque route ID and category. Observer failures and +capacity drops are isolated and counted on `ServerHandle`; the observer must +return quickly and use an execution adapter for blocking work. diff --git a/guide/getting-started.md b/guide/getting-started.md new file mode 100644 index 0000000..1101ab5 --- /dev/null +++ b/guide/getting-started.md @@ -0,0 +1,66 @@ +# Getting started + +## Requirements + +SmallServer requires Python 3.10 or newer. During development, +`requirements.txt` installs SmallOS from the canonical GitHub `master` branch; +SmallServer itself declares no package-index runtime dependency yet. + +```console +python3 -m pip install -r requirements.txt +python3 -m pip install -e . +``` + +Install `.[regex-routes]` for regex routes or `.[websocket]` for WebSocket +routes. Static HTTP usage imports without either optional package. + +The first command needs Git and network access. Pin the SmallOS revision in +your own deployment lock or build process if reproducibility matters. + +## Create an application + +```python +from smallserver import Request, Response, SmallServer + +app = SmallServer() + + +@app.get("/health") +async def health(request: Request) -> Response: + return Response.json({"status": "ok"}) + + +if __name__ == "__main__": + app.listen(host="127.0.0.1", port=8000) +``` + +Run the file and request the exact path: + +```console +curl -i http://127.0.0.1:8000/health +``` + +`listen()` creates a SmallOS runtime with its Unix kernel, blocks while that +runtime runs, and handles Ctrl-C by cleaning up server-owned resources. Normal +application code does not need to import SmallOS. + +Use `port=0` when a test or tool needs the kernel to choose an available port. +Because managed `listen()` blocks, inspect the returned handle only after the +runtime has stopped. For access to the bound port while the server is running, +use [caller-owned runtime mode](runtime-lifecycle.md#caller-owned-runtime). + +## Try the task demo + +[`demo.py`](../demo.py) implements GET, POST, PUT, PATCH, and DELETE on the +static `/tasks` route plus a WebSocket echo route at `/ws`: + +```console +python3 demo.py +curl -i http://127.0.0.1:8000/tasks +curl -i -X POST -H 'Content-Type: application/json' \ + --data '{"title":"read the guide"}' http://127.0.0.1:8000/tasks +``` + +Every ordinary HTTP/1.1 connection serves one request and closes after the +response. See [Routing](routing.md), [WebSockets](websockets.md), and +[Configuration](configuration.md) before building a larger application. diff --git a/guide/index.md b/guide/index.md new file mode 100644 index 0000000..54bac06 --- /dev/null +++ b/guide/index.md @@ -0,0 +1,29 @@ +# SmallServer guide + +This guide documents the current SmallServer API: bounded HTTP/1.1, static and +regex routing, WebSockets, managed or caller-owned SmallOS lifecycle, and +execution adapters. + +## Learn SmallServer + +1. [Getting started](getting-started.md) — install, create an app, and run it. +2. [Routing](routing.md) — exact and bounded regex routes. +3. [WebSockets](websockets.md) — HTTP/1.1 Upgrade, messages, and deadlines. +4. [Requests and responses](requests-and-responses.md) — immutable HTTP values. +5. [Runtime and lifecycle](runtime-lifecycle.md) — managed and caller-owned modes. +6. [Configuration](configuration.md) — finite parser and protocol limits. + +## Integrate and operate + +- [Third-party adapters](adapters.md) +- [Errors and observability](errors-observability.md) +- [Platforms and kernels](platforms-kernels.md) +- [API reference](api-reference.md) + +## Project direction + +- [Protocol roadmap](protocol-roadmap.md) +- [Development](development.md) + +SmallServer currently supports HTTP/1.1 and RFC 6455 Upgrade only. See the +roadmap for deferred HTTP/2, TLS, compression, and keep-alive work. diff --git a/guide/platforms-kernels.md b/guide/platforms-kernels.md new file mode 100644 index 0000000..e29f0df --- /dev/null +++ b/guide/platforms-kernels.md @@ -0,0 +1,40 @@ +# Platforms and kernels + +SmallServer delegates networking, readiness, task registration, and task +cancellation to SmallOS. Production framework modules do not import Python's +`socket` module directly; kernel-owned transport handles remain opaque to the +application. + +## Desktop default + +Managed `app.listen()` lazily imports `SmallOS` and the `Unix` kernel, configures +that runtime, and starts it. If the dependency or Unix kernel is unavailable, +it raises `ServerConfigurationError` and asks the caller to provide a suitable +runtime. + +The canonical SmallOS dependency is installed from GitHub `master` by +`requirements.txt`. Python package metadata intentionally has no runtime +dependency until SmallOS has an unambiguous published distribution contract. + +## Custom and constrained kernels + +A supplied runtime must expose a configured `kernel` plus callable `fork`, +`resume_task`, and `cancel_task` operations. Starting it through SmallServer +also requires `start`. + +The kernel must satisfy SmallOS's network capability contract for listeners, +streams, readiness, retry direction, addresses, and cleanup. Capability checks +occur before SmallServer binds a listener. + +A wakeup channel is optional: + +- with one, `ServerHandle.close()` can notify the scheduler from another thread; +- without one, external `close()` raises and a running task must call + `await handle.close_from_task(task)`; +- after a caller-owned scheduler exits, `handle.finalize()` is the owner-thread + cleanup path on either kind of kernel. + +Do not infer that a MicroPython-like platform supports managed Unix mode or a +thread-safe wakeup just because it can accept TCP connections. Supply the +platform runtime explicitly and test its real capability surface and cleanup +behavior. diff --git a/guide/protocol-roadmap.md b/guide/protocol-roadmap.md new file mode 100644 index 0000000..9be0286 --- /dev/null +++ b/guide/protocol-roadmap.md @@ -0,0 +1,31 @@ +# Protocol and feature roadmap + +The current branch provides bounded HTTP/1.1, static and regex routes, shared +HTTP values, RFC 6455 Upgrade, explicit SmallOS lifecycle control, and +application-owned execution adapters. + +## Routing extensions + +Timeout-bounded regex routes and immutable named captures are implemented as +the optional `regex-routes` extra. Exact static routes retain precedence. + +## WebSocket server + +Optional RFC 6455 server support over HTTP/1.1 Upgrade is implemented through +the `websocket` extra with SmallOS-native transport ownership and bounded +protocol state. TLS, compression, custom extensions, and RFC 8441 WebSockets +over HTTP/2 remain separate concerns. + +## HTTP/2 server + +The HTTP/2 feature is planned as an optional cleartext prior-knowledge server +using the hyper-h2 4.x sans-I/O stack. Its branch is responsible for documenting +dependency installation, stream concurrency, flow control, protocol limits, +GOAWAY, and graceful shutdown. SmallServer does not yet accept HTTP/2 +connections or export an HTTP/2 configuration type. + +## Existing HTTP/1.1 limits + +Keep-alive, pipelining, TLS termination, automatic protocol detection, h2c +upgrade, middleware/ASGI compatibility, and automatic request-data decoding are +not implemented here. Treat this page as direction, not a compatibility promise. diff --git a/guide/requests-and-responses.md b/guide/requests-and-responses.md new file mode 100644 index 0000000..b27c79f --- /dev/null +++ b/guide/requests-and-responses.md @@ -0,0 +1,68 @@ +# Requests and responses + +`Request`, `Response`, and `Headers` are immutable value objects shared by the +router and server. + +## Request + +A handler receives: + +- `method`: a valid HTTP token; +- `path`: the request target, beginning with `/`; +- `headers`: a case-insensitive `Headers` mapping; +- `body`: complete request bytes; +- `version`: `HTTP/1.1` for the current network server; +- `raw_target`: the exact origin-form target; +- `query_string`: undecoded text after `?`; +- `path_params` and `route_pattern`: immutable regex-route context when used. + +The base parser accepts one origin-form HTTP/1.1 request framed by zero or one +`Content-Length` header. It rejects transfer encoding, multiple content lengths, +missing `Host`, invalid targets, oversized input, and pipelined bytes. It does +not decode JSON, forms, query parameters, percent escapes, or text for you. + +```python +import json + +from smallserver import HTTPError, Request, Response + + +async def create(request: Request) -> Response: + try: + value = json.loads(request.body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise HTTPError(400, "body must be valid JSON") from exc + return Response.json({"received": value}, status=201) +``` + +## Headers + +Header lookup is case-insensitive while iteration preserves the originally +provided spelling. Names must be HTTP tokens; values cannot contain control +characters other than horizontal tab or characters outside Latin-1. Duplicate +names are rejected after case folding. + +```python +from smallserver import Headers + +headers = Headers({"Content-Type": "application/json"}) +assert headers["content-type"] == "application/json" +``` + +## Response + +Construct `Response(status, body, headers)`, or use `Response.text()` and +`Response.json()`. Bodies must already be `bytes`. An explicit `Content-Length` +must exactly match the body; otherwise construction fails. The HTTP/1.1 server +adds a length when absent and sends `Connection: close`. + +```python +from smallserver import Response + +plain = Response.text("ready") +created = Response.json({"id": "1"}, status=201) +empty = Response(status=204) +``` + +`Response.to_http1()` is available for deterministic serialization and tests. +Applications normally return the value and let SmallServer write it. diff --git a/guide/routing.md b/guide/routing.md new file mode 100644 index 0000000..5fcd6b5 --- /dev/null +++ b/guide/routing.md @@ -0,0 +1,66 @@ +# Routing + +SmallServer checks exact static routes first, then optional timeout-bounded +regular-expression routes. Register static routes with `get`, `post`, `put`, +`patch`, `delete`, or the multi-method `route` decorator. + +```python +from smallserver import Response, SmallServer + +app = SmallServer() + + +@app.route("/status", methods=("GET", "POST")) +async def status(request): + return Response.text(request.method) +``` + +Methods passed to `route` are normalized to uppercase and duplicates are +removed. Registration rejects an empty method set, unsupported methods, +non-callable handlers, duplicate method/path pairs, and paths that do not start +with `/`. A failed multi-method registration does not partially add a route. + +## Dispatch behavior + +- An exact method/path match runs its async handler. +- A known path with the wrong method returns 405 and a sorted `Allow` header. +- An unknown path returns 404. +- A handler must return an awaitable whose result is a `Response`. +- Raising `HTTPError` produces the requested 4xx or 5xx response. + +An ordinary handler exception becomes a generic 500 when the network server +invokes it. A direct call to `await app.dispatch(request)` preserves ordinary +exceptions for tests and embedding code. + +## Request targets + +The parser preserves the exact ASCII origin-form target as +`request.raw_target`. Routing uses `request.path`, excluding the raw query +string stored in `request.query_string`. Neither field nor a regex capture is +percent-decoded, so `/files/a%2Fb` remains distinct from `/files/a/b`. + +## Regex routes + +Install the bounded matching engine only when needed: + +```console +python3 -m pip install -e '.[regex-routes]' +``` + +```python +@app.get_regex(r"/users/(?P[0-9]+)") +async def user(request): + return Response.json({"user_id": request.path_params["user_id"]}) +``` + +`route_regex(pattern, methods)` and the five method-specific regex decorators +use full-path matching in registration order after static lookup. Only named +captures are exposed through immutable `request.path_params`; an unmatched +optional group is omitted. `request.route_pattern` identifies the selected +pattern. + +Patterns must begin with a literal `/`. Registration and dispatch bound route +count, pattern length, capture count, path bytes, each match, and total matching +time. A timeout becomes a sanitized 500 on the network path and may be observed +through the bounded `route_error_observer` channel without disclosing the +hostile path. Oversized paths return 414 before matching. diff --git a/guide/runtime-lifecycle.md b/guide/runtime-lifecycle.md new file mode 100644 index 0000000..d67611e --- /dev/null +++ b/guide/runtime-lifecycle.md @@ -0,0 +1,78 @@ +# Runtime and lifecycle + +SmallOS always owns scheduling and I/O readiness. SmallServer offers one +managed mode for normal applications and explicit modes for applications that +coordinate other SmallOS tasks. + +Only one listener invocation may be active on a `SmallServer` instance. The +instance can be reused after its handle is fully finished. + +## Managed runtime + +```python +from smallserver import Response, SmallServer + +app = SmallServer() + + +@app.get("/") +async def index(request): + return Response.text("hello") + + +app.listen(host="127.0.0.1", port=8000) +``` + +With no `runtime`, `listen()` lazily creates `SmallOS().setKernel(Unix())`, +starts it, blocks until shutdown, and finalizes server-owned resources. In this +managed mode, Ctrl-C is consumed after successful cleanup and the closed +`ServerHandle` is returned. + +## Caller-owned runtime + +Supply a configured runtime to schedule the listener without starting it: + +```python +from SmallPackage import SmallOS, Unix +from smallserver import Response, SmallServer + +runtime = SmallOS().setKernel(Unix()) +app = SmallServer() + + +@app.get("/health") +async def health(request): + return Response.json({"status": "ok"}) + + +handle = app.listen(runtime=runtime, start=False, port=0) +print(handle.address) +try: + runtime.start() +finally: + handle.finalize() +``` + +With a supplied runtime, `start=False` is the default. `app.serve(runtime, ...)` +is the equivalent schedule-and-return compatibility API. Passing `start=True` +starts the supplied runtime once; the caller still owns that runtime. + +## Shutdown operations + +- `handle.close()` requests shutdown from outside the scheduler when the kernel + provides a wakeup channel. Unix supports this path. +- `await handle.close_from_task(task)` shuts down from the currently running + SmallOS task and is required on kernels without a wakeup channel. +- `handle.finalize()` performs idempotent owner-thread cleanup after a manually + started scheduler has exited or failed. + +`closed` means shutdown was requested. `finished` is stronger: the listener, +wakeup channel, connections, and retained cleanup work have all completed. +Failed closes remain owned and appear in `cleanup_errors`; call the appropriate +cleanup operation again from a safe context. + +`address` and `port` are cached and remain readable after close. `failure` +reports the first fatal listener or connection-cleanup failure. + +See [Errors and observability](errors-observability.md) for incomplete startup +and finalization transactions. diff --git a/guide/websockets.md b/guide/websockets.md new file mode 100644 index 0000000..5fe7b48 --- /dev/null +++ b/guide/websockets.md @@ -0,0 +1,70 @@ +# WebSockets + +Install the optional protocol engine before serving WebSocket routes: + +```bash +python3 -m pip install -e '.[websocket]' +``` + +WebSocket routes use HTTP/1.1 Upgrade while SmallOS continues to own task +scheduling and socket readiness. A normal `GET` route may use the same path; +requests without Upgrade headers remain ordinary HTTP requests. + +```python +from smallserver import SmallServer, WebSocket + +app = SmallServer() + +@app.websocket( + "/chat", + origins={"https://app.example.com"}, + subprotocols=("chat.v1",), +) +async def chat(socket: WebSocket) -> None: + await socket.accept(subprotocol="chat.v1") + async for message in socket: + if message.is_text: + await socket.send_text(message.text) + else: + await socket.send_bytes(message.bytes) + +app.listen() +``` + +The application must explicitly call `accept()` or `reject()` before using +message operations. Returning without either decision sends a sanitized 403. +Text, binary, fragmented messages, Ping/Pong, and Close are supported. Queue, +frame, message, connection, handshake, idle, Pong, write, and close limits are +finite and configurable through `WebSocketConfig`. + +Only one application Ping may await a Pong at a time. The timeout is armed +before the frame is written, and only a Pong with the matching payload clears +it. Handshake, idle, Pong, and close deadlines also bound cleanup when a peer +stops reading; expired connections cancel handler work owned by that +connection. + +An origin allowlist is strongly recommended when browser credentials or +cookies are involved. A selected subprotocol must have been offered by the +client and allowed by the route. Outbound saturation raises +`WebSocketCapacityError`. + +Direct calls to `receive()`, `receive_text()`, or `receive_bytes()` raise +`WebSocketDisconnect` after already queued messages have been delivered when +the peer or application closes the connection. `async for message in socket` +instead treats that disconnect as normal iteration completion. Server shutdown +and expired handshake, idle, Pong, write, or close deadlines may cancel the +connection handler to guarantee bounded cleanup, so application resource +cleanup belongs in the handler's `finally` block. + +Send calls complete after the serialized frame bytes have been flushed through +the connection writer. They do not mean the peer application has processed the +message. + +This release does not implement `wss://` termination, compression, custom +extensions, or RFC 8441 WebSockets over HTTP/2. Put TLS at a trusted reverse +proxy until SmallServer gains a native TLS boundary. + +The runnable [`websocket_echo.py`](../examples/websocket_echo.py) accepts +clients without requiring a subprotocol. The `/chat` example above separately +demonstrates explicit negotiation: a client must offer `chat.v1` before the +handler may select it. diff --git a/tests/test_documentation.py b/tests/test_documentation.py new file mode 100644 index 0000000..044afd5 --- /dev/null +++ b/tests/test_documentation.py @@ -0,0 +1,78 @@ +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +GUIDE_FILES = { + "index.md", + "getting-started.md", + "routing.md", + "requests-and-responses.md", + "runtime-lifecycle.md", + "websockets.md", + "configuration.md", + "adapters.md", + "errors-observability.md", + "platforms-kernels.md", + "api-reference.md", + "protocol-roadmap.md", + "development.md", +} +MARKDOWN_LINK = re.compile(r"(? {}".format(document.relative_to(ROOT), target)) + continue + if separator: + headings = { + heading_slug(value) + for value in HEADING.findall(destination.read_text()) + } + if fragment not in headings: + failures.append( + "{} -> {} (missing heading)".format( + document.relative_to(ROOT), target + ) + ) + self.assertEqual(failures, []) + + def test_python_code_blocks_compile(self): + failures = [] + for document in self._documents(): + for position, source in enumerate(PYTHON_BLOCK.findall(document.read_text()), 1): + try: + compile(source, "{}:block{}".format(document, position), "exec") + except SyntaxError as exc: + failures.append(str(exc)) + self.assertEqual(failures, []) + + +if __name__ == "__main__": + unittest.main() From b804cfc7ed68da09aefed93388a592cb768fbd05 Mon Sep 17 00:00:00 2001 From: Michael Emperador Date: Sun, 23 Aug 2026 16:15:37 -0500 Subject: [PATCH 2/2] docs: align examples with reconciled WebSocket API --- README.md | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 73b5744..2411238 100644 --- a/README.md +++ b/README.md @@ -229,8 +229,8 @@ async def delete_widgets(request: Request) -> Response: return Response(status=204) ``` -Route paths are static in this release. Path parameters and richer lifecycle -hooks are deferred; the current `ServerHandle` provides explicit shutdown. +Exact static routes are dependency-free. Optional regex routes expose bounded +named captures; automatic path-template syntax is not implemented. ## Dispatch a request @@ -238,16 +238,17 @@ The listener creates requests and calls `dispatch()`. The same boundary is useful in application tests: ```python -request = Request( - method="GET", - path="/health", - headers={"Accept": "application/json"}, -) - -response = await app.dispatch(request) -assert response.status == 200 -assert response.body == b'{"status":"ok"}' -assert response.headers["content-type"] == "application/json" +async def test_health() -> None: + request = Request( + method="GET", + path="/health", + headers={"Accept": "application/json"}, + ) + + response = await app.dispatch(request) + assert response.status == 200 + assert response.body == b'{"status":"ok"}' + assert response.headers["content-type"] == "application/json" ``` For a path that is registered but does not accept the request method, @@ -284,9 +285,9 @@ async def delete_widget(request: Request) -> Response: raise HTTPError(413, "request is too large") ``` -`dispatch()` turns this into a text response with status 413. Unexpected -exceptions are intentionally left visible for the future SmallOS server's -runtime error handling. +`dispatch()` turns this into a text response with status 413. Direct dispatch +leaves unexpected exceptions visible; network listeners return a sanitized +500 response. ## Third-party blocking and asyncio libraries