@orkestrel/scaffold 0.0.66 → 0.0.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/main.js +67 -44
- package/dist/bin/main.js.map +1 -1
- package/dist/host/agents/templates/brief.md +9 -0
- package/dist/host/claude/agents/orkestrel.md +8 -8
- package/dist/host/claude/rules/names.md +15 -0
- package/dist/host/claude/rules/tests.md +33 -4
- package/dist/host/claude/rules/workspace.md +14 -2
- package/dist/host/dotfiles/prettierignore +3 -0
- package/dist/host/guides/README.md +65 -0
- package/dist/host/guides/abort.md +169 -0
- package/dist/host/guides/agent.md +1509 -0
- package/dist/host/guides/brief.md +1266 -0
- package/dist/host/guides/browser.md +2200 -0
- package/dist/host/guides/budget.md +196 -0
- package/dist/host/guides/codec.md +519 -0
- package/dist/host/guides/console.md +785 -0
- package/dist/host/guides/contract.md +1193 -0
- package/dist/host/guides/csv.md +541 -0
- package/dist/host/guides/database.md +2518 -0
- package/dist/host/guides/emitter.md +233 -0
- package/dist/host/guides/form.md +1791 -0
- package/dist/host/guides/html.md +717 -0
- package/dist/host/guides/indexeddb.md +505 -0
- package/dist/host/guides/interpret.md +1029 -0
- package/dist/host/guides/lsp.md +515 -0
- package/dist/host/guides/markdown.md +964 -0
- package/dist/host/guides/mcp.md +5554 -0
- package/dist/host/guides/middleware.md +927 -0
- package/dist/host/guides/msg.md +440 -0
- package/dist/host/guides/ndjson.md +120 -0
- package/dist/host/guides/ollama.md +380 -0
- package/dist/host/guides/pool.md +280 -0
- package/dist/host/guides/probe.md +1210 -0
- package/dist/host/guides/process.md +1620 -0
- package/dist/host/guides/program.md +1110 -0
- package/dist/host/guides/qualifier.md +854 -0
- package/dist/host/guides/queue.md +370 -0
- package/dist/host/guides/rater.md +330 -0
- package/dist/host/guides/reason.md +1122 -0
- package/dist/host/guides/relation.md +373 -0
- package/dist/host/guides/router.md +753 -0
- package/dist/host/guides/scaffold.md +192 -31
- package/dist/host/guides/sea.md +383 -0
- package/dist/host/guides/server.md +752 -0
- package/dist/host/guides/sqlite.md +330 -0
- package/dist/host/guides/sse.md +187 -0
- package/dist/host/guides/supervisor.md +4890 -0
- package/dist/host/guides/table.md +1556 -0
- package/dist/host/guides/template.md +280 -0
- package/dist/host/guides/terminal.md +1145 -0
- package/dist/host/guides/test.md +2969 -0
- package/dist/host/guides/timeout.md +252 -0
- package/dist/host/guides/tool.md +311 -0
- package/dist/host/guides/toolbox.md +1038 -0
- package/dist/host/guides/websocket.md +282 -0
- package/dist/host/guides/worker.md +615 -0
- package/dist/host/guides/workflow.md +1507 -0
- package/dist/host/guides/workspace.md +595 -0
- package/dist/host/manifest.json +1218 -10
- package/dist/host/tests/policy.test.ts +279 -2
- package/dist/host/tests/setupPolicy.ts +437 -6
- package/dist/src/core/index.cjs +44 -22
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +33 -9
- package/dist/src/core/index.d.ts +33 -9
- package/dist/src/core/index.js +43 -23
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1750 -1567
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +106 -24
- package/dist/src/server/index.d.ts +106 -24
- package/dist/src/server/index.js +1751 -1570
- package/dist/src/server/index.js.map +1 -1
- package/package.json +9 -9
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
# Server
|
|
2
|
+
|
|
3
|
+
> A typed HTTP server for the `@orkestrel` line: a node-bound `Server` lifecycle entity
|
|
4
|
+
> that composes a middleware onion around a consumed `@orkestrel/router` dispatcher,
|
|
5
|
+
> beside the `HTTPError` vocabulary and a shared substrate for cookies, WebCrypto
|
|
6
|
+
> tokens, content negotiation, ETag and Range, security headers, Server-Sent Events,
|
|
7
|
+
> and the body pipeline.
|
|
8
|
+
|
|
9
|
+
The server consumes `@orkestrel/router` — routing, matching, and dispatch are that
|
|
10
|
+
package's, never re-implemented here — mechanism, not product policy. Its node face
|
|
11
|
+
adds the upgrade seam, per-request connection-fact injection, and `discoverPort` over
|
|
12
|
+
`node:http` through that package's adapter helpers. Source:
|
|
13
|
+
[`src/server`](../src/server). Surfaced through the `@orkestrel/server` barrel
|
|
14
|
+
(aliased `@src/server` inside this repo).
|
|
15
|
+
|
|
16
|
+
## Surface
|
|
17
|
+
|
|
18
|
+
Bring your own `@orkestrel/router` dispatcher, mount middleware, and start:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import type { MiddlewareHandler } from '@orkestrel/server'
|
|
22
|
+
import { createServer } from '@orkestrel/server'
|
|
23
|
+
import { createDispatcher } from '@orkestrel/router'
|
|
24
|
+
|
|
25
|
+
interface State {
|
|
26
|
+
readonly requestId: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const dispatcher = createDispatcher<State>()
|
|
30
|
+
dispatcher.add({
|
|
31
|
+
method: 'GET',
|
|
32
|
+
path: '/users/:id',
|
|
33
|
+
handler: (_request, context) =>
|
|
34
|
+
Response.json({ id: context.params.id, requestId: context.state.requestId }),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const withRequestId: MiddlewareHandler<State> = async (_request, context, next) => {
|
|
38
|
+
const response = await next()
|
|
39
|
+
response.headers.set('X-Request-ID', context.state.requestId)
|
|
40
|
+
return response
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const server = createServer<State>({
|
|
44
|
+
dispatcher,
|
|
45
|
+
state: () => ({ requestId: crypto.randomUUID() }),
|
|
46
|
+
middleware: [withRequestId],
|
|
47
|
+
})
|
|
48
|
+
const port = await server.start()
|
|
49
|
+
server.address // { address, family, port } for the bound listener
|
|
50
|
+
await server.stop()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A route handler reads `context.state` exactly as middleware wrote it — the
|
|
54
|
+
composed onion terminates in `dispatcher.handle(request, context.state)`, so
|
|
55
|
+
there is no second plumbing between the middleware seam and the router.
|
|
56
|
+
|
|
57
|
+
Cross-face and substrate usage appear under [Patterns](#patterns).
|
|
58
|
+
|
|
59
|
+
### Factories
|
|
60
|
+
|
|
61
|
+
| API | Kind | Summary |
|
|
62
|
+
| ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
63
|
+
| `createNegotiator` | function | Creates a `NegotiatorInterface` — the reusable content-negotiation machine over the weighted `Accept` family. |
|
|
64
|
+
| `createServer` | function | Creates a `ServerInterface` — the node face's HTTP server facade over a consumed `@orkestrel/router` dispatcher. |
|
|
65
|
+
| `createStream` | function | Creates a `StreamInterface` — a generic Server-Sent-Events stream whose `response` is a fetch-standard streaming `Response` a route returns. |
|
|
66
|
+
|
|
67
|
+
### Constants
|
|
68
|
+
|
|
69
|
+
A `Shape` cell holds the constant's declared type.
|
|
70
|
+
|
|
71
|
+
| API | Kind | Shape | Summary |
|
|
72
|
+
| ---------------------------- | ----- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
73
|
+
| `DEFAULT_DRAIN_MS` | const | `10_000` | Names the default graceful-stop deadline `stop()` gives in-flight requests and claimed upgraded sockets, `10_000` ms. |
|
|
74
|
+
| `DEFAULT_BODY_LIMIT` | const | `1_048_576` | Names the default maximum request body size `readBody` accepts before a 413, `1_048_576` bytes. |
|
|
75
|
+
| `DEFAULT_DECOMPRESSED_LIMIT` | const | `16_777_216` | Names the default maximum decompressed request body size, `16_777_216` bytes — the zip-bomb cap the body pipeline's byte-counting `TransformStream` enforces when it transparently decompresses a `Content-Encoding` request body. |
|
|
76
|
+
| `SSE_HEADERS` | const | `Readonly<Record<string, string>>` | Holds the SSE response headers a `Stream` sets on its response, merged under any caller `headers` so a caller repeating one of these keys replaces its value. |
|
|
77
|
+
| `REQUEST_ID_PATTERN` | const | `Readonly<RegExp>` | Defines the strict charset `isValidRequestId` requires an incoming `X-Request-ID` to match, `^[A-Za-z0-9_-]{1,200}$`. |
|
|
78
|
+
| `COMPRESSIBLE_TYPES` | const | `ReadonlySet<string>` | Holds the bare `Content-Type` values `isCompressibleType` treats as compressible, beyond the `text/*` prefix and structured-suffix (`+json`, `+xml`) rules that helper also applies. |
|
|
79
|
+
| `HTTP_ERROR_BRAND` | const | `symbol` | Names the `Symbol.for`-interned brand `HTTPError` carries, `@orkestrel/server.HTTPError`, so `isHTTPError` recognizes an instance across package copies. A consumer never sets it by hand. |
|
|
80
|
+
| `DEFAULT_ENCODINGS` | const | `readonly Encoding[]` | Lists the default `Encoding` content-codings the substrate offers, in preference order — `gzip` then `deflate`. |
|
|
81
|
+
|
|
82
|
+
### Helpers
|
|
83
|
+
|
|
84
|
+
| API | Kind | Summary |
|
|
85
|
+
| ------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
86
|
+
| `compose` | function | Composes an ordered chain of `MiddlewareHandler` layers around a `terminal` handler into one request handler — the frozen middleware seam. |
|
|
87
|
+
| `wrapMiddleware` | function | Wraps one middleware layer around its downstream handler, enforcing the one-call `next` invariant. |
|
|
88
|
+
| `parseCookies` | function | Parses a raw `Cookie:` request header into a `name → value` lookup. |
|
|
89
|
+
| `isCookieName` | function | Checks whether a string is a valid RFC 6265 cookie name — a non-empty run of cookie-token characters with no surrounding or interior whitespace. |
|
|
90
|
+
| `decodeCookieValue` | function | Decodes a cookie value with `decodeURIComponent`, falling back to the raw text when the value is not valid percent-encoding. |
|
|
91
|
+
| `isCookieAttribute` | function | Checks whether a string is safe to interpolate as a `Set-Cookie` attribute value — the guard `serializeCookie` applies to a `Domain` and a `Path` before it emits them. |
|
|
92
|
+
| `serializeCookie` | function | Serializes a cookie into a `Set-Cookie` header value — `name=value` plus its attributes. |
|
|
93
|
+
| `resolveSecure` | function | Resolves a cookie's effective `Secure` flag from its `CookieOptions` `secure` setting and whether the request arrived over TLS. |
|
|
94
|
+
| `writeSignedCookie` | function | Writes a signed cookie — HMAC-signs `value` with `signToken` and appends it as a `Set-Cookie` (the inverse of `readSignedCookie`). |
|
|
95
|
+
| `readSignedCookie` | function | Reads and verifies a signed cookie off a request — total, returning the embedded value or `undefined` (the inverse of `writeSignedCookie`). |
|
|
96
|
+
| `clearCookie` | function | Clears a cookie — appends a `Set-Cookie` that expires it immediately (`Max-Age=0`). |
|
|
97
|
+
| `signToken` | function | Signs a value into a stateless, HMAC-SHA256 token — `<payload>.<signature>`. |
|
|
98
|
+
| `verifyToken` | function | Verifies a stateless token and returns its embedded value — total, never throws. |
|
|
99
|
+
| `decodeTokenPayload` | function | Decodes and narrows a signed token's base64url JSON payload, honoring its expiry — the shared decode step `verifyToken` applies after a signature match. |
|
|
100
|
+
| `normalizeSecret` | function | Normalizes a `TokenSecret` to a concrete list of usable secrets — the list behind both `signToken` and `verifyToken`. |
|
|
101
|
+
| `parseAcceptHeader` | function | Parses a weighted `Accept` / `Accept-Encoding` / `Accept-Language` header into its q-sorted entries. |
|
|
102
|
+
| `computeCodingQuality` | function | Computes the client's quality (q) for one content-coding from the parsed `Accept-Encoding` entries — the scoring leaf `resolveCoding` runs over each offered coding. |
|
|
103
|
+
| `resolveCoding` | function | Picks the highest-scoring content-coding the server offers against already parsed `Accept-Encoding` entries — the shared selection leaf behind `negotiateEncoding` and a `Negotiator`'s `encoding` axis. |
|
|
104
|
+
| `negotiateEncoding` | function | Selects the best content-coding for a raw `Accept-Encoding` header from the codings the server offers. |
|
|
105
|
+
| `matchMediaType` | function | Reports the rank and quality of one `candidate` media type against the parsed `Accept` entries — the generic media-type primitive the `Negotiator`'s `negotiate` uses to score each `available` candidate. |
|
|
106
|
+
| `computeLanguageQuality` | function | Computes the client's quality for one `candidate` language from the parsed `Accept-Language` entries — the scoring leaf the `Negotiator`'s `language` axis runs over each offered tag. |
|
|
107
|
+
| `isCompressibleType` | function | Checks whether a `Content-Type` is worth compressing. |
|
|
108
|
+
| `computeBodyETag` | function | Computes a content `ETag` over a fully-buffered response body by using WebCrypto. |
|
|
109
|
+
| `unwrapETag` | function | Strips the weak indicator (`W/`) from an entity-tag, returning its opaque comparison body — the reduction `matchesETag` applies to each side before the RFC 7232 §2.3.2 weak comparison. |
|
|
110
|
+
| `matchesETag` | function | Checks whether a request's `If-None-Match` header matches a resource's current `ETag` — the RFC 7232 §2.3.2 weak comparison. |
|
|
111
|
+
| `parseRange` | function | Parses an HTTP `Range` request header against a known resource `size` — total, returning a `RangeSpec` or `undefined`. |
|
|
112
|
+
| `resolveOrigin` | function | Resolves the `Access-Control-Allow-Origin` value for a request. |
|
|
113
|
+
| `mergeVary` | function | Merges a `Vary` value into an existing `Vary` header without duplication. |
|
|
114
|
+
| `resolveSecurityHeader` | function | Resolves one opt-out, value-bearing security header. |
|
|
115
|
+
| `isValidRequestId` | function | Checks whether a client-supplied `X-Request-ID` is safe to echo into a response header and `context.state`. |
|
|
116
|
+
| `computeIPv6Network` | function | Computes the `/64` network of a full IPv6 address, or `undefined` when the input is not a plain IPv6 address to collapse. |
|
|
117
|
+
| `computeClientKey` | function | Collapses a client IP into its rate-limit bucket key — an IPv6 address to its `/64` network, an IPv4 (or IPv4-mapped) address unchanged. |
|
|
118
|
+
| `serializeEvent` | function | Serializes one `SSEMessage` to the SSE wire. |
|
|
119
|
+
| `isDangerousKey` | function | Checks whether a key is a prototype-pollution vector — `__proto__`, `constructor`, or `prototype`, each of which can reach and mutate `Object.prototype` when it is assigned onto a normal object. |
|
|
120
|
+
| `scrubPrototype` | function | Strips the prototype-pollution keys from a parsed value in place, recursively. |
|
|
121
|
+
| `collectRequestBody` | function | Collects a `Request` body into a single `Uint8Array`, enforcing a size limit. |
|
|
122
|
+
| `parseEncoding` | function | Parses a raw `Content-Encoding` header value into a decompressible `Encoding` — the boundary `readBody` coerces through to decide whether a request body needs transparent decompression. |
|
|
123
|
+
| `decompressRequestBody` | function | Decompresses an already-collected, `gzip`/`deflate`-encoded byte sequence transparently through `DecompressionStream`, capping the decompressed output — the zip-bomb defense. |
|
|
124
|
+
| `readBody` | function | Collects and decodes a `Request` body — the shared body-collection pipeline surfaced to middleware and handlers as the middleware context's cached `body()`. An empty body and a malformed `application/json` body each decode to `undefined`. |
|
|
125
|
+
| `isHTTPError` | function | Narrows an unknown caught value to an `HTTPError`, including a subclass such as `ContentTooLargeError`, and recognizes an instance from another copy of this package through a structural brand fallback. |
|
|
126
|
+
| `isServerError` | function | Narrows an unknown caught value to a `ServerError` — the code-bearing refusal of a call the caller programmed. |
|
|
127
|
+
| `isAddressInfo` | function | Checks whether a `node:net` `server.address()` return is the structured `AddressInfo` (carrying a numeric `port`) rather than a pipe `string` or `null` — the total, never-throwing narrow `discoverPort` and the `Server`'s own port resolution read the bound port through. |
|
|
128
|
+
| `probePort` | function | Binds and closes a throwaway TCP server to resolve one available port. |
|
|
129
|
+
| `discoverPort` | function | Finds a free TCP port — binds a throwaway `node:net` server on a `preferred` port where one is given and on an ephemeral port otherwise, reads the bound port, closes the server, and resolves that port. |
|
|
130
|
+
|
|
131
|
+
### Classes
|
|
132
|
+
|
|
133
|
+
| API | Kind | Summary |
|
|
134
|
+
| ---------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
135
|
+
| `HTTPError` | class | Represents an error a handler (or middleware) throws to produce an HTTP response of a specific status. |
|
|
136
|
+
| `ContentTooLargeError` | class | Represents the `HTTPError` thrown when a request body exceeds the body pipeline's size limit — a `413 Content Too Large`. |
|
|
137
|
+
| `ServerError` | class | Represents the error this package raises when a caller programmed a call the entity refuses, carrying `'STATUS'` or `'NEXT'` as its code. |
|
|
138
|
+
| `Negotiator` | class | Represents the content-negotiation machine over the weighted `Accept` family — a reusable, cross-middleware entity rather than a middleware. Implements exactly `NegotiatorInterface`. |
|
|
139
|
+
| `Server` | class | Represents the HTTP server facade — an observable `node:http` lifecycle composing this module's own middleware onion around a consumed `@orkestrel/router` dispatcher. Implements exactly `ServerInterface`. |
|
|
140
|
+
| `Stream` | class | Represents the Server-Sent-Events handle over an open, fetch-standard streaming `Response`. Implements exactly `StreamInterface`. |
|
|
141
|
+
|
|
142
|
+
### Types
|
|
143
|
+
|
|
144
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`.
|
|
145
|
+
|
|
146
|
+
| Type | Kind | Shape | Summary |
|
|
147
|
+
| ------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
148
|
+
| `MiddlewareContext` | interface | `{ url, method, state } plus body` | Represents the composition context — plain data, one per request, shared by every middleware and, as `state`, by the route handlers behind the dispatcher. |
|
|
149
|
+
| `NextFunction` | type | `(request?: Request) => Promise<Response>` | Represents the downstream continuation a `MiddlewareHandler` invokes to run the rest of the onion — guarded so a second call within one invocation rejects. |
|
|
150
|
+
| `MiddlewareHandler` | type | `(request: Request, context: MiddlewareContext<TState>, next: NextFunction) => Response \| Promise<Response>` | Represents one link in the middleware onion — runs around the rest of the chain. |
|
|
151
|
+
| `Connection` | interface | `{ ip?, encrypted }` | Represents the per-request connection facts the server face injects — the only data that genuinely exists solely on the socket, surfaced so middleware and a consumer's `state` factory stay core-pure. |
|
|
152
|
+
| `TokenSecret` | type | `string \| readonly string[]` | Represents a secret, or a `[current, ...older]` rotation list, for signing and verifying a stateless, HMAC-signed token. |
|
|
153
|
+
| `TokenOptions` | interface | `{ secret, ttl? }` | Options for `signToken` — how a stateless, HMAC-signed token is minted. |
|
|
154
|
+
| `CookieOptions` | interface | `{ path?, domain?, maxAge?, httpOnly?, secure?, sameSite? }` | Represents the `Set-Cookie` attributes for `serializeCookie` (and any signed-cookie transport built over it). |
|
|
155
|
+
| `AcceptEntry` | interface | `{ value, q }` | Represents one parsed entry of a weighted `Accept` / `Accept-Encoding` / `Accept-Language` header — a value and its quality weight, the element type `parseAcceptHeader` returns (sorted by `q` descending). |
|
|
156
|
+
| `MediaMatch` | interface | `{ q, rank }` | Rates one candidate media type against a parsed `Accept` header — the quality and specificity `matchMediaType` reports for the best matching `AcceptEntry`. |
|
|
157
|
+
| `Encoding` | type | `'gzip' \| 'deflate' \| 'identity'` | Represents a content-coding the substrate compresses or decompresses with. Its members are the `Content-Encoding` and `Accept-Encoding` token vocabulary the substrate understands. |
|
|
158
|
+
| `FormatHandlerMap` | type | `Readonly<Record<string, (request: Request, context: MiddlewareContext<TState>) => Response \| Promise<Response>>>` | Represents a map of media type → handler for `NegotiatorInterface.format` — the content-negotiation dispatch table. |
|
|
159
|
+
| `NegotiatorInterface` | interface | `{} plus negotiate, encoding, language, format` | Represents content negotiation over the weighted `Accept` family — a reusable, cross-middleware machine (not itself a middleware). |
|
|
160
|
+
| `SSEMessage` | interface | `{ data, event?, id?, retry? }` | Represents one Server-Sent Event to serialize to the wire. |
|
|
161
|
+
| `StreamOptions` | interface | `{ status?, headers? }` | Options for a `StreamInterface` — how `createStream` opens the streaming response. |
|
|
162
|
+
| `StreamInterface` | interface | `{ response, closed } plus write, comment, drain, end` | Represents a handle to write Server-Sent Events to an open, fetch-standard streaming `Response` — the generic streaming surface `createStream` returns over a `ReadableStream`. |
|
|
163
|
+
| `RangeSpec` | type | `{ readonly satisfiable: true; readonly start: number; readonly end: number } \| { readonly satisfiable: false }` | Represents the parsed outcome of an HTTP `Range` request header. |
|
|
164
|
+
| `BodyOptions` | interface | `{ limit?, decompression? }` | Options for `readBody` — how the shared body-collection pipeline caps and decompresses a request body. |
|
|
165
|
+
| `ServerStatus` | type | `'idle' \| 'starting' \| 'listening' \| 'stopping' \| 'stopped'` | Represents the `Server`'s lifecycle state. |
|
|
166
|
+
| `ServerErrorCode` | type | `'STATUS' \| 'NEXT'` | Represents the machine-readable category a `ServerError` carries — `'STATUS'` for a lifecycle call the current status forbids, `'NEXT'` for a middleware that called its `next` a second time. |
|
|
167
|
+
| `RequestLine` | interface | `{ method, url }` | Identifies the request a server-level fault came from — its method and its parsed URL. |
|
|
168
|
+
| `ResponseRecord` | interface | `{ method, pathname, status, ms }` | Records one finished request — the payload `ServerEventMap.response` carries. |
|
|
169
|
+
| `ServerEventMap` | type | `{ start, request, upgrade, error, stop, drain, response }` | Represents the `Server`'s observable lifecycle events. |
|
|
170
|
+
| `UpgradeHandler` | type | `(request: IncomingMessage, socket: Duplex, head: Buffer) => boolean` | Represents a raw `node:http` protocol-upgrade claimant — registered through `ServerInterface.upgrade`. |
|
|
171
|
+
| `ConnectionStateFunction` | type | `(connection: Connection) => TState` | Derives a consumer's per-request `TState` from the adapter-injected `Connection` — `ServerOptions.state`, invoked once per request before the middleware onion runs. |
|
|
172
|
+
| `ServerOptions` | interface | `{ dispatcher, state, middleware?, host?, port?, drain?, limit?, expose?, report?, timeouts?, sockets?, on?, error? }` | Options for `createServer` — the dispatcher and per-request state factory the server requires, plus its listener, drain, boundary, timeout, socket-cap, and emitter knobs. |
|
|
173
|
+
| `ServerInterface` | interface | `{ id, status, port, address, dispatcher, emitter } plus use, upgrade, start, stop, destroy` | Represents the HTTP server facade — an observable `node:http` lifecycle that composes a middleware onion (this module's own middleware seam) around a consumed `@orkestrel/router` `DispatcherInterface`. |
|
|
174
|
+
|
|
175
|
+
Each interface's `readonly` data members stay Surface rows, and its call-signature
|
|
176
|
+
members are documented under [Methods](#methods). `ServerInterface.address` is the
|
|
177
|
+
bound node `AddressInfo` while the listener is active and `undefined` otherwise.
|
|
178
|
+
|
|
179
|
+
## Methods
|
|
180
|
+
|
|
181
|
+
The public methods of `NegotiatorInterface`, `StreamInterface`, and
|
|
182
|
+
`ServerInterface` — every call-signature member listed (their `readonly` data
|
|
183
|
+
members stay Surface rows). `Negotiator`, `Server`, and `Stream` implement
|
|
184
|
+
their interfaces exactly, so their tables also double as each class's
|
|
185
|
+
instance-method surface.
|
|
186
|
+
|
|
187
|
+
#### `NegotiatorInterface`
|
|
188
|
+
|
|
189
|
+
`negotiate` is the generic media-type primitive; `encoding` / `language` are
|
|
190
|
+
its sibling axes over the same q-value parser; `format` is the dispatcher —
|
|
191
|
+
it reads the request `Accept`, negotiates a `FormatHandlerMap`'s keys, and
|
|
192
|
+
invokes the winner, or answers `406`.
|
|
193
|
+
|
|
194
|
+
The axes diverge on an absent, empty, or unparseable header. `encoding`
|
|
195
|
+
resolves to `undefined` — no compression — rather than to the first offered
|
|
196
|
+
coding, because an absent `Accept-Encoding` makes identity the correct answer;
|
|
197
|
+
`negotiate` and `language` fall back to the first offered value instead.
|
|
198
|
+
|
|
199
|
+
| Method | Returns | Summary |
|
|
200
|
+
| ----------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
201
|
+
| `negotiate` | `string \| undefined` | Picks the best `available` value for a weighted `Accept`-style `header` — the generic media-type primitive (`encoding` / `language` build on it). |
|
|
202
|
+
| `encoding` | `Encoding \| undefined` | Picks the best `available` content-coding for an `Accept-Encoding` header — the coding axis of the same q-value parser (a bare `*` wildcard ⇒ the first `available`). |
|
|
203
|
+
| `language` | `string \| undefined` | Picks the best `available` language for an `Accept-Language` header — `negotiate` with a language-prefix match (`en` accepts `en-US`) and a bare `*` wildcard. |
|
|
204
|
+
| `format` | `Promise<Response>` | Dispatches to the handler whose media type the client most prefers — reads the request `Accept`, negotiates against `handlers`' keys, and invokes the winner; `406` when none is acceptable. |
|
|
205
|
+
|
|
206
|
+
#### `StreamInterface`
|
|
207
|
+
|
|
208
|
+
`write` always accepts an event while open and returns whether the local
|
|
209
|
+
`ReadableStream` queue still has positive desired size afterward. A producer
|
|
210
|
+
receiving `false` parks on `drain` before writing again. This is process-local
|
|
211
|
+
queue state, not proof that the remote peer consumed bytes; the router's
|
|
212
|
+
drain-honoring response pump makes socket pressure stop pulls so the local
|
|
213
|
+
queue can faithfully signal that transport pressure. Ignoring the boolean
|
|
214
|
+
preserves the prior unconditional-enqueue behavior. The default queue strategy
|
|
215
|
+
counts chunks rather than their byte lengths, so a producer needing a byte
|
|
216
|
+
bound must also cap each individual event. The route must return `response`
|
|
217
|
+
before its producer awaits a `false` write, because no consumer can pull the
|
|
218
|
+
body before receiving that response.
|
|
219
|
+
|
|
220
|
+
| Method | Returns | Summary |
|
|
221
|
+
| --------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
222
|
+
| `write` | `boolean` | Serializes and enqueues one `SSEMessage` to the wire, reporting whether the process-local queue still has capacity afterward — `false` also once the stream is closed. |
|
|
223
|
+
| `comment` | `void` | Writes a `: text` SSE comment line — a keep-alive a conforming parser ignores, and a no-op once the stream is closed. |
|
|
224
|
+
| `drain` | `Promise<void>` | Parks until the process-local stream queue has capacity again — resolving on the consumer pull that restores it, or immediately when capacity is already available or the stream is closed, and never polling. |
|
|
225
|
+
| `end` | `void` | Ends the stream, completing the response — a no-op once already `closed`, and it settles any parked producer. |
|
|
226
|
+
|
|
227
|
+
#### `ServerInterface`
|
|
228
|
+
|
|
229
|
+
`use` mounts middleware (one handler or an array); `upgrade`
|
|
230
|
+
registers a raw protocol-upgrade claimant; `start` binds the listener and
|
|
231
|
+
resolves the actually-bound port while accepting an optional caller
|
|
232
|
+
`AbortSignal`; `stop` gracefully drains then closes; `destroy` is the
|
|
233
|
+
terminal, idempotent teardown. `stop` and `destroy` always resolve: an upgraded socket a
|
|
234
|
+
handler claimed is drained up to the `drain` deadline and then destroyed,
|
|
235
|
+
never waited on forever.
|
|
236
|
+
|
|
237
|
+
| Method | Returns | Summary |
|
|
238
|
+
| --------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
239
|
+
| `use` | `void` | Appends one middleware, or an array of them in order, to the onion, outer-to-inner in call order. |
|
|
240
|
+
| `upgrade` | `void` | Registers an `UpgradeHandler` claimant that runs in registration order; a claimed socket is tracked until it closes. |
|
|
241
|
+
| `start` | `Promise<number>` | Binds the configured `host` and `port`, or an ephemeral port, under an optional caller `AbortSignal`, and resolves the actually-bound port. |
|
|
242
|
+
| `stop` | `Promise<void>` | Stops gracefully: refuses new connections, fires the stop signal, drains in-flight requests and claimed upgraded sockets up to the `drain` deadline, then closes. |
|
|
243
|
+
| `destroy` | `Promise<void>` | Tears down for good: force-closes the listener and every socket, then the emitter — terminal and idempotent from any state. |
|
|
244
|
+
|
|
245
|
+
## Contract
|
|
246
|
+
|
|
247
|
+
These invariants hold across `src/server` ↔ `server.md`.
|
|
248
|
+
|
|
249
|
+
1. **Doc ↔ source bijection.** Every `function` / `class` / `interface` /
|
|
250
|
+
`type` / `const` row in the `## Surface` tables is a real export of its
|
|
251
|
+
source directory, and every export appears as a Surface row — exhaustive,
|
|
252
|
+
both directions.
|
|
253
|
+
2. **Doc ↔ source method bijection.** The `## Methods` tables list exactly
|
|
254
|
+
`NegotiatorInterface`'s, `StreamInterface`'s, and `ServerInterface`'s public
|
|
255
|
+
methods — exhaustive, both directions — and `Negotiator` / `Stream` /
|
|
256
|
+
`Server` expose the same public methods, no more.
|
|
257
|
+
3. **Status machine + bound address + restart-fresh-abort.**
|
|
258
|
+
`idle → starting → listening → stopping → stopped`; `start()` from
|
|
259
|
+
`listening`/`starting`/`stopping` rejects with a `ServerError` of code
|
|
260
|
+
`'STATUS'`, carrying that status in its `context` and narrowed by
|
|
261
|
+
`isServerError`; each `start()` mints a fresh stop signal, so a restarted
|
|
262
|
+
server is never born aborted; `address` is the real bound `AddressInfo`
|
|
263
|
+
after a successful start and `undefined` before start and after stop or
|
|
264
|
+
destroy; `stop()`/`destroy()` are idempotent no-ops from a state with
|
|
265
|
+
nothing to tear down; `EADDRINUSE` rejects `start()` outright — no silent
|
|
266
|
+
ephemeral fallback (use `discoverPort` up front for a guaranteed-free
|
|
267
|
+
port).
|
|
268
|
+
4. **Startup is bounded and caller-cancellable.** `start(signal?)` observes
|
|
269
|
+
caller cancellation only while binding; `timeouts.start` independently
|
|
270
|
+
bounds the bind (`0` permits no startup window). Cancellation or deadline
|
|
271
|
+
expiry closes the partial listener, clears the startup deadline, resets
|
|
272
|
+
the entity to `idle`, and rejects; expiry rejects with a `DOMException`
|
|
273
|
+
named `TimeoutError`, while caller cancellation rejects with that signal's
|
|
274
|
+
`reason`. A later `start()` is therefore permitted. Aborting the caller
|
|
275
|
+
signal after a successful start does not stop a live server.
|
|
276
|
+
5. **Graceful drain is event-driven, never a busy-loop.** `stop()` fires the
|
|
277
|
+
stop signal, arms a `@orkestrel/timeout` deadline, and parks on the
|
|
278
|
+
drainable count reaching zero or the deadline firing (a wake-park, not
|
|
279
|
+
polling); it then emits `drain` with the still-pending counts and closes —
|
|
280
|
+
dropping idle keep-alive sockets always, force-closing every open socket
|
|
281
|
+
only when the deadline fired with work still pending (or on `destroy()`).
|
|
282
|
+
Drainable work is every in-flight request plus every upgraded socket a
|
|
283
|
+
handler claimed, because a long-lived upgraded connection is work a
|
|
284
|
+
graceful stop lets finish rather than cuts mid-frame. `drain` carries
|
|
285
|
+
both counts, so a caller can tell a clean stop from a forced one. This is
|
|
286
|
+
also what makes `stop()` and `destroy()` always resolve: node detaches an
|
|
287
|
+
upgraded socket from its own connection set, so neither
|
|
288
|
+
`closeIdleConnections()` nor `closeAllConnections()` reaches it while
|
|
289
|
+
`server.close()` still waits on it, and the server therefore tracks each
|
|
290
|
+
claimed socket until it closes and destroys the survivors itself on a
|
|
291
|
+
forced close. The claimant still owns the socket; tracking only watches it.
|
|
292
|
+
A handler that wants a protocol-clean goodbye — a WebSocket close frame —
|
|
293
|
+
sends it on the `stop` event, which fires before the drain begins, and the
|
|
294
|
+
drain then settles on that close instead of running the deadline out. A
|
|
295
|
+
socket nothing closes costs `stop()` the whole `drain` budget and is then
|
|
296
|
+
cut, so lower `drain` for a faster shutdown.
|
|
297
|
+
6. **The built-in boundary is lifecycle machinery, not policy — one seam
|
|
298
|
+
that spans setup and dispatch.** The `Server` wraps the whole per-request
|
|
299
|
+
lifecycle in an inner phase and an outer phase of the same boundary. The
|
|
300
|
+
inner phase covers only `buildRequest`: a malformed request (for example,
|
|
301
|
+
an unparsable `Host`) answers a plain `400`, with no `error` emit, no
|
|
302
|
+
`report` call, and no `response` emit, because nothing downstream ever ran
|
|
303
|
+
and no parsed `Request` exists yet to derive its facts from. The outer
|
|
304
|
+
phase covers everything after — a throwing `this.#state(connection)`
|
|
305
|
+
through the middleware/dispatcher run — where a thrown `HTTPError` renders
|
|
306
|
+
as its own status + message; any
|
|
307
|
+
other throw renders `500` with its message hidden unless `expose` is set,
|
|
308
|
+
`report` is invoked with the caught error plus the originating request's
|
|
309
|
+
`{ method, url }` (its own throw swallowed so reporting can never crash
|
|
310
|
+
the response), and `error` is emitted with that same `{ method, url }` as
|
|
311
|
+
its second argument. Beneath this single seam sits one server-owned last
|
|
312
|
+
resort: if writing the mapped response itself throws, the connection is
|
|
313
|
+
destroyed rather than left half-written or crashing the process — the
|
|
314
|
+
middleware package may still ship a richer boundary that short-circuits
|
|
315
|
+
earlier. On both the success path and this outer-boundary error path, a
|
|
316
|
+
`response` event fires once the response has been sent, carrying
|
|
317
|
+
`{ method, pathname, status, ms }` — so observability covers every request
|
|
318
|
+
that reached the middleware pipeline, exactly once, regardless of outcome.
|
|
319
|
+
A request rejected at the inner `buildRequest` boundary is the one
|
|
320
|
+
exception: it emits no `response` at all.
|
|
321
|
+
7. **Upgrade fan-out is isolated, first-claimer-wins.** Registered
|
|
322
|
+
`UpgradeHandler`s run in registration order; the first to return `true`
|
|
323
|
+
claims the socket and stops the fan-out; a handler that throws is treated as
|
|
324
|
+
declined (the throw surfaces on `error` with no request context — `error`'s
|
|
325
|
+
second argument is `undefined` on the upgrade path, since no fetch
|
|
326
|
+
`Request` exists there, only a raw `IncomingMessage` — and never crashes
|
|
327
|
+
the process) and the fan-out continues; an upgrade nothing claims destroys
|
|
328
|
+
the socket so it never leaks a dangling connection. A claimed socket is
|
|
329
|
+
tracked until it closes, which is what item 5's drain and forced close
|
|
330
|
+
act on — ownership stays with the claimant either way.
|
|
331
|
+
8. **Body read exactly once, capped, zip-bomb-safe, scrubbed.**
|
|
332
|
+
`MiddlewareContext.body()` is lazy and cached, so a body-parsing middleware
|
|
333
|
+
and the eventual handler both reading it consume the underlying stream
|
|
334
|
+
exactly once; `readBody` caps the wire size (`ContentTooLargeError`/413 over
|
|
335
|
+
`limit`), transparently decompresses a `gzip`/`deflate` body through a
|
|
336
|
+
byte-counting `TransformStream` that aborts the instant decompressed output
|
|
337
|
+
would exceed `decompression` (fail before materializing a decompression
|
|
338
|
+
bomb, since `DecompressionStream` has no `maxOutputLength`), and scrubs
|
|
339
|
+
`__proto__`/`constructor`/`prototype` keys from a parsed JSON body
|
|
340
|
+
(`scrubPrototype`) before it is ever handed to application code.
|
|
341
|
+
9. **Cookie + token jewels preserved.** `parseCookies` rejects a
|
|
342
|
+
whitespace-padded name so a `' __Host-x'` never reconciles into a
|
|
343
|
+
protected `__Host-` name; `serializeCookie` throws on a `Domain`/`Path`
|
|
344
|
+
injection attempt rather than silently dropping it; a `sameSite: 'None'`
|
|
345
|
+
cookie is always `Secure` regardless of the `secure` setting;
|
|
346
|
+
`resolveSecure` derives `Secure` from the connection's TLS fact whenever
|
|
347
|
+
`secure` is left `undefined` (omitted).
|
|
348
|
+
`verifyToken` is total (malformed / tampered / expired / empty-rotation all
|
|
349
|
+
yield `undefined`, never throw); the expiry is HMAC-covered inside the
|
|
350
|
+
signed payload; a `TokenSecret` rotation list signs with the first secret
|
|
351
|
+
and verifies against any of them; comparison is constant-time through
|
|
352
|
+
`crypto.subtle.verify` (the old `safeCompare` is retired, not ported).
|
|
353
|
+
10. **Seam semantics: returning onion.** Each `MiddlewareHandler` receives a
|
|
354
|
+
`next` that, called, runs the downstream chain and resolves its `Response`;
|
|
355
|
+
not calling it short-circuits with the middleware's own `Response`; a
|
|
356
|
+
second call to the same `next` within one invocation rejects with a
|
|
357
|
+
`ServerError` of code `'NEXT'` (the double-`next` guard) — a middleware can
|
|
358
|
+
transform the request (`next(newRequest)`), transform the response (mutate
|
|
359
|
+
after `await next()`), or short-circuit, but never fork the chain. That
|
|
360
|
+
rejection escapes into the request boundary, which carries no `status` for
|
|
361
|
+
it and answers a generic 500.
|
|
362
|
+
11. **The bag is the router's state.** `compose`'s `terminal` is
|
|
363
|
+
`(request, context) => dispatcher.handle(request, context.state)` — the
|
|
364
|
+
exact object every middleware wrote into `context.state` is what a route
|
|
365
|
+
handler reads as `RouteContext.state`. No second plumbing.
|
|
366
|
+
12. **Connection facts are injected once, at the adapter boundary.**
|
|
367
|
+
`Connection` (`ip`, `encrypted`) is built per-request from the raw
|
|
368
|
+
socket and handed to `ServerOptions.state` — `X-Forwarded-For` is never
|
|
369
|
+
implicitly trusted; a deployment behind a trusted proxy derives its own
|
|
370
|
+
client key explicitly in `state` or in middleware.
|
|
371
|
+
13. **The stop signal is observable inside a handler.** The `Request`'s
|
|
372
|
+
`signal` (already tied to client disconnect by the router's
|
|
373
|
+
`buildRequest`) is linked, through `@orkestrel/abort`'s `linkSignal`, to the
|
|
374
|
+
server's per-run stop signal — so a handler awaiting `request.signal`
|
|
375
|
+
observes either the client disconnecting or the server calling `stop()`,
|
|
376
|
+
closing the old design's latent gap.
|
|
377
|
+
14. **Enterprise timeout knobs, Slowloris-guarded.** `timeouts.request` /
|
|
378
|
+
`timeouts.headers` / `timeouts.keepalive` map onto `node:http`'s
|
|
379
|
+
`requestTimeout` / `headersTimeout` / `keepAliveTimeout`; construction
|
|
380
|
+
throws a `TypeError` when `headers` exceeds `keepalive` (the Slowloris
|
|
381
|
+
footgun) — a guard at the boundary, never on the hot path.
|
|
382
|
+
15. **Socket caps map without policy.** `sockets.connections` /
|
|
383
|
+
`sockets.headers` / `sockets.requests` apply directly to node's
|
|
384
|
+
`maxConnections` / `maxHeadersCount` / `maxRequestsPerSocket` before bind.
|
|
385
|
+
Each is optional, so omission preserves node's native default; `0` keeps
|
|
386
|
+
each native meaning (reject all connections for `connections`, unlimited
|
|
387
|
+
for `headers` and `requests`).
|
|
388
|
+
16. **Content negotiation is total and q-value-linear.** `parseAcceptHeader`
|
|
389
|
+
is a single pass with no backtracking (ReDoS-safe); a `;q=0` entry is kept
|
|
390
|
+
(an explicit rejection a caller must honor, never silently dropped); an
|
|
391
|
+
absent/malformed `Accept` header resolves to the any-range (the first
|
|
392
|
+
offered value/handler) rather than rejecting.
|
|
393
|
+
17. **`expose: false` leaks nothing; `HTTPError` messages always surface.** A
|
|
394
|
+
generic (non-`HTTPError`) throw's message is hidden behind a fixed
|
|
395
|
+
`'Internal Server Error'` string unless `expose` is explicitly `true`; an
|
|
396
|
+
`HTTPError`'s own `message` is always client-facing (it is the handler's
|
|
397
|
+
deliberate signal), independent of `expose`.
|
|
398
|
+
18. **`isHTTPError` recognizes an `HTTPError` across package copies, not only
|
|
399
|
+
`instanceof`.** A version-skewed or workspace-linked duplicate install of
|
|
400
|
+
this package produces a second, distinct `HTTPError` constructor —
|
|
401
|
+
`instanceof` fails across the copies even though the thrown value is
|
|
402
|
+
structurally identical, which would otherwise collapse a deliberate 4xx
|
|
403
|
+
into the built-in boundary's 500 fallback. `isHTTPError` tries
|
|
404
|
+
`instanceof` first, then falls back to a total structural check: the
|
|
405
|
+
value must carry a stable cross-copy brand (a `Symbol.for`-interned key,
|
|
406
|
+
so every copy resolves the same symbol) and expose a numeric `status` and
|
|
407
|
+
a string `message` — the exact fields the boundary reads off a
|
|
408
|
+
recognized error. The brand is an implementation detail of `HTTPError`'s
|
|
409
|
+
constructor, not a field a consumer sets by hand.
|
|
410
|
+
19. **SSE producers can cooperate with real process-local transport
|
|
411
|
+
backpressure.** `StreamInterface.write` returns `true` only while the
|
|
412
|
+
underlying `ReadableStream` controller retains positive desired size after
|
|
413
|
+
accepting the event. A `false` result tells a cooperative producer to await
|
|
414
|
+
`drain()`; that promise parks without polling until a consumer pull restores
|
|
415
|
+
capacity, or stream closure settles the wait. Because the router response
|
|
416
|
+
pump stops pulling while `ServerResponse.write` is backpressured, a slow TCP
|
|
417
|
+
consumer makes this queue fill and the producer park. The signal remains
|
|
418
|
+
process-local — it does not prove remote receipt — and callers that ignore
|
|
419
|
+
it retain the original unconditional-enqueue behavior. The queue's default
|
|
420
|
+
strategy counts chunks, not their byte lengths, so a producer seeking a
|
|
421
|
+
byte bound must also cap each individual event.
|
|
422
|
+
|
|
423
|
+
## Patterns
|
|
424
|
+
|
|
425
|
+
### Quickstart: dispatcher, middleware, lifecycle
|
|
426
|
+
|
|
427
|
+
`createServer` takes a dispatcher and a per-request state factory, `use`
|
|
428
|
+
mounts middleware around the dispatch, and `start`, `stop`, and `destroy`
|
|
429
|
+
run the lifecycle.
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
import type { MiddlewareHandler } from '@orkestrel/server'
|
|
433
|
+
import { createServer } from '@orkestrel/server'
|
|
434
|
+
import { createDispatcher } from '@orkestrel/router'
|
|
435
|
+
|
|
436
|
+
interface State {
|
|
437
|
+
readonly requestId: string
|
|
438
|
+
readonly ip: string | undefined
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const dispatcher = createDispatcher<State>()
|
|
442
|
+
dispatcher.add({ method: 'GET', path: '/health', handler: () => new Response('ok') })
|
|
443
|
+
|
|
444
|
+
const logRequestId: MiddlewareHandler<State> = async (_request, context, next) => {
|
|
445
|
+
const response = await next()
|
|
446
|
+
response.headers.set('X-Request-ID', context.state.requestId)
|
|
447
|
+
return response
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const server = createServer<State>({
|
|
451
|
+
dispatcher,
|
|
452
|
+
state: (connection) => ({ requestId: crypto.randomUUID(), ip: connection.ip }),
|
|
453
|
+
})
|
|
454
|
+
server.use(logRequestId)
|
|
455
|
+
const port = await server.start()
|
|
456
|
+
await server.stop()
|
|
457
|
+
await server.destroy()
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### Middleware ordering idiom
|
|
461
|
+
|
|
462
|
+
Middleware runs outermost-first (`middleware[0]` wraps everything after it).
|
|
463
|
+
A CORS handler must claim a preflight `OPTIONS` request before the
|
|
464
|
+
dispatcher's own auto-`OPTIONS` responder ever sees it — mount it earliest in
|
|
465
|
+
the array, ahead of anything that would short-circuit later.
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
import type { MiddlewareHandler } from '@orkestrel/server'
|
|
469
|
+
import { createServer } from '@orkestrel/server'
|
|
470
|
+
import { createDispatcher } from '@orkestrel/router'
|
|
471
|
+
|
|
472
|
+
interface State {
|
|
473
|
+
readonly userId?: string
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const cors: MiddlewareHandler<State> = async (request, _context, next) => {
|
|
477
|
+
if (request.method === 'OPTIONS') return new Response(null, { status: 204 })
|
|
478
|
+
return next()
|
|
479
|
+
}
|
|
480
|
+
const auth: MiddlewareHandler<State> = async (request, context, next) => {
|
|
481
|
+
return next(request)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const dispatcher = createDispatcher<State>()
|
|
485
|
+
const server = createServer<State>({
|
|
486
|
+
dispatcher,
|
|
487
|
+
state: () => ({}),
|
|
488
|
+
middleware: [cors, auth], // CORS claims preflights before dispatcher.handle's auto-OPTIONS
|
|
489
|
+
})
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
### Typed state slices
|
|
493
|
+
|
|
494
|
+
Each middleware family publishes its own state-slice interface; a consumer
|
|
495
|
+
intersects the slices it mounts into one `TState` — no per-middleware generic
|
|
496
|
+
accumulation.
|
|
497
|
+
|
|
498
|
+
```ts
|
|
499
|
+
import type { MiddlewareHandler } from '@orkestrel/server'
|
|
500
|
+
|
|
501
|
+
interface TokenState {
|
|
502
|
+
readonly userId?: string
|
|
503
|
+
}
|
|
504
|
+
interface RequestIdState {
|
|
505
|
+
readonly requestId: string
|
|
506
|
+
}
|
|
507
|
+
type State = TokenState & RequestIdState
|
|
508
|
+
|
|
509
|
+
const withUser: MiddlewareHandler<State> = async (_request, context, next) => next()
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
### SSE route
|
|
513
|
+
|
|
514
|
+
A route returns the stream's `response` at once and pumps events into the
|
|
515
|
+
handle afterwards; a `write` that reports `false` is backpressure `drain`
|
|
516
|
+
waits out.
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
import type { StreamInterface } from '@orkestrel/server'
|
|
520
|
+
import { createStream } from '@orkestrel/server'
|
|
521
|
+
|
|
522
|
+
async function pumpStream(stream: StreamInterface): Promise<void> {
|
|
523
|
+
if (!stream.write({ event: 'token', data: 'hello' })) await stream.drain()
|
|
524
|
+
stream.comment('keep-alive')
|
|
525
|
+
stream.end()
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function streamHandler(): Response {
|
|
529
|
+
const stream = createStream()
|
|
530
|
+
void pumpStream(stream)
|
|
531
|
+
return stream.response
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
### Graceful shutdown
|
|
536
|
+
|
|
537
|
+
`stop()` refuses new connections, gives in-flight work up to the `drain`
|
|
538
|
+
deadline, then closes; `destroy()` is the final, idempotent teardown. In-flight
|
|
539
|
+
work is requests and claimed upgraded sockets, so each call always returns.
|
|
540
|
+
|
|
541
|
+
```ts
|
|
542
|
+
import { createServer } from '@orkestrel/server'
|
|
543
|
+
import { createDispatcher } from '@orkestrel/router'
|
|
544
|
+
|
|
545
|
+
const dispatcher = createDispatcher()
|
|
546
|
+
const server = createServer({ dispatcher, state: () => ({}), drain: 5_000 })
|
|
547
|
+
server.emitter.on('drain', (pending, upgraded) =>
|
|
548
|
+
console.log(`drained with ${pending} requests and ${upgraded} sockets still open`),
|
|
549
|
+
)
|
|
550
|
+
await server.start()
|
|
551
|
+
await server.stop() // graceful — waits up to 5s for requests and upgraded sockets
|
|
552
|
+
await server.destroy() // idempotent final teardown
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
A long-lived upgraded socket does not end by itself, so close it from the
|
|
556
|
+
`stop` event to keep the shutdown short. Without that the drain runs its whole
|
|
557
|
+
budget and the socket is cut instead.
|
|
558
|
+
|
|
559
|
+
```ts
|
|
560
|
+
import type { Duplex } from 'node:stream'
|
|
561
|
+
import { createServer } from '@orkestrel/server'
|
|
562
|
+
import { createDispatcher } from '@orkestrel/router'
|
|
563
|
+
|
|
564
|
+
const live = new Set<Duplex>()
|
|
565
|
+
const server = createServer({ dispatcher: createDispatcher(), state: () => ({}) })
|
|
566
|
+
server.upgrade((_request, socket) => {
|
|
567
|
+
socket.write('HTTP/1.1 101 Switching Protocols\r\n\r\n')
|
|
568
|
+
live.add(socket)
|
|
569
|
+
return true
|
|
570
|
+
})
|
|
571
|
+
server.emitter.on('stop', () => {
|
|
572
|
+
for (const socket of live) socket.end() // your protocol's clean goodbye
|
|
573
|
+
})
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### Bounded startup and socket caps
|
|
577
|
+
|
|
578
|
+
`timeouts.start` bounds only listener startup; pass an `AbortSignal` to cancel
|
|
579
|
+
that same pending bind from the caller. Socket caps use one grouped option and
|
|
580
|
+
map directly to node's server properties.
|
|
581
|
+
|
|
582
|
+
```ts
|
|
583
|
+
import { createServer } from '@orkestrel/server'
|
|
584
|
+
import { createDispatcher } from '@orkestrel/router'
|
|
585
|
+
|
|
586
|
+
const controller = new AbortController()
|
|
587
|
+
const server = createServer({
|
|
588
|
+
dispatcher: createDispatcher(),
|
|
589
|
+
state: () => ({}),
|
|
590
|
+
timeouts: { start: 5_000 },
|
|
591
|
+
sockets: { connections: 1_000, headers: 100, requests: 1_000 },
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
// Calling controller.abort() while startup is pending cancels this bind.
|
|
595
|
+
const port = await server.start(controller.signal)
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### Upgrade attach
|
|
599
|
+
|
|
600
|
+
An upgrade handler returns `true` to claim the socket, which ends the
|
|
601
|
+
fan-out and leaves the connection with that handler.
|
|
602
|
+
|
|
603
|
+
```ts
|
|
604
|
+
import { createServer } from '@orkestrel/server'
|
|
605
|
+
import { createDispatcher } from '@orkestrel/router'
|
|
606
|
+
|
|
607
|
+
const dispatcher = createDispatcher()
|
|
608
|
+
const server = createServer({ dispatcher, state: () => ({}) })
|
|
609
|
+
server.upgrade((_request, socket, _head) => {
|
|
610
|
+
if (socket.destroyed) return false
|
|
611
|
+
socket.write('HTTP/1.1 101 Switching Protocols\r\n\r\n')
|
|
612
|
+
return true // claims the socket; a later handler never sees it
|
|
613
|
+
})
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
### Substrate direct use — tokens, cookies, negotiation
|
|
617
|
+
|
|
618
|
+
Each substrate helper stands on its own, so a caller reaches negotiation,
|
|
619
|
+
signed cookies, tokens, and capped decompression without a `Server`.
|
|
620
|
+
|
|
621
|
+
```ts
|
|
622
|
+
import type { MiddlewareContext } from '@orkestrel/server'
|
|
623
|
+
import {
|
|
624
|
+
createNegotiator,
|
|
625
|
+
decodeTokenPayload,
|
|
626
|
+
decompressRequestBody,
|
|
627
|
+
readSignedCookie,
|
|
628
|
+
signToken,
|
|
629
|
+
verifyToken,
|
|
630
|
+
writeSignedCookie,
|
|
631
|
+
} from '@orkestrel/server'
|
|
632
|
+
|
|
633
|
+
declare const context: MiddlewareContext<Record<string, never>>
|
|
634
|
+
|
|
635
|
+
const negotiator = createNegotiator()
|
|
636
|
+
negotiator.negotiate('text/html, application/json;q=0.9', ['application/json', 'text/html']) // 'text/html'
|
|
637
|
+
negotiator.encoding('gzip;q=1.0, deflate;q=0.8', ['gzip', 'deflate']) // 'gzip'
|
|
638
|
+
negotiator.language('en-US, en;q=0.8, fr;q=0.5', ['en', 'fr']) // 'en'
|
|
639
|
+
await negotiator.format(new Request('http://x'), context, {
|
|
640
|
+
'application/json': (_request, _context) => Response.json({ ok: true }),
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
const headers = new Headers()
|
|
644
|
+
await writeSignedCookie(headers, 'session', 'user-1', 'secret')
|
|
645
|
+
await readSignedCookie(
|
|
646
|
+
new Request('http://x', { headers: { cookie: 'session=abc' } }),
|
|
647
|
+
'session',
|
|
648
|
+
'secret',
|
|
649
|
+
)
|
|
650
|
+
await verifyToken('bad.token', 'secret') // undefined — total, never throws
|
|
651
|
+
|
|
652
|
+
const token = await signToken('client', { secret: 'shh' })
|
|
653
|
+
decodeTokenPayload(token.split('.')[0]) // 'client' — the shared decode step verifyToken applies after a signature match
|
|
654
|
+
|
|
655
|
+
const gzipped = new Uint8Array(
|
|
656
|
+
await new Response(
|
|
657
|
+
new Blob(['hi']).stream().pipeThrough(new CompressionStream('gzip')),
|
|
658
|
+
).arrayBuffer(),
|
|
659
|
+
)
|
|
660
|
+
const body = await decompressRequestBody(gzipped, 'gzip', 1_048_576)
|
|
661
|
+
new TextDecoder().decode(body) // 'hi' — capped decompression, the zip-bomb defense
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
### Practices
|
|
665
|
+
|
|
666
|
+
- **The server consumes the router, never re-implements it** — bring your own
|
|
667
|
+
`DispatcherInterface`; this package owns zero route matching — mechanism,
|
|
668
|
+
not product policy.
|
|
669
|
+
- **Mount CORS before anything that could short-circuit an `OPTIONS`** — the
|
|
670
|
+
ordering idiom under [Middleware ordering idiom](#middleware-ordering-idiom);
|
|
671
|
+
the dispatcher's own auto-`OPTIONS` runs last.
|
|
672
|
+
- **Read `context.body()` through the cache, never `request.body` directly**
|
|
673
|
+
— the stream is drained exactly once, capped and zip-bomb-safe.
|
|
674
|
+
- **Thread `request.signal` into downstream work** — it fires on either
|
|
675
|
+
client disconnect or server `stop()`.
|
|
676
|
+
- **Never derive a rate key from `X-Forwarded-For`** — use the injected
|
|
677
|
+
`Connection.ip` (or your own trusted-proxy derivation).
|
|
678
|
+
- **Publish a state-slice interface per middleware family** — intersect the
|
|
679
|
+
slices a consumer mounts into one `TState`, never a generic-accumulating
|
|
680
|
+
chain.
|
|
681
|
+
- **`stop()` before `destroy()`** for a graceful shutdown; `destroy()` alone
|
|
682
|
+
is the abrupt final teardown, idempotent from any state.
|
|
683
|
+
- **Close your upgraded sockets on the `stop` event** — the server tracks them
|
|
684
|
+
so shutdown terminates, but only your handler speaks the protocol, so only
|
|
685
|
+
it can close one cleanly before the deadline cuts it.
|
|
686
|
+
- **Install a `report` sink for observability** — its own throw is swallowed,
|
|
687
|
+
so it can never crash a response.
|
|
688
|
+
|
|
689
|
+
## Tests
|
|
690
|
+
|
|
691
|
+
- [`tests/guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ `src/server`
|
|
692
|
+
bijection (value and type exports), the `NegotiatorInterface` / `StreamInterface` /
|
|
693
|
+
`ServerInterface` ↔ implementing-class method bijections, and the equality gate:
|
|
694
|
+
every `Summary` cell against its declaration's description paragraph, the titled
|
|
695
|
+
`Substrate direct use — tokens, cookies, negotiation` fence against the `@example`
|
|
696
|
+
block of that title (pinned so the titled pair cannot be retired silently), and the
|
|
697
|
+
README pitch against this guide's tagline. It also runs the flagship fences and
|
|
698
|
+
asserts the values their comments claim.
|
|
699
|
+
- [`tests/src/server/helpers.test.ts`](../tests/src/server/helpers.test.ts) —
|
|
700
|
+
`compose` (outer-first ordering, double-`next` rejection, short-circuit,
|
|
701
|
+
request substitution, response transformation), cookie parse/serialize/
|
|
702
|
+
attribute-injection guards, `resolveSecure`, `clearCookie` (expiry and
|
|
703
|
+
accumulation), `computeCodingQuality`/`resolveCoding`/`negotiateEncoding`,
|
|
704
|
+
the `ETag` hex digest, and `discoverPort` (default, preferred, and
|
|
705
|
+
taken-preferred-falls-back cases).
|
|
706
|
+
- [`tests/src/server/validators.test.ts`](../tests/src/server/validators.test.ts) —
|
|
707
|
+
`isAddressInfo` narrowing over every shape `node:net`'s `server.address()`
|
|
708
|
+
returns.
|
|
709
|
+
- [`tests/src/server/Negotiator.test.ts`](../tests/src/server/Negotiator.test.ts) —
|
|
710
|
+
`negotiate`/`encoding`/`language`/`format`: exact vs subtype-wildcard vs
|
|
711
|
+
any-range precedence, `;q=0` rejection semantics, q-tie server-order
|
|
712
|
+
break, `format`'s 406 fallback and handler dispatch, the empty-header
|
|
713
|
+
divergence between `encoding` and `negotiate`, and the proof that
|
|
714
|
+
`encoding` and `negotiateEncoding` agree because they run one selection leaf.
|
|
715
|
+
- [`tests/src/server/Stream.test.ts`](../tests/src/server/Stream.test.ts) —
|
|
716
|
+
the opened SSE response and its header merge order, the serialized wire for
|
|
717
|
+
events and comments, readiness/drain and ignore-the-signal behavior, and the
|
|
718
|
+
ways the handle closes (`end`, and a consumer cancelling).
|
|
719
|
+
- [`tests/src/server/errors.test.ts`](../tests/src/server/errors.test.ts) —
|
|
720
|
+
`HTTPError`/`ContentTooLargeError` shape and `isHTTPError` narrowing, and
|
|
721
|
+
`ServerError` shape with `isServerError` narrowing.
|
|
722
|
+
- [`tests/src/server/factories.test.ts`](../tests/src/server/factories.test.ts) —
|
|
723
|
+
`createNegotiator`, `createServer`, and `createStream` round-trips + factory
|
|
724
|
+
return-type assertions, `createServer` option threading and construction
|
|
725
|
+
guards, and `createStream` option threading.
|
|
726
|
+
- [`tests/src/server/Server.test.ts`](../tests/src/server/Server.test.ts) —
|
|
727
|
+
the status matrix, restart-fresh-abort, caller-cancelled / timed-out / clean
|
|
728
|
+
bounded startup, `EADDRINUSE` honesty, host/port binds, ephemeral default,
|
|
729
|
+
connection / header / per-socket request caps, graceful-vs-forced drain,
|
|
730
|
+
the held-upgraded-socket stop (drained to the deadline then cut, settled
|
|
731
|
+
early when the claimant closes it, reported on `drain`, and force-closed by
|
|
732
|
+
`destroy()`) against a no-socket control, 20-parallel-none-dropped,
|
|
733
|
+
connection facts threaded into state,
|
|
734
|
+
`context.body()` caching, boundary mapping (`HTTPError`/other/`expose`), the
|
|
735
|
+
stop-signal-reaches-handlers case, and the real slow-TCP proof that an SSE
|
|
736
|
+
producer parks at local queue pressure, resumes on drain, and stays bounded
|
|
737
|
+
when cooperative while ignored readiness retains unconditional enqueue.
|
|
738
|
+
|
|
739
|
+
## See also
|
|
740
|
+
|
|
741
|
+
- [`AGENTS.md`](../AGENTS.md) — the rules this package is written to, including
|
|
742
|
+
the design laws behind its emitter, its guards, and its
|
|
743
|
+
documentation-as-contract.
|
|
744
|
+
- [`router.md`](router.md) — `@orkestrel/router`, the dispatcher this server
|
|
745
|
+
consumes and never re-implements.
|
|
746
|
+
- [`abort.md`](abort.md) — `@orkestrel/abort`, the stop-signal/request-signal
|
|
747
|
+
linking primitive.
|
|
748
|
+
- [`emitter.md`](emitter.md) — `@orkestrel/emitter`, the `Server`'s lifecycle
|
|
749
|
+
event map.
|
|
750
|
+
- [`contract.md`](contract.md) — `@orkestrel/contract`, the guards backing
|
|
751
|
+
every construction boundary and untrusted read.
|
|
752
|
+
- [`README.md`](README.md) — the guides index.
|