@greendrake/server 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eugene Greendrake
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # @greendrake/server
2
+
3
+ What a service binary needs around its request handler: health probes an orchestrator can route on, CORS, and the SIGTERM sequence that gets the process out of the way without dropping work. **Runs on [bun](https://bun.sh)** — it owns the `Bun.serve` call.
4
+
5
+ It knows nothing about what the service serves; [`@greendrake/rpc-server`](../rpc-server) is one thing that can sit inside it, a plain `fetch` handler is another.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ bun add @greendrake/server
11
+ ```
12
+
13
+ TypeScript source ships as-is (`src/main.ts` is the entry; no build step, no `.d.ts`). Extend `@greendrake/dev-config/tsconfig/node.json` with `"types": ["bun"]`.
14
+
15
+ ## Serving
16
+
17
+ ```ts
18
+ import { serve } from '@greendrake/server'
19
+
20
+ const service = serve({
21
+ name: 'billing-api',
22
+ port: 8080,
23
+ fetch: (request, server) => router(request, server),
24
+ checks: [
25
+ { name: 'db', check: signal => db.ping(signal) },
26
+ { name: 'cache', check: signal => cache.ping(signal) }
27
+ ],
28
+ probeTimeoutMs: 2000,
29
+ shutdown: { deadlineMs: 15_000, lameDuckMs: 3000 },
30
+ drain: () => worker.finish()
31
+ })
32
+ ```
33
+
34
+ `websocket` takes a `Bun.serve` WebSocket handler where the service has one. `cors` takes `{ origin?, allowHeaders?, exposeHeaders? }`, or `false` for a service no browser reaches.
35
+
36
+ The returned handle carries the `Bun.Server`, a `draining` flag the application can read to stop taking on work of its own, and `stop()` — the same sequence the signal handlers run, so a test can drive it without the process exiting.
37
+
38
+ ## Probes
39
+
40
+ `/healthz` — is this process still the process. It deliberately asks nothing of the dependencies: a database outage must not get the service killed and restarted into the same outage. 200, or 503 once draining.
41
+
42
+ `/readyz` — can this process serve right now. Every check runs at once under `probeTimeoutMs`, and the body names each one's verdict, so a stalled deploy says which dependency and why rather than leaving a bare 503 to be guessed at:
43
+
44
+ ```json
45
+ { "db": "ok", "cache": "fail: connection refused" }
46
+ ```
47
+
48
+ The deadline is enforced around each check rather than left to it: a dependency that has stopped answering typically stops answering its cancellation too, and a readiness probe that hangs is worse than one that fails. The check still receives the signal, and should pass it to its I/O.
49
+
50
+ Both are answered before the application's `fetch` sees the request, and CORS wraps both — a preflight never reaches the app router, or any middleware hung off it that would charge a rate limit for a request the browser made on its own.
51
+
52
+ ## Shutting down
53
+
54
+ On SIGINT or SIGTERM, in order:
55
+
56
+ 1. `draining` flips, so the probes report 503 and whatever routes traffic stops sending it.
57
+ 2. `lameDuckMs` passes, still serving — that gap is what gives the unready state time to propagate before the sockets stop accepting. Zero for a service nothing routes to.
58
+ 3. The listener stops accepting, then `drain()` runs: finish in-flight work, close pools, flush.
59
+
60
+ The whole sequence races `deadlineMs`. Past it `stop()` answers `'forced'` and the signal handler exits non-zero — task brokers and HTTP clients recover from that (re-delivery, retry), whereas a binary that will not die has to be killed by something else.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@greendrake/server",
3
+ "version": "0.1.0",
4
+ "description": "Service-binary scaffolding for bun: dependency health probes with /healthz + /readyz, CORS, and the SIGTERM-driven graceful-shutdown sequence",
5
+ "license": "MIT",
6
+ "author": "Eugene Greendrake <eugene@greendrake.nz>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/greendrake/lib.git",
10
+ "directory": "server"
11
+ },
12
+ "homepage": "https://github.com/greendrake/lib/tree/main/server#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/greendrake/lib/issues"
15
+ },
16
+ "type": "module",
17
+ "main": "src/main.ts",
18
+ "types": "src/main.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./src/main.ts",
22
+ "default": "./src/main.ts"
23
+ }
24
+ },
25
+ "files": [
26
+ "src"
27
+ ],
28
+ "sideEffects": false,
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "check": "tsc --noEmit"
34
+ },
35
+ "prettier": "@greendrake/dev-config/prettier",
36
+ "devDependencies": {
37
+ "@greendrake/dev-config": "0.1.0",
38
+ "@types/bun": "^1.3.5",
39
+ "typescript": "6.0.3"
40
+ }
41
+ }
package/src/cors.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { FetchHandler } from './serve'
2
+
3
+ // What a browser may send. The list is the union of what a JSON API over fetch
4
+ // needs; a service whose clients send more names them in `allowHeaders`.
5
+ const REQUEST_HEADERS = ['Accept', 'Authorization', 'Cache-Control', 'Content-Type', 'Origin', 'X-Requested-With']
6
+
7
+ const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
8
+
9
+ export interface CorsOptions {
10
+ // `*` by default. These APIs authorize by token rather than by cookie, so
11
+ // the browser's origin is not what controls access — locking it down is a
12
+ // defence in depth a deployment may still want.
13
+ origin?: string
14
+ // Added to the defaults above.
15
+ allowHeaders?: string[]
16
+ // Response headers a browser client may read. Everything else is hidden
17
+ // from it whatever the server sends.
18
+ exposeHeaders?: string[]
19
+ }
20
+
21
+ // CORS at the outermost layer, so a preflight is answered before it reaches
22
+ // the application — and before any middleware hung off it that would charge a
23
+ // rate limit or a captcha for a request the browser made on its own.
24
+ export const withCors = <T>(fetch: FetchHandler<T>, options: CorsOptions = {}): FetchHandler<T> => {
25
+ const origin = options.origin ?? '*'
26
+ const headers: Record<string, string> = {
27
+ 'Access-Control-Allow-Origin': origin,
28
+ 'Access-Control-Allow-Headers': [...REQUEST_HEADERS, ...(options.allowHeaders ?? [])].join(', '),
29
+ 'Access-Control-Allow-Methods': METHODS.join(', ')
30
+ }
31
+ // A browser refuses credentials against a wildcard origin, so claiming to
32
+ // allow them there is not merely useless but misleading.
33
+ if (origin !== '*') {
34
+ headers['Access-Control-Allow-Credentials'] = 'true'
35
+ }
36
+ if (options.exposeHeaders?.length) {
37
+ headers['Access-Control-Expose-Headers'] = options.exposeHeaders.join(', ')
38
+ }
39
+
40
+ return async (request, server) => {
41
+ if (request.method === 'OPTIONS') {
42
+ return new Response(null, { status: 204, headers })
43
+ }
44
+ const response = await fetch(request, server)
45
+ // Nothing to decorate: the request was upgraded to a WebSocket, whose
46
+ // handshake CORS does not govern.
47
+ if (!response) {
48
+ return undefined
49
+ }
50
+ Object.entries(headers).forEach(([name, value]) => response.headers.set(name, value))
51
+ return response
52
+ }
53
+ }
package/src/main.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { healthz, readyz, runChecks, type CheckResult, type DependencyCheck } from './probes'
2
+ export { withCors, type CorsOptions } from './cors'
3
+ export { serve, type FetchHandler, type RunningService, type ServeOptions, type ShutdownOptions } from './serve'
package/src/probes.ts ADDED
@@ -0,0 +1,57 @@
1
+ // One dependency this service cannot serve without, and how to ask it. The
2
+ // signal is already bounded by the probe deadline — a check must not add a
3
+ // timeout of its own on top.
4
+ export interface DependencyCheck {
5
+ name: string
6
+ check(signal: AbortSignal): Promise<void>
7
+ }
8
+
9
+ export interface CheckResult {
10
+ ok: boolean
11
+ // "ok", or "fail: <reason>" — the readiness body is what an operator reads
12
+ // when a deploy stalls, so a failure says which dependency and why rather
13
+ // than leaving a bare 503 to be guessed at.
14
+ message: string
15
+ }
16
+
17
+ // Every check at once, each under the same deadline. The deadline is enforced
18
+ // here rather than left to the check: a dependency that has stopped answering
19
+ // typically stops answering its cancellation too, and a readiness probe that
20
+ // hangs is worse than one that fails.
21
+ export const runChecks = async (checks: DependencyCheck[], timeoutMs: number): Promise<Record<string, CheckResult>> => {
22
+ const results = await Promise.all(
23
+ checks.map(async ({ name, check }): Promise<[string, CheckResult]> => {
24
+ const signal = AbortSignal.timeout(timeoutMs)
25
+ const expiry = new Promise<never>((_, reject) => {
26
+ signal.addEventListener('abort', () => reject(new Error(`timed out after ${timeoutMs}ms`)), { once: true })
27
+ })
28
+ try {
29
+ await Promise.race([check(signal), expiry])
30
+ return [name, { ok: true, message: 'ok' }]
31
+ } catch (e) {
32
+ return [name, { ok: false, message: `fail: ${(e as Error).message}` }]
33
+ }
34
+ })
35
+ )
36
+ return Object.fromEntries(results)
37
+ }
38
+
39
+ const json = (status: number, body: Record<string, unknown>): Response => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8' } })
40
+
41
+ // Liveness: is this process still the process. It deliberately asks nothing of
42
+ // the dependencies — a database outage must not get the service killed and
43
+ // restarted into the same outage. 503 while draining, so a load balancer stops
44
+ // sending work before the sockets close.
45
+ export const healthz = (draining: boolean): Response => json(draining ? 503 : 200, { status: draining ? 'draining' : 'ok' })
46
+
47
+ // Readiness: can this process serve right now. Every dependency is probed, and
48
+ // the body names each one's verdict.
49
+ export const readyz = async (checks: DependencyCheck[], timeoutMs: number, draining: boolean): Promise<Response> => {
50
+ const results = await runChecks(checks, timeoutMs)
51
+ const body: Record<string, unknown> = Object.fromEntries(Object.entries(results).map(([name, result]) => [name, result.message]))
52
+ if (draining) {
53
+ body.draining = true
54
+ }
55
+ const ready = !draining && Object.values(results).every(result => result.ok)
56
+ return json(ready ? 200 : 503, body)
57
+ }
package/src/serve.ts ADDED
@@ -0,0 +1,128 @@
1
+ import type { Server, WebSocketHandler } from 'bun'
2
+ import { withCors, type CorsOptions } from './cors'
3
+ import { healthz, readyz, type DependencyCheck } from './probes'
4
+
5
+ // Bun's request handler, including the `undefined` a handler returns once it
6
+ // has upgraded the request to a WebSocket.
7
+ export type FetchHandler<T = undefined> = (request: Request, server: Server<T>) => Response | undefined | Promise<Response | undefined>
8
+
9
+ export interface ShutdownOptions {
10
+ // The whole sequence's budget. Past it the process exits non-zero: task
11
+ // brokers and HTTP clients recover from that (re-delivery, retry), whereas
12
+ // a binary that will not die has to be killed by something else.
13
+ deadlineMs: number
14
+ // How long to keep serving after the probes start reporting unready, so
15
+ // whatever routes traffic notices before the sockets stop accepting. Zero
16
+ // for anything nothing routes to.
17
+ lameDuckMs: number
18
+ }
19
+
20
+ export interface ServeOptions<T> {
21
+ // Appears in the startup and forced-exit log lines, so a host running
22
+ // several binaries says which one.
23
+ name: string
24
+ port: number
25
+ // The application's routes. Probes and CORS are answered before it.
26
+ fetch: FetchHandler<T>
27
+ websocket?: WebSocketHandler<T>
28
+ checks?: DependencyCheck[]
29
+ probeTimeoutMs: number
30
+ shutdown: ShutdownOptions
31
+ // false disables CORS entirely — for a service reached only by other
32
+ // services, where no browser is involved.
33
+ cors?: CorsOptions | false
34
+ // Application teardown, run once the listener has stopped accepting:
35
+ // finish in-flight work, close pools, flush.
36
+ drain?: () => Promise<void> | void
37
+ }
38
+
39
+ export interface RunningService<T> {
40
+ readonly server: Server<T>
41
+ // Set from the moment shutdown begins: the probes report it, and an
42
+ // application can read it to stop taking on new work of its own.
43
+ readonly draining: boolean
44
+ // The shutdown sequence, as the signal handlers run it. 'forced' means the
45
+ // deadline elapsed first — the caller decides what that is worth, which is
46
+ // what lets a test drive this without the process exiting.
47
+ stop(): Promise<'clean' | 'forced'>
48
+ }
49
+
50
+ // Runs a service binary: bind the port, answer the probes, and shut down on
51
+ // SIGINT/SIGTERM the same way every time — unready first, then stop accepting,
52
+ // then drain, all inside one deadline.
53
+ export const serve = <T>(options: ServeOptions<T>): RunningService<T> => {
54
+ let draining = false
55
+ const app: FetchHandler<T> = (request, server) => {
56
+ const { pathname } = new URL(request.url)
57
+ if (pathname === '/healthz') {
58
+ return healthz(draining)
59
+ }
60
+ if (pathname === '/readyz') {
61
+ return readyz(options.checks ?? [], options.probeTimeoutMs, draining)
62
+ }
63
+ return options.fetch(request, server)
64
+ }
65
+ const fetch = options.cors === false ? app : withCors(app, options.cors)
66
+ // Bun's options are two distinct shapes, and an explicit
67
+ // `websocket: undefined` fits neither. The shapes differ in their fetch
68
+ // signature too: only the WebSocket one allows a handler to answer
69
+ // nothing, which is how a successful upgrade is reported.
70
+ const server = options.websocket
71
+ ? Bun.serve<T, never>({
72
+ port: options.port,
73
+ fetch,
74
+ websocket: options.websocket
75
+ })
76
+ : Bun.serve<T, never>({
77
+ port: options.port,
78
+ fetch: async (request, srv) => {
79
+ const response = await fetch(request, srv)
80
+ if (!response) {
81
+ throw new Error(`${options.name}: no response for ${request.url}, and no WebSocket handler configured that could have upgraded it`)
82
+ }
83
+ return response
84
+ }
85
+ })
86
+ console.log(`${options.name} listening on ${server.url.href}`)
87
+
88
+ const stop = async (): Promise<'clean' | 'forced'> => {
89
+ draining = true
90
+ const { deadlineMs, lameDuckMs } = options.shutdown
91
+ let timer: ReturnType<typeof setTimeout> | undefined
92
+ const expiry = new Promise<'forced'>(resolve => {
93
+ timer = setTimeout(() => resolve('forced'), deadlineMs)
94
+ })
95
+ const sequence = async (): Promise<'clean'> => {
96
+ await Bun.sleep(lameDuckMs)
97
+ await server.stop()
98
+ await options.drain?.()
99
+ return 'clean'
100
+ }
101
+ try {
102
+ return await Promise.race([sequence(), expiry])
103
+ } finally {
104
+ clearTimeout(timer)
105
+ }
106
+ }
107
+
108
+ const onSignal = (): void => {
109
+ void stop().then(outcome => {
110
+ if (outcome === 'forced') {
111
+ console.error(`${options.name}: shutdown deadline (${options.shutdown.deadlineMs}ms) exceeded, forcing exit`)
112
+ process.exit(1)
113
+ }
114
+ console.log(`${options.name} stopped`)
115
+ process.exit(0)
116
+ })
117
+ }
118
+ process.on('SIGINT', onSignal)
119
+ process.on('SIGTERM', onSignal)
120
+
121
+ return {
122
+ server,
123
+ get draining() {
124
+ return draining
125
+ },
126
+ stop
127
+ }
128
+ }