castrum 0.7.0 → 0.9.1
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 +154 -368
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +2763 -0
- package/dist/index.js +8707 -0
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +507 -0
- package/docs/BENCHMARKS.md +343 -0
- package/docs/CASE_STUDY.md +240 -0
- package/docs/ENVIRONMENT.md +81 -0
- package/docs/FFI_BUN_GUIDE.md +327 -0
- package/docs/GETTING_STARTED.md +192 -0
- package/docs/INGRESS-ROUTER.md +81 -0
- package/docs/INGRESS.md +746 -0
- package/docs/NATIVE-ROUTE.md +53 -0
- package/docs/REPO_MAP.md +279 -0
- package/docs/adr/0001-two-wire-formats.md +39 -0
- package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
- package/docs/adr/0003-bun-builtins-delegation.md +43 -0
- package/docs/bun-builtins-decision-matrix.md +115 -0
- package/index.ts +44 -8
- package/package.json +98 -33
- package/src/ingress/body.ts +289 -0
- package/src/ingress/constants.ts +220 -48
- package/src/ingress/context.ts +136 -0
- package/src/ingress/decode/baked-result.ts +121 -0
- package/src/ingress/decode/fast-result.ts +160 -0
- package/src/ingress/decode/packed-sections.ts +56 -0
- package/src/ingress/decode/result-base.ts +172 -0
- package/src/ingress/errors.ts +72 -0
- package/src/ingress/fast.ts +152 -1021
- package/src/ingress/handlers.ts +639 -1001
- package/src/ingress/headers/baked-templates.ts +102 -0
- package/src/ingress/headers/cors.ts +44 -0
- package/src/ingress/headers/fast-templates.ts +238 -0
- package/src/ingress/headers/hsts.ts +83 -0
- package/src/ingress/health.ts +68 -0
- package/src/ingress/index.ts +142 -405
- package/src/ingress/metrics.ts +132 -0
- package/src/ingress/options.ts +197 -0
- package/src/ingress/packing/gather-raw-headers.ts +139 -0
- package/src/ingress/packing/header-packing.ts +75 -0
- package/src/ingress/packing/input-packer.ts +164 -0
- package/src/ingress/packing/scratch.ts +50 -0
- package/src/ingress/packing/select-headers.ts +102 -0
- package/src/ingress/response/error-bodies.ts +75 -0
- package/src/ingress/response/terminal.ts +59 -0
- package/src/ingress/router.ts +237 -0
- package/src/ingress/routes/common.ts +79 -0
- package/src/ingress/routes/delete.ts +14 -0
- package/src/ingress/routes/echo.ts +107 -0
- package/src/ingress/routes/fallback.ts +23 -0
- package/src/ingress/routes/head.ts +27 -0
- package/src/ingress/routes/index.ts +11 -0
- package/src/ingress/routes/json-write.ts +129 -0
- package/src/ingress/routes/options.ts +26 -0
- package/src/ingress/routes/read.ts +35 -0
- package/src/ingress/routes/responder.ts +159 -0
- package/src/ingress/server-node.ts +376 -0
- package/src/ingress/server.ts +475 -0
- package/src/ingress/shared.ts +132 -0
- package/src/ingress/status.ts +97 -0
- package/src/ingress/types.ts +191 -0
- package/src/integration/batch.ts +58 -0
- package/src/integration/index.ts +29 -0
- package/src/integration/pipeline.ts +220 -0
- package/src/integration/streaming.ts +78 -0
- package/src/integration/websocket.ts +93 -0
- package/src/loader/batch.ts +137 -0
- package/src/loader/cost.ts +105 -0
- package/src/loader/index.ts +745 -0
- package/src/loader/ops.ts +657 -0
- package/src/loader/state.ts +84 -0
- package/src/loader/types.ts +145 -0
- package/src/native/ffi/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +729 -0
- package/src/native/ffi/types.ts +474 -0
- package/src/native/ffi.ts +1775 -0
- package/src/native/index.ts +8 -207
- package/src/native/loader.ts +240 -0
- package/src/native/types/instances.ts +274 -0
- package/src/native/types.ts +401 -0
- package/src/runtime/builtins.ts +131 -0
- package/src/runtime/codec.ts +147 -0
- package/src/runtime/detect.ts +36 -0
- package/src/runtime/index.ts +28 -0
- package/src/runtime/native.ts +35 -0
- package/src/runtime/server.ts +31 -0
- package/src/runtime/transport.ts +64 -0
- package/src/runtime/types.ts +173 -0
- package/src/runtime/uuid.ts +45 -0
- package/src/runtime/websocket.ts +30 -0
- package/src/rust-ffi/addon.ts +10 -0
- package/src/rust-ffi/batch/build.ts +155 -0
- package/src/rust-ffi/batch/index.ts +8 -0
- package/src/rust-ffi/batch/types.ts +92 -0
- package/src/rust-ffi/client.ts +87 -0
- package/src/rust-ffi/context.ts +193 -0
- package/src/rust-ffi/index.ts +51 -541
- package/src/rust-ffi/into.ts +21 -0
- package/src/rust-ffi/options.ts +113 -0
- package/src/rust-ffi/packed.ts +253 -0
- package/src/rust-ffi/proven.ts +28 -0
- package/src/rust-ffi/scalar/crypto.ts +346 -0
- package/src/rust-ffi/scalar/factories.ts +459 -0
- package/src/rust-ffi/scalar/hashing.ts +74 -0
- package/src/rust-ffi/scalar/http.ts +337 -0
- package/src/rust-ffi/scalar/index.ts +31 -0
- package/src/rust-ffi/scalar/interface.ts +325 -0
- package/src/rust-ffi/scalar/json-packed.ts +175 -0
- package/src/rust-ffi/scalar/json.ts +61 -0
- package/src/rust-ffi/scalar/payload.ts +241 -0
- package/src/rust-ffi/text.ts +121 -0
- package/src/selection.json +194 -0
- package/src/selection.ts +73 -0
- package/src/shared/adaptive.ts +67 -0
- package/src/shared/buffer-pool.ts +264 -0
- package/src/shared/bytes.ts +108 -9
- package/src/shared/codec.ts +78 -0
- package/src/shared/env.ts +20 -0
- package/src/shared/log.ts +75 -0
- package/src/shared/metrics.ts +247 -0
- package/src/shared/packed/index.ts +10 -0
- package/src/shared/packed/parsers.ts +31 -0
- package/src/shared/packed/schema.ts +23 -0
- package/src/shared/packed/wire.ts +504 -0
- package/src/shared/proven.ts +289 -0
- package/src/shared/request-id.ts +67 -0
- package/src/shared/response.ts +104 -0
- package/src/shared/runtime.ts +29 -0
- package/src/shared/trace.ts +81 -0
- package/src/shared/uuid.ts +15 -0
- package/Cargo.toml +0 -73
- package/bench.ts +0 -3
- package/rust/batch.rs +0 -195
- package/rust/cookie_parser.rs +0 -70
- package/rust/cors.rs +0 -263
- package/rust/hashing.rs +0 -164
- package/rust/headers.rs +0 -234
- package/rust/hmac_sha256.rs +0 -113
- package/rust/http_parser.rs +0 -132
- package/rust/ingress.rs +0 -616
- package/rust/ingress_constants.rs +0 -89
- package/rust/ip_trust.rs +0 -185
- package/rust/json_ops.rs +0 -66
- package/rust/json_patch_ops.rs +0 -25
- package/rust/json_schema.rs +0 -151
- package/rust/json_ser.rs +0 -429
- package/rust/lib.rs +0 -48
- package/rust/method.rs +0 -141
- package/rust/mime_lookup.rs +0 -76
- package/rust/output.rs +0 -238
- package/rust/proxy.rs +0 -90
- package/rust/query_parser.rs +0 -141
- package/rust/random_token.rs +0 -52
- package/rust/rate_limit.rs +0 -203
- package/rust/terminal.rs +0 -95
- package/rust/unit_tests.rs +0 -843
- package/rust/url_codec.rs +0 -133
- package/rust/util.rs +0 -537
- package/rust/validation.rs +0 -107
- package/rust/websocket.rs +0 -23
- package/src/baseline/index.ts +0 -12
- package/src/baseline/tasks/cookie.ts +0 -22
- package/src/baseline/tasks/hashing.ts +0 -18
- package/src/baseline/tasks/hmac.ts +0 -38
- package/src/baseline/tasks/http.ts +0 -62
- package/src/baseline/tasks/json-patch.ts +0 -12
- package/src/baseline/tasks/json.ts +0 -50
- package/src/baseline/tasks/mime.ts +0 -5
- package/src/baseline/tasks/query.ts +0 -14
- package/src/baseline/tasks/token.ts +0 -15
- package/src/baseline/tasks/url.ts +0 -11
- package/src/baseline/tasks/validation.ts +0 -19
- package/src/baseline/tasks/websocket.ts +0 -14
- package/src/bench/assert.ts +0 -70
- package/src/bench/checks.ts +0 -227
- package/src/bench/checksum.ts +0 -27
- package/src/bench/comparisons.ts +0 -70
- package/src/bench/concurrent-worker.ts +0 -135
- package/src/bench/fixtures.ts +0 -233
- package/src/bench/index.ts +0 -2
- package/src/bench/measure.ts +0 -177
- package/src/bench/now.ts +0 -3
- package/src/bench/report.ts +0 -79
- package/src/bench/run.ts +0 -57
- package/src/bench/tasks/complex.ts +0 -232
- package/src/bench/tasks/concurrent.ts +0 -213
- package/src/bench/tasks/cookie.ts +0 -44
- package/src/bench/tasks/hashing.ts +0 -33
- package/src/bench/tasks/hmac.ts +0 -36
- package/src/bench/tasks/http.ts +0 -50
- package/src/bench/tasks/index.ts +0 -52
- package/src/bench/tasks/json-patch.ts +0 -21
- package/src/bench/tasks/json.ts +0 -33
- package/src/bench/tasks/mime.ts +0 -21
- package/src/bench/tasks/query.ts +0 -44
- package/src/bench/tasks/stress.ts +0 -142
- package/src/bench/tasks/token.ts +0 -20
- package/src/bench/tasks/url.ts +0 -34
- package/src/bench/tasks/validation.ts +0 -57
- package/src/bench/tasks/websocket.ts +0 -21
- package/src/bench/types.ts +0 -60
- package/src/data/json-rows.ts +0 -33
- package/src/ingress/packed-input.ts +0 -59
- package/src/shared/json.ts +0 -18
- package/src/shared/packed.ts +0 -316
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// src/ingress/server-node.ts — Node.js HTTP adapter for the pre-baked ingress
|
|
2
|
+
// route handlers.
|
|
3
|
+
//
|
|
4
|
+
// Bun remains the primary server target (`createIngressServer` in server.ts
|
|
5
|
+
// uses Bun.serve). This adapter lets the SAME pre-baked route handlers run
|
|
6
|
+
// under Node.js via `node:http`. Route handlers are web-standard
|
|
7
|
+
// (`Request` → `Response`), so the adapter only translates between Node's
|
|
8
|
+
// `IncomingMessage`/`ServerResponse` and the web `Request`/`Response` types.
|
|
9
|
+
//
|
|
10
|
+
// Differences vs Bun: no `reusePort`, `maxRequestBodySize` is not enforced at
|
|
11
|
+
// the socket level (the route handlers still enforce `maxBodyBytes` after
|
|
12
|
+
// reading, matching the Bun path's current behavior), and `getIp` receives the
|
|
13
|
+
// Node `IncomingMessage` (which exposes `socket.remoteAddress`, not
|
|
14
|
+
// `srv.requestIP`).
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
createServer,
|
|
18
|
+
type Server as HttpServer,
|
|
19
|
+
type IncomingMessage,
|
|
20
|
+
type ServerResponse,
|
|
21
|
+
} from 'node:http'
|
|
22
|
+
import { type Duplex, Readable } from 'node:stream'
|
|
23
|
+
import { fallbackHandler } from './routes'
|
|
24
|
+
import {
|
|
25
|
+
type BakedServer,
|
|
26
|
+
buildPathMatcher,
|
|
27
|
+
buildRouteHandlers,
|
|
28
|
+
type CreateIngressServerOptions,
|
|
29
|
+
type RouteHandler,
|
|
30
|
+
type ServerHandle,
|
|
31
|
+
} from './server'
|
|
32
|
+
|
|
33
|
+
export type { RouteHandler } from './server'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Default socket-level guard for receiving request headers/body (ms). Node's
|
|
37
|
+
* `requestTimeout` / `headersTimeout` default to 0 (disabled); castrum opts
|
|
38
|
+
* into a 30s cap to reject slowloris/trickling sockets. Distinct from the
|
|
39
|
+
* route-level `bodyTimeoutMs` (`DEFAULT_BODY_TIMEOUT_MS`), which guards the
|
|
40
|
+
* body-read loop after headers arrive.
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULT_SOCKET_TIMEOUT_MS = 30_000
|
|
43
|
+
|
|
44
|
+
import type { BakedHandlerOptions } from './routes/common'
|
|
45
|
+
|
|
46
|
+
/** A Node.js ingress server: {@link BakedServer} plus an async ready signal. */
|
|
47
|
+
export interface NodeIngressServer extends BakedServer {
|
|
48
|
+
/**
|
|
49
|
+
* Resolves to the actually-bound port once the server is listening
|
|
50
|
+
* (`node:http` binds asynchronously; useful when `port: 0`).
|
|
51
|
+
*/
|
|
52
|
+
ready: Promise<number>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Convert a Node IncomingMessage into a web-standard Request. */
|
|
56
|
+
function nodeRequestToWebRequest(req: IncomingMessage): Request {
|
|
57
|
+
const host = req.headers.host ?? 'localhost'
|
|
58
|
+
const url = new URL(req.url ?? '/', `http://${host}`)
|
|
59
|
+
|
|
60
|
+
const headers = new Headers()
|
|
61
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
62
|
+
if (value === undefined) continue
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
for (const v of value) headers.append(key, v)
|
|
65
|
+
} else {
|
|
66
|
+
headers.set(key, value)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const method = req.method ?? 'GET'
|
|
71
|
+
// The fetch Request constructor forbids a body on GET/HEAD, but allows it on
|
|
72
|
+
// POST/PUT/PATCH/DELETE/OPTIONS etc. Attach the body stream whenever the
|
|
73
|
+
// request actually carries one (content-length > 0 or chunked), so DELETE/
|
|
74
|
+
// OPTIONS-with-a-body are no longer dropped.
|
|
75
|
+
const bodyForbidden = method === 'GET' || method === 'HEAD'
|
|
76
|
+
const hasBody =
|
|
77
|
+
Number(req.headers['content-length'] ?? 0) > 0 || req.headers['transfer-encoding'] !== undefined
|
|
78
|
+
|
|
79
|
+
const init: RequestInit & { duplex?: 'half' } = { method, headers }
|
|
80
|
+
|
|
81
|
+
// Stream the request body through (the route handlers call
|
|
82
|
+
// `req.arrayBuffer()` which drains this stream).
|
|
83
|
+
if (hasBody && !bodyForbidden) {
|
|
84
|
+
init.body = Readable.toWeb(req) as ReadableStream<Uint8Array>
|
|
85
|
+
init.duplex = 'half'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return new Request(url.toString(), init)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Write a web Response back to a Node ServerResponse. */
|
|
92
|
+
async function writeResponse(res: ServerResponse, response: Response): Promise<void> {
|
|
93
|
+
// If the handler answered without consuming the request body (e.g. an early
|
|
94
|
+
// 400/413/415 guard), drain it BEFORE ending the response: Node only reuses a
|
|
95
|
+
// keep-alive socket once the request is fully read, and calling resume()
|
|
96
|
+
// after end() is too late — the unread bytes would bleed into the next
|
|
97
|
+
// request's parse (spurious 400s).
|
|
98
|
+
drainUnreadBody(res)
|
|
99
|
+
|
|
100
|
+
res.statusCode = response.status
|
|
101
|
+
|
|
102
|
+
if (response.headers.has('set-cookie')) {
|
|
103
|
+
res.setHeader('set-cookie', response.headers.getSetCookie())
|
|
104
|
+
}
|
|
105
|
+
for (const [key, value] of response.headers) {
|
|
106
|
+
if (key.toLowerCase() === 'set-cookie') continue
|
|
107
|
+
res.setHeader(key, value)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// HEAD requests must not carry a body.
|
|
111
|
+
if (res.req?.method === 'HEAD') {
|
|
112
|
+
res.end()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (response.body !== null) {
|
|
117
|
+
const reader = response.body.getReader()
|
|
118
|
+
try {
|
|
119
|
+
for (;;) {
|
|
120
|
+
const { done, value } = await reader.read()
|
|
121
|
+
if (done) break
|
|
122
|
+
if (!res.write(value)) {
|
|
123
|
+
await new Promise<void>((resolve) => res.once('drain', resolve))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
reader.releaseLock()
|
|
128
|
+
}
|
|
129
|
+
res.end()
|
|
130
|
+
} else {
|
|
131
|
+
res.end(Buffer.from(await response.arrayBuffer()))
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Handle a response whose request body was never consumed (early 4xx guards).
|
|
137
|
+
*
|
|
138
|
+
* Empirically Node CANNOT safely reuse a keep-alive socket here: `resume()`
|
|
139
|
+
* alone still lets the unread bytes bleed into the next request's parse
|
|
140
|
+
* (spurious 400s — verified with a minimal repro). The safe behavior is to
|
|
141
|
+
* drain best-effort AND force `Connection: close` so the socket ends cleanly
|
|
142
|
+
* instead of corrupting the next request.
|
|
143
|
+
*/
|
|
144
|
+
function drainUnreadBody(res: ServerResponse): void {
|
|
145
|
+
const req = res.req
|
|
146
|
+
if (req !== undefined && req !== null && !req.complete && hasBody(req)) {
|
|
147
|
+
req.resume()
|
|
148
|
+
// Only force-close when the request actually carried a body that was not
|
|
149
|
+
// consumed. A bodiless GET/HEAD is always safe to keep alive (bun's
|
|
150
|
+
// node:http reports `complete` differently, so don't trust it alone).
|
|
151
|
+
res.shouldKeepAlive = false
|
|
152
|
+
if (!res.headersSent) {
|
|
153
|
+
res.setHeader('connection', 'close')
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Whether an incoming request carried a body (content-length or chunked). */
|
|
159
|
+
function hasBody(req: IncomingMessage): boolean {
|
|
160
|
+
return (
|
|
161
|
+
Number(req.headers['content-length'] ?? 0) > 0 || req.headers['transfer-encoding'] !== undefined
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Build a request listener that dispatches to the shared route map. */
|
|
166
|
+
function makeRequestListener(
|
|
167
|
+
options: CreateIngressServerOptions,
|
|
168
|
+
routes: Record<string, Record<string, unknown>>,
|
|
169
|
+
baseOpts: BakedHandlerOptions,
|
|
170
|
+
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
|
171
|
+
// Exact paths win; `:param`/`*` patterns are matched most-specific-first.
|
|
172
|
+
const matchPath = buildPathMatcher(routes)
|
|
173
|
+
|
|
174
|
+
return async (req, res) => {
|
|
175
|
+
try {
|
|
176
|
+
// Socket-level body cap: reject oversized requests BEFORE any body is
|
|
177
|
+
// buffered (mirrors Bun's `maxRequestBodySize` on the Node adapter).
|
|
178
|
+
const contentLengthHeader = req.headers['content-length']
|
|
179
|
+
if (
|
|
180
|
+
options.maxRequestBodySize !== undefined &&
|
|
181
|
+
contentLengthHeader !== undefined &&
|
|
182
|
+
Number(contentLengthHeader) > options.maxRequestBodySize
|
|
183
|
+
) {
|
|
184
|
+
// Reject before buffering; Node cannot safely reuse a keep-alive socket
|
|
185
|
+
// with an unread body, so force the connection to close (no corruption
|
|
186
|
+
// of the next request).
|
|
187
|
+
req.resume()
|
|
188
|
+
res.shouldKeepAlive = false
|
|
189
|
+
res.setHeader('connection', 'close')
|
|
190
|
+
const body = JSON.stringify({
|
|
191
|
+
error: { code: 'body_too_large', message: 'Request body is too large' },
|
|
192
|
+
})
|
|
193
|
+
res.statusCode = 413
|
|
194
|
+
res.setHeader('content-type', 'application/json')
|
|
195
|
+
res.setHeader('content-length', String(Buffer.byteLength(body)))
|
|
196
|
+
res.end(body)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const webReq = nodeRequestToWebRequest(req)
|
|
201
|
+
const pathname = new URL(webReq.url).pathname
|
|
202
|
+
const method = webReq.method ?? 'GET'
|
|
203
|
+
|
|
204
|
+
const matched = matchPath(pathname)
|
|
205
|
+
const handler = matched?.methods?.[method] as RouteHandler | undefined
|
|
206
|
+
|
|
207
|
+
let response: Response
|
|
208
|
+
if (handler !== undefined && matched !== undefined) {
|
|
209
|
+
// The ingress pipeline does not echo path params, so matching only
|
|
210
|
+
// selects the handler; the extracted params are passed as a 3rd arg
|
|
211
|
+
// (ignored by current handlers) for future/raw-handler use.
|
|
212
|
+
response = await handler(webReq, req, matched.params)
|
|
213
|
+
} else if (options.fallback !== undefined) {
|
|
214
|
+
const fallback = fallbackHandler(options.fallback, baseOpts)
|
|
215
|
+
response = await fallback(webReq, req)
|
|
216
|
+
} else {
|
|
217
|
+
response = new Response('Not Found', { status: 404 })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
await writeResponse(res, response)
|
|
221
|
+
} catch (err) {
|
|
222
|
+
// Never leave a client hanging on a handler failure.
|
|
223
|
+
if (!res.headersSent) {
|
|
224
|
+
res.statusCode = 500
|
|
225
|
+
res.setHeader('content-type', 'application/json')
|
|
226
|
+
res.end(
|
|
227
|
+
JSON.stringify({
|
|
228
|
+
error: { code: 'internal_error', message: 'Internal Server Error' },
|
|
229
|
+
}),
|
|
230
|
+
)
|
|
231
|
+
} else {
|
|
232
|
+
res.destroy()
|
|
233
|
+
}
|
|
234
|
+
// NOTE: surfaced via the onError hook (observability phase).
|
|
235
|
+
if (options.onError) {
|
|
236
|
+
try {
|
|
237
|
+
options.onError({
|
|
238
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
239
|
+
})
|
|
240
|
+
} catch {
|
|
241
|
+
// hook must never crash the server
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Handle a WebSocket upgrade on the Node adapter. Node's `Response` cannot
|
|
250
|
+
* carry status 101 (undici only allows 200-599), so `createWebSocketUpgrade`
|
|
251
|
+
* is Bun-only. Here the caller supplies the RFC 6455 handshake VALUES via
|
|
252
|
+
* `options.upgrade` (accept key + optional negotiated subprotocol); we write
|
|
253
|
+
* the 101 head on the hijacked socket and hand it to `options.onUpgrade`
|
|
254
|
+
* (which owns the frame codec — e.g. the `ws` library). Previously a 101 was
|
|
255
|
+
* written as a normal response and the connection never became a socket.
|
|
256
|
+
*/
|
|
257
|
+
function makeUpgradeListener(
|
|
258
|
+
options: CreateIngressServerOptions,
|
|
259
|
+
): (req: IncomingMessage, socket: Duplex, head: Buffer) => Promise<void> {
|
|
260
|
+
return async (req, socket, head) => {
|
|
261
|
+
try {
|
|
262
|
+
const webReq = nodeRequestToWebRequest(req)
|
|
263
|
+
const handshake = options.upgrade?.(webReq)
|
|
264
|
+
if (!handshake?.accept) {
|
|
265
|
+
// No valid handshake — decline the upgrade (never reuse the socket).
|
|
266
|
+
socket.destroy()
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Complete the RFC 6455 handshake on the hijacked socket.
|
|
271
|
+
let raw =
|
|
272
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
273
|
+
'Upgrade: websocket\r\n' +
|
|
274
|
+
'Connection: Upgrade\r\n' +
|
|
275
|
+
`Sec-WebSocket-Accept: ${handshake.accept}\r\n`
|
|
276
|
+
if (handshake.protocol) {
|
|
277
|
+
raw += `Sec-WebSocket-Protocol: ${handshake.protocol}\r\n`
|
|
278
|
+
}
|
|
279
|
+
raw += '\r\n'
|
|
280
|
+
socket.write(raw)
|
|
281
|
+
|
|
282
|
+
// Hand the upgraded socket to the caller (frame codec / message loop).
|
|
283
|
+
options.onUpgrade?.(socket, req, head)
|
|
284
|
+
} catch {
|
|
285
|
+
// Never leave a half-open socket on a handshake failure.
|
|
286
|
+
socket.destroy()
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Start a Node.js `node:http` server serving the same pre-baked ingress route
|
|
293
|
+
* handlers as {@link createIngressServer}. Returns a {@link NodeIngressServer};
|
|
294
|
+
* `stop()` drains in-flight connections (graceful) before closing, and
|
|
295
|
+
* `ready` resolves to the bound port once listening.
|
|
296
|
+
*
|
|
297
|
+
* Requires Node.js >= 20.3 (N-API floor). Bun users should prefer
|
|
298
|
+
* {@link createIngressServer} (Bun.serve).
|
|
299
|
+
*/
|
|
300
|
+
export function createIngressServerNode(options: CreateIngressServerOptions): NodeIngressServer {
|
|
301
|
+
const { routes, baseOpts } = buildRouteHandlers(options)
|
|
302
|
+
|
|
303
|
+
const server: HttpServer = createServer(makeRequestListener(options, routes, baseOpts))
|
|
304
|
+
|
|
305
|
+
// Map Bun-style options onto Node equivalents + harden slowloris/DoS.
|
|
306
|
+
server.keepAliveTimeout = (options.idleTimeout ?? 30) * 1000
|
|
307
|
+
server.requestTimeout = options.requestTimeoutMs ?? DEFAULT_SOCKET_TIMEOUT_MS
|
|
308
|
+
server.headersTimeout = options.headersTimeoutMs ?? DEFAULT_SOCKET_TIMEOUT_MS
|
|
309
|
+
// Apply unconditionally: Node's own default is 0 (unlimited), so the
|
|
310
|
+
// documented `maxRequestsPerSocket ?? 1000` must always land here — a
|
|
311
|
+
// conditional write could silently leave the unlimited default in place.
|
|
312
|
+
server.maxRequestsPerSocket = options.maxRequestsPerSocket ?? 1000
|
|
313
|
+
|
|
314
|
+
// Malformed requests → castrum's JSON error shape (not Node's raw 400/431).
|
|
315
|
+
server.on('clientError', (err, socket) => {
|
|
316
|
+
if (!socket.writable) {
|
|
317
|
+
socket.destroy()
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
// Header-too-large parse failures are 431, everything else 400.
|
|
321
|
+
const isHeaderOverflow =
|
|
322
|
+
(err as NodeJS.ErrnoException | undefined)?.code === 'HPE_HEADER_OVERFLOW'
|
|
323
|
+
const status = isHeaderOverflow ? 431 : 400
|
|
324
|
+
const reason = isHeaderOverflow ? 'Request Header Fields Too Large' : 'Bad Request'
|
|
325
|
+
const code = isHeaderOverflow ? 'headers_too_large' : 'bad_request'
|
|
326
|
+
const body = JSON.stringify({
|
|
327
|
+
error: { code, message: reason },
|
|
328
|
+
})
|
|
329
|
+
socket.end(
|
|
330
|
+
`HTTP/1.1 ${status} ${reason}\r\n` +
|
|
331
|
+
'Content-Type: application/json\r\n' +
|
|
332
|
+
`Content-Length: ${Buffer.byteLength(body)}\r\n` +
|
|
333
|
+
'Connection: close\r\n\r\n' +
|
|
334
|
+
body,
|
|
335
|
+
)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
// WebSocket upgrades: only register a listener when the caller opted in
|
|
339
|
+
// (otherwise Node's default — destroy the socket — applies unchanged).
|
|
340
|
+
if (options.upgrade || options.onUpgrade) {
|
|
341
|
+
server.on('upgrade', makeUpgradeListener(options))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const hostname = options.hostname ?? '0.0.0.0'
|
|
345
|
+
|
|
346
|
+
const ready = new Promise<number>((resolve, reject) => {
|
|
347
|
+
server.once('listening', () => {
|
|
348
|
+
const addr = server.address()
|
|
349
|
+
resolve(typeof addr === 'object' && addr !== null ? addr.port : options.port)
|
|
350
|
+
})
|
|
351
|
+
server.once('error', reject)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
server.listen(options.port, hostname)
|
|
355
|
+
|
|
356
|
+
const handle: ServerHandle = {
|
|
357
|
+
get port(): number {
|
|
358
|
+
const addr = server.address()
|
|
359
|
+
return typeof addr === 'object' && addr !== null ? addr.port : options.port
|
|
360
|
+
},
|
|
361
|
+
stop: (force?: boolean) => {
|
|
362
|
+
if (force) {
|
|
363
|
+
server.closeAllConnections?.()
|
|
364
|
+
}
|
|
365
|
+
// Graceful: stop accepting and drain in-flight connections.
|
|
366
|
+
server.close()
|
|
367
|
+
},
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
server: handle,
|
|
372
|
+
stop: () => handle.stop(),
|
|
373
|
+
port: options.port,
|
|
374
|
+
ready,
|
|
375
|
+
}
|
|
376
|
+
}
|