@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 +21 -0
- package/README.md +60 -0
- package/package.json +41 -0
- package/src/cors.ts +53 -0
- package/src/main.ts +3 -0
- package/src/probes.ts +57 -0
- package/src/serve.ts +128 -0
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
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
|
+
}
|