castrum 0.9.0 → 0.9.2
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/README.md +153 -630
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1653 -1817
- package/dist/index.js +6172 -4294
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +111 -17
- package/docs/BENCHMARKS.md +117 -49
- package/docs/CASE_STUDY.md +243 -0
- package/docs/ENVIRONMENT.md +24 -7
- package/docs/FFI_BUN_GUIDE.md +335 -0
- package/docs/GETTING_STARTED.md +18 -34
- package/docs/HOW_TO_ADD_AN_OP.md +180 -0
- package/docs/INGRESS-ROUTER.md +82 -0
- package/docs/INGRESS.md +11 -7
- package/docs/NATIVE-ROUTE.md +77 -0
- package/docs/REPO_MAP.md +103 -53
- package/docs/adr/0001-two-wire-formats.md +1 -1
- package/docs/adr/0003-bun-builtins-delegation.md +6 -5
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +1070 -0
- package/docs/bun-builtins-decision-matrix.md +25 -18
- package/index.ts +9 -16
- package/package.json +41 -28
- package/src/ingress/body.ts +167 -27
- package/src/ingress/decode/result-base.ts +10 -2
- package/src/ingress/handlers.ts +116 -285
- package/src/ingress/headers/security.ts +41 -0
- package/src/ingress/health.ts +1 -0
- package/src/ingress/index.ts +45 -169
- package/src/ingress/native-route.ts +182 -0
- package/src/ingress/packing/gather-raw-headers.ts +110 -22
- package/src/ingress/packing/header-packing.ts +5 -5
- package/src/ingress/packing/input-packer.ts +17 -3
- package/src/ingress/packing/route-wire.ts +197 -0
- package/src/ingress/packing/select-headers.ts +20 -7
- package/src/ingress/path-matcher.ts +115 -0
- package/src/ingress/response/baked-response.ts +302 -0
- package/src/ingress/router.ts +277 -0
- package/src/ingress/routes/common.ts +50 -1
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +3 -13
- package/src/ingress/routes/index.ts +5 -4
- package/src/ingress/routes/json-write.ts +26 -47
- package/src/ingress/routes/native.ts +130 -0
- package/src/ingress/routes/options.ts +2 -12
- package/src/ingress/routes/read.ts +3 -13
- package/src/ingress/routes/responder.ts +159 -0
- package/src/ingress/server-node.ts +7 -7
- package/src/ingress/server.ts +83 -112
- package/src/ingress/sync.ts +151 -0
- package/src/ingress/types.ts +44 -1
- package/src/integration/pipeline.ts +3 -0
- package/src/integration/streaming.ts +2 -3
- package/src/integration/websocket.ts +4 -3
- package/src/loader/batch.ts +4 -0
- package/src/loader/cost.ts +1 -0
- package/src/loader/index.ts +43 -228
- package/src/loader/state.ts +84 -0
- package/src/loader/types.ts +145 -0
- package/src/native/ffi/build/codecs.ts +736 -0
- package/src/native/ffi/build/compress.ts +248 -0
- package/src/native/ffi/build/instances.ts +598 -0
- package/src/native/ffi/build/parse.ts +539 -0
- package/src/native/ffi/build/util.ts +140 -0
- package/src/native/ffi/build.ts +94 -0
- package/src/native/ffi/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +22 -0
- package/src/native/ffi/types.ts +499 -0
- package/src/native/ffi.ts +307 -1357
- package/src/native/loader.ts +52 -6
- package/src/native/types/instances.ts +279 -0
- package/src/native/types.ts +41 -238
- 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 +1 -1
- 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 +5 -6
- package/src/rust-ffi/context.ts +39 -10
- package/src/rust-ffi/index.ts +28 -17
- package/src/rust-ffi/into.ts +21 -0
- package/src/rust-ffi/options.ts +1 -1
- package/src/rust-ffi/packed.ts +40 -2
- package/src/rust-ffi/proven.ts +16 -30
- package/src/rust-ffi/scalar/crypto.ts +188 -107
- package/src/rust-ffi/scalar/factories.ts +257 -27
- package/src/rust-ffi/scalar/hashing.ts +43 -41
- package/src/rust-ffi/scalar/http.ts +208 -77
- package/src/rust-ffi/scalar/index.ts +3 -3
- package/src/rust-ffi/scalar/interface.ts +88 -213
- package/src/rust-ffi/scalar/json-packed.ts +175 -0
- package/src/rust-ffi/scalar/json.ts +28 -12
- package/src/rust-ffi/scalar/payload.ts +159 -42
- package/src/rust-ffi/text.ts +31 -5
- package/src/selection.ts +27 -31
- package/src/shared/adaptive.ts +1 -2
- package/src/shared/buffer-pool.ts +72 -17
- package/src/shared/bytes.ts +75 -6
- package/src/shared/codec.ts +78 -0
- package/src/shared/env.ts +4 -4
- package/src/shared/log.ts +1 -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.ts → packed/wire.ts} +31 -39
- package/src/shared/proven.ts +189 -496
- package/src/shared/runtime.ts +20 -25
- package/src/shared/uuid.ts +12 -28
- package/src/data/json-rows.ts +0 -33
- package/src/rust-ffi/batch.ts +0 -431
- package/src/shared/bench-classify.ts +0 -209
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
4
4
|
import type { BakedIngressResult } from '../decode/baked-result'
|
|
5
|
-
import {
|
|
6
|
-
import { secondsFromMs } from '../shared'
|
|
5
|
+
import { buildSuccessInit, runBaked, type BakedHandlerOptions } from './common'
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* Pre-baked GET read handler: returns the ingress body JSON on success.
|
|
@@ -25,21 +24,12 @@ export function readHandler(
|
|
|
25
24
|
return ingress.internalErrorResponse(ctx, result)
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
const init
|
|
29
|
-
status: 200,
|
|
30
|
-
headers: ingress.responseHeaders(
|
|
31
|
-
result.headerVariant,
|
|
32
|
-
ctx.requestIdHeader,
|
|
33
|
-
ctx.origin,
|
|
34
|
-
result.rateRemaining,
|
|
35
|
-
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
36
|
-
),
|
|
37
|
-
}
|
|
27
|
+
const init = buildSuccessInit(ingress, result, ctx)
|
|
38
28
|
|
|
39
29
|
return copyBody
|
|
40
30
|
? new Response(result.bodyJson(true), init)
|
|
41
31
|
: ingress.zeroCopyResponse(result, ctx, init)
|
|
42
32
|
}
|
|
43
33
|
|
|
44
|
-
return (
|
|
34
|
+
return runBaked(ingress, opts, respond)
|
|
45
35
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// src/ingress/routes/responder.ts — JS responder bridge over the native
|
|
2
|
+
// pipeline ("native decides, JS formats the 2xx").
|
|
3
|
+
//
|
|
4
|
+
// The responder bridge is the castrum foundation for delegating a WHOLE
|
|
5
|
+
// request to the per-route native `IngressInner` while keeping the 2xx body in
|
|
6
|
+
// JS: the native pipeline runs parse/validate/CORS/rate-limit/limits and either
|
|
7
|
+
// produces a terminal (rejection) response — in the configured envelope style —
|
|
8
|
+
// or hands a decoded snapshot to a `NativeResponder` that builds the 2xx.
|
|
9
|
+
//
|
|
10
|
+
// The sync/async boundary is deliberate: `OptimizedIngressHandler.run()` is
|
|
11
|
+
// SYNC-only (`assertSyncCallback` rejects thenables), so the factory captures
|
|
12
|
+
// the decoded snapshot (requestId/query/cookies/body/verdicts) INSIDE the sync
|
|
13
|
+
// callback and calls the async `responder` OUTSIDE it, after the pooled output
|
|
14
|
+
// buffer has been released. `result.body` is the caller-provided request body
|
|
15
|
+
// (never the pooled output buffer), so the snapshot's `body` is safe to hold.
|
|
16
|
+
|
|
17
|
+
import { decoder } from '../../shared/bytes'
|
|
18
|
+
import { generateRequestId } from '../../shared/request-id'
|
|
19
|
+
import { readBodyWithLimit } from '../body'
|
|
20
|
+
import type { BakedIngressResult } from '../decode/baked-result'
|
|
21
|
+
import { errorCodeName, errorMessage } from '../errors'
|
|
22
|
+
import type { RouteHandler } from '../server'
|
|
23
|
+
import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from '../shared'
|
|
24
|
+
import { safeTerminalStatus } from '../status'
|
|
25
|
+
import type {
|
|
26
|
+
BakedContext,
|
|
27
|
+
NativeRequestContext,
|
|
28
|
+
NativeResponder,
|
|
29
|
+
OptimizedIngressHandler,
|
|
30
|
+
TerminalStyle,
|
|
31
|
+
} from '../types'
|
|
32
|
+
import { type BakedHandlerOptions, resolveIp } from './common'
|
|
33
|
+
|
|
34
|
+
/** Options for {@link nativeResponderRoute}. */
|
|
35
|
+
export interface ResponderRouteOptions extends BakedHandlerOptions {
|
|
36
|
+
/** Terminal envelope style. Default: `'castrum'` (the pre-baked wire). */
|
|
37
|
+
terminalStyle?: TerminalStyle
|
|
38
|
+
/**
|
|
39
|
+
* Read the request body and pass it to the native pipeline for validation
|
|
40
|
+
* (`requireJsonBody`/schema). Default `false` — the framework owns the body
|
|
41
|
+
* (the responder reads it via `ctx.body`); native only needs the bytes when
|
|
42
|
+
* a body stage is enabled in the route's `IngressHandlerOptions`.
|
|
43
|
+
*/
|
|
44
|
+
readBody?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** ignex framework security posture for terminal responses. */
|
|
48
|
+
const IGNGEX_SECURITY_HEADERS: [string, string][] = [
|
|
49
|
+
['x-frame-options', 'DENY'],
|
|
50
|
+
['x-content-type-options', 'nosniff'],
|
|
51
|
+
['referrer-policy', 'no-referrer'],
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
/** Build the ignex-framework terminal envelope (`{error,status,code}` + security headers). */
|
|
55
|
+
function buildIgngexTerminal(result: BakedIngressResult, _ctx: BakedContext): Response {
|
|
56
|
+
const preflightAllowed = result.isPreflight && result.corsAllowed
|
|
57
|
+
if (preflightAllowed) {
|
|
58
|
+
return new Response(null, { status: 204, headers: IGNGEX_SECURITY_HEADERS })
|
|
59
|
+
}
|
|
60
|
+
const status = safeTerminalStatus(result)
|
|
61
|
+
const body = JSON.stringify({
|
|
62
|
+
error: errorMessage(status, result.errorCode),
|
|
63
|
+
status,
|
|
64
|
+
code: errorCodeName(result.errorCode),
|
|
65
|
+
})
|
|
66
|
+
const headers = new Headers(IGNGEX_SECURITY_HEADERS)
|
|
67
|
+
headers.set('content-type', 'application/json; charset=utf-8')
|
|
68
|
+
return new Response(body, { status, headers })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Parse a `queryJson()`/`cookiesJson()` section into an object (empty on failure). */
|
|
72
|
+
function parseSection(json: string): Record<string, unknown> {
|
|
73
|
+
try {
|
|
74
|
+
const v: unknown = JSON.parse(json)
|
|
75
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
76
|
+
? (v as Record<string, unknown>)
|
|
77
|
+
: {}
|
|
78
|
+
} catch {
|
|
79
|
+
return {}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build a responder route: native pipeline decides + rejects (terminal
|
|
85
|
+
* response in the chosen style); on success the JS `responder` builds the 2xx.
|
|
86
|
+
* Returns an async `RouteHandler` (`(req, srv?, params?) => Promise<Response>`).
|
|
87
|
+
*/
|
|
88
|
+
export function nativeResponderRoute(
|
|
89
|
+
ingress: OptimizedIngressHandler,
|
|
90
|
+
responder: NativeResponder,
|
|
91
|
+
opts: ResponderRouteOptions = {},
|
|
92
|
+
): RouteHandler {
|
|
93
|
+
const terminalStyle: TerminalStyle = opts.terminalStyle ?? 'castrum'
|
|
94
|
+
const readBody = opts.readBody ?? false
|
|
95
|
+
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
96
|
+
const bodyTimeoutMs = opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
|
|
97
|
+
|
|
98
|
+
return async (req, srv, _params) => {
|
|
99
|
+
const ip = resolveIp(req, srv, opts)
|
|
100
|
+
// The request-id the framework surfaces (same source as the pre-baked path's
|
|
101
|
+
// `ctx.requestIdHeader`): the baked result does not carry it, so generate it
|
|
102
|
+
// here — the native pipeline uses the same rid for its internal frame.
|
|
103
|
+
const requestId = decoder.decode(generateRequestId())
|
|
104
|
+
// Read the body ONLY when native validation needs it (the framework owns
|
|
105
|
+
// the body otherwise). A `BODY_TOO_LARGE`/`REQUEST_TIMEOUT` throw becomes
|
|
106
|
+
// a terminal 413/408 from the native path.
|
|
107
|
+
let body: Uint8Array | null = null
|
|
108
|
+
if (readBody) {
|
|
109
|
+
try {
|
|
110
|
+
body = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
|
|
111
|
+
} catch (err) {
|
|
112
|
+
const code = (err as Error & { code?: string }).code
|
|
113
|
+
const status = code === 'REQUEST_TIMEOUT' ? 408 : code === 'BODY_TOO_LARGE' ? 413 : 400
|
|
114
|
+
const payload = JSON.stringify({ error: 'Bad Request', status, code: 'BAD_REQUEST' })
|
|
115
|
+
const headers = new Headers(IGNGEX_SECURITY_HEADERS)
|
|
116
|
+
headers.set('content-type', 'application/json; charset=utf-8')
|
|
117
|
+
return new Response(payload, { status, headers })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let terminal: Response | null = null
|
|
122
|
+
let snapshot: NativeRequestContext | null = null
|
|
123
|
+
|
|
124
|
+
// Sync callback (never returns a thenable). Captures the decoded snapshot
|
|
125
|
+
// or the terminal response, then the async responder runs outside `run()`.
|
|
126
|
+
ingress.run<null>(req, ip, body, (result, ctx) => {
|
|
127
|
+
if (result.terminal || !result.ok) {
|
|
128
|
+
terminal =
|
|
129
|
+
terminalStyle === 'ignex'
|
|
130
|
+
? buildIgngexTerminal(result, ctx)
|
|
131
|
+
: ingress.terminalResponse(req, result, ctx)
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
const query = parseSection(result.queryJson())
|
|
135
|
+
const cookies = parseSection(result.cookiesJson())
|
|
136
|
+
snapshot = {
|
|
137
|
+
requestId,
|
|
138
|
+
status: result.status,
|
|
139
|
+
rateLimited: result.rateLimited,
|
|
140
|
+
retryAfterMs: result.retryAfterMs,
|
|
141
|
+
query: query as Record<string, string | string[]>,
|
|
142
|
+
cookies: cookies as Record<string, string>,
|
|
143
|
+
body: body ?? new Uint8Array(0),
|
|
144
|
+
ip,
|
|
145
|
+
req,
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
if (terminal !== null) {
|
|
151
|
+
return terminal
|
|
152
|
+
}
|
|
153
|
+
if (snapshot === null) {
|
|
154
|
+
// Native internal error (setInternalError) — the result was never OK.
|
|
155
|
+
return ingress.internalErrorResponse({ requestIdHeader: null, origin: null })
|
|
156
|
+
}
|
|
157
|
+
return responder(snapshot)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -15,21 +15,20 @@
|
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
createServer,
|
|
18
|
-
type IncomingMessage,
|
|
19
18
|
type Server as HttpServer,
|
|
19
|
+
type IncomingMessage,
|
|
20
20
|
type ServerResponse,
|
|
21
21
|
} from 'node:http'
|
|
22
|
-
import {
|
|
23
|
-
|
|
22
|
+
import { type Duplex, Readable } from 'node:stream'
|
|
23
|
+
import { buildPathMatcher } from './path-matcher'
|
|
24
|
+
import { fallbackHandler } from './routes'
|
|
24
25
|
import {
|
|
25
|
-
buildPathMatcher,
|
|
26
|
-
buildRouteHandlers,
|
|
27
26
|
type BakedServer,
|
|
27
|
+
buildRouteHandlers,
|
|
28
28
|
type CreateIngressServerOptions,
|
|
29
|
-
type ServerHandle,
|
|
30
29
|
type RouteHandler,
|
|
30
|
+
type ServerHandle,
|
|
31
31
|
} from './server'
|
|
32
|
-
import { fallbackHandler } from './routes'
|
|
33
32
|
|
|
34
33
|
export type { RouteHandler } from './server'
|
|
35
34
|
|
|
@@ -41,6 +40,7 @@ export type { RouteHandler } from './server'
|
|
|
41
40
|
* body-read loop after headers arrive.
|
|
42
41
|
*/
|
|
43
42
|
const DEFAULT_SOCKET_TIMEOUT_MS = 30_000
|
|
43
|
+
|
|
44
44
|
import type { BakedHandlerOptions } from './routes/common'
|
|
45
45
|
|
|
46
46
|
/** A Node.js ingress server: {@link BakedServer} plus an async ready signal. */
|
package/src/ingress/server.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
// src/ingress/server.ts — Bun.serve builder over pre-baked route handlers.
|
|
2
2
|
|
|
3
|
+
import { isBun } from '../shared/runtime'
|
|
3
4
|
import {
|
|
4
|
-
|
|
5
|
-
headHandler,
|
|
6
|
-
jsonWriteHandler,
|
|
5
|
+
deleteHandler,
|
|
7
6
|
echoHandler,
|
|
8
7
|
fallbackHandler,
|
|
9
|
-
|
|
8
|
+
headHandler,
|
|
9
|
+
jsonWriteHandler,
|
|
10
10
|
optionsHandler,
|
|
11
|
+
readHandler,
|
|
11
12
|
} from './routes'
|
|
12
13
|
import type { BakedHandlerOptions } from './routes/common'
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
14
|
+
import { nativeRouteHandler } from './routes/native'
|
|
15
|
+
import { nativeResponderRoute } from './routes/responder'
|
|
16
|
+
import { createNativeRoute } from './native-route'
|
|
17
|
+
import type { NativeRoutePlan } from './native-route'
|
|
18
|
+
import type { NativeResponder, OptimizedIngressHandler, TerminalStyle } from './types'
|
|
15
19
|
|
|
16
20
|
/** Server-level default for the socket request-body cap (16 MiB). */
|
|
17
21
|
export const DEFAULT_MAX_REQUEST_BODY_SIZE = 16 * 1024 * 1024
|
|
@@ -45,6 +49,40 @@ export interface BakedRoute {
|
|
|
45
49
|
cookies?: OptimizedIngressHandler
|
|
46
50
|
/** Wires a DELETE read-style handler. */
|
|
47
51
|
delete?: OptimizedIngressHandler
|
|
52
|
+
/**
|
|
53
|
+
* A JS responder route: the native pipeline owns parse/validate/CORS/
|
|
54
|
+
* rate-limit + terminal rejections; the responder builds the 2xx body
|
|
55
|
+
* (async OK). Wired for `methods` (default GET). Built by
|
|
56
|
+
* `nativeResponderRoute` (src/ingress/routes/responder.ts).
|
|
57
|
+
*/
|
|
58
|
+
responder?: {
|
|
59
|
+
/** The route's compiled native `IngressInner`. */
|
|
60
|
+
ingress: OptimizedIngressHandler
|
|
61
|
+
/** The JS 2xx builder. */
|
|
62
|
+
handler: NativeResponder
|
|
63
|
+
/** HTTP methods to wire (default `['GET']`). */
|
|
64
|
+
methods?: ReadonlyArray<string>
|
|
65
|
+
/** Terminal envelope style (`'castrum'` default / `'ignex'`). */
|
|
66
|
+
terminalStyle?: TerminalStyle
|
|
67
|
+
/** Read the body for native validation (default false — framework owns it). */
|
|
68
|
+
readBody?: boolean
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A LEAN native-stack responder route: the route-wire v3 per-route stack
|
|
72
|
+
* (`createNativeRoute`) runs ONLY the plan's stages in ONE native call —
|
|
73
|
+
* no CORS/rate-limit/security/IP/metadata envelope. Wired for `methods`
|
|
74
|
+
* (default GET) by `nativeRouteHandler` (src/ingress/routes/native.ts).
|
|
75
|
+
*/
|
|
76
|
+
native?: {
|
|
77
|
+
/** The route-wire v3 plan (parse/validate stages + limits). */
|
|
78
|
+
plan: NativeRoutePlan
|
|
79
|
+
/** The JS 2xx builder. */
|
|
80
|
+
handler: NativeResponder
|
|
81
|
+
/** HTTP methods to wire (default `['GET']`). */
|
|
82
|
+
methods?: ReadonlyArray<string>
|
|
83
|
+
/** Read the body for `requireJsonBody`/`validateBody` (default false). */
|
|
84
|
+
readBody?: boolean
|
|
85
|
+
}
|
|
48
86
|
/** Overrides `maxBodyBytes` for this route's write/echo handlers. */
|
|
49
87
|
maxBodyBytes?: number
|
|
50
88
|
/**
|
|
@@ -124,6 +162,7 @@ export interface BakedServer {
|
|
|
124
162
|
port: number
|
|
125
163
|
}
|
|
126
164
|
|
|
165
|
+
/** Options for `gracefulShutdown` (drain grace period + signals). */
|
|
127
166
|
export interface GracefulShutdownOptions {
|
|
128
167
|
/** Grace period (ms) to drain in-flight requests before force-closing. */
|
|
129
168
|
timeoutMs?: number
|
|
@@ -182,13 +221,6 @@ export function gracefulShutdown(
|
|
|
182
221
|
}
|
|
183
222
|
}
|
|
184
223
|
|
|
185
|
-
/**
|
|
186
|
-
* Build the route → { method → handler } map from a server spec.
|
|
187
|
-
*
|
|
188
|
-
* Shared by `createIngressServer` (Bun.serve) and `createIngressServerNode`
|
|
189
|
-
* (node:http) so both runtimes wire the exact same route handlers.
|
|
190
|
-
*/
|
|
191
|
-
|
|
192
224
|
/** Options `buildRouteHandlers` actually consumes (a subset of the server
|
|
193
225
|
* options — it does not need `port` or the Node-only server fields). */
|
|
194
226
|
export type BuildRouteHandlersOptions = Pick<
|
|
@@ -196,6 +228,12 @@ export type BuildRouteHandlersOptions = Pick<
|
|
|
196
228
|
'routes' | 'fallback' | 'getIp' | 'copyBody'
|
|
197
229
|
>
|
|
198
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Build the route → { method → handler } map from a server spec.
|
|
233
|
+
*
|
|
234
|
+
* Shared by `createIngressServer` (Bun.serve) and `createIngressServerNode`
|
|
235
|
+
* (node:http) so both runtimes wire the exact same route handlers.
|
|
236
|
+
*/
|
|
199
237
|
export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
|
|
200
238
|
routes: Record<string, Record<string, unknown>>
|
|
201
239
|
baseOpts: BakedHandlerOptions
|
|
@@ -249,6 +287,33 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
|
|
|
249
287
|
methods.DELETE = deleteHandler(spec.delete, routeOpts)
|
|
250
288
|
}
|
|
251
289
|
|
|
290
|
+
if (spec.responder) {
|
|
291
|
+
// JS responder route: native decides + rejects; JS builds the 2xx.
|
|
292
|
+
const responderRoute = nativeResponderRoute(spec.responder.ingress, spec.responder.handler, {
|
|
293
|
+
...routeOpts,
|
|
294
|
+
terminalStyle: spec.responder.terminalStyle,
|
|
295
|
+
readBody: spec.responder.readBody,
|
|
296
|
+
})
|
|
297
|
+
const responderMethods = spec.responder.methods ?? ['GET']
|
|
298
|
+
for (const m of responderMethods) {
|
|
299
|
+
methods[m] = responderRoute
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (spec.native) {
|
|
304
|
+
// LEAN native-stack responder route: route-wire v3 stack, no envelope.
|
|
305
|
+
// The compiled route is injected into the pure route factory (DI across
|
|
306
|
+
// the purity boundary — the compile touches the dlopen layer here).
|
|
307
|
+
const nativeRoute = nativeRouteHandler(createNativeRoute(spec.native.plan), spec.native.handler, {
|
|
308
|
+
...routeOpts,
|
|
309
|
+
readBody: spec.native.readBody,
|
|
310
|
+
})
|
|
311
|
+
const nativeMethods = spec.native.methods ?? ['GET']
|
|
312
|
+
for (const m of nativeMethods) {
|
|
313
|
+
methods[m] = nativeRoute
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
252
317
|
// CORS preflight (OPTIONS) is served for EVERY route with a NATIVE handler
|
|
253
318
|
// (not just write routes with a fallback), so read-only routes also answer
|
|
254
319
|
// preflights with 204/403 from the native pipeline. Raw probe handlers
|
|
@@ -269,106 +334,12 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
|
|
|
269
334
|
return { routes: serverRoutes, baseOpts }
|
|
270
335
|
}
|
|
271
336
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
interface CompiledDynamicPattern {
|
|
279
|
-
re: RegExp
|
|
280
|
-
paramNames: string[]
|
|
281
|
-
rest: boolean
|
|
282
|
-
methods: Record<string, unknown>
|
|
283
|
-
/** Number of literal (static) segments — used to prefer the most specific pattern. */
|
|
284
|
-
staticSegments: number
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* Build a path matcher over a route map, supporting `:param` and `*` (rest)
|
|
289
|
-
* segments. Exact (static) paths always win over dynamic patterns; dynamic
|
|
290
|
-
* patterns are ordered most-specific-first (most static segments, then fewest
|
|
291
|
-
* params). Used by `createIngressServerNode` (Bun's native router already
|
|
292
|
-
* matches dynamic patterns itself). The ingress pipeline does not echo path
|
|
293
|
-
* params in its response, so matching only selects the handler.
|
|
294
|
-
*/
|
|
295
|
-
export function buildPathMatcher(
|
|
296
|
-
routes: Record<string, Record<string, unknown>>,
|
|
297
|
-
): (pathname: string) => PathMatch | undefined {
|
|
298
|
-
const exact = new Map<string, Record<string, unknown>>()
|
|
299
|
-
const dynamic: CompiledDynamicPattern[] = []
|
|
300
|
-
|
|
301
|
-
for (const [path, methods] of Object.entries(routes)) {
|
|
302
|
-
const hasParam = path.indexOf(':') !== -1
|
|
303
|
-
const hasRest = path.indexOf('*') !== -1
|
|
304
|
-
if (!hasParam && !hasRest) {
|
|
305
|
-
exact.set(path, methods)
|
|
306
|
-
continue
|
|
307
|
-
}
|
|
337
|
+
// ── Path matching (shared with server-node.ts + router.ts) ─────────
|
|
338
|
+
// `buildPathMatcher` / `PathMatch` / `safeDecode` live in path-matcher.ts;
|
|
339
|
+
// re-exported here for back-compat (they were historically exported from this
|
|
340
|
+
// module).
|
|
308
341
|
|
|
309
|
-
|
|
310
|
-
let rest = false
|
|
311
|
-
let staticSegments = 0
|
|
312
|
-
const parts = path.split('/').filter((s) => s.length > 0)
|
|
313
|
-
const source = parts
|
|
314
|
-
.map((seg) => {
|
|
315
|
-
if (seg === '*') {
|
|
316
|
-
rest = true
|
|
317
|
-
return '(.*)'
|
|
318
|
-
}
|
|
319
|
-
if (seg.startsWith(':')) {
|
|
320
|
-
paramNames.push(seg.slice(1))
|
|
321
|
-
return '([^/]+)'
|
|
322
|
-
}
|
|
323
|
-
staticSegments++
|
|
324
|
-
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
325
|
-
})
|
|
326
|
-
.join('/')
|
|
327
|
-
|
|
328
|
-
dynamic.push({
|
|
329
|
-
re: new RegExp(`^/${source}${rest ? '' : '/?$'}`),
|
|
330
|
-
paramNames,
|
|
331
|
-
rest,
|
|
332
|
-
methods,
|
|
333
|
-
staticSegments,
|
|
334
|
-
})
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
dynamic.sort(
|
|
338
|
-
(a, b) => b.staticSegments - a.staticSegments || a.paramNames.length - b.paramNames.length,
|
|
339
|
-
)
|
|
340
|
-
|
|
341
|
-
return (pathname: string) => {
|
|
342
|
-
const exactHit = exact.get(pathname)
|
|
343
|
-
if (exactHit !== undefined) {
|
|
344
|
-
return { methods: exactHit, params: {} }
|
|
345
|
-
}
|
|
346
|
-
for (const d of dynamic) {
|
|
347
|
-
const m = d.re.exec(pathname)
|
|
348
|
-
if (m !== null) {
|
|
349
|
-
const params: Record<string, string> = {}
|
|
350
|
-
for (let i = 0; i < d.paramNames.length; i++) {
|
|
351
|
-
const raw = m[i + 1]
|
|
352
|
-
if (raw !== undefined) params[d.paramNames[i] ?? ''] = safeDecode(raw)
|
|
353
|
-
}
|
|
354
|
-
if (d.rest) {
|
|
355
|
-
const restRaw = m[d.paramNames.length + 1]
|
|
356
|
-
if (restRaw !== undefined) params['*'] = safeDecode(restRaw)
|
|
357
|
-
}
|
|
358
|
-
return { methods: d.methods, params }
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
return undefined
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function safeDecode(value: string): string {
|
|
366
|
-
try {
|
|
367
|
-
return decodeURIComponent(value)
|
|
368
|
-
} catch {
|
|
369
|
-
return value
|
|
370
|
-
}
|
|
371
|
-
}
|
|
342
|
+
export { buildPathMatcher, type PathMatch, safeDecode } from './path-matcher'
|
|
372
343
|
|
|
373
344
|
/** Typed options forwarded to `Bun.serve`. */
|
|
374
345
|
interface BunServerOptions {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// src/ingress/sync.ts — Sync + async convenience factories (path 1 wrappers)
|
|
2
|
+
//
|
|
3
|
+
// `createIngressSync` and `createIngress` wrap `createIngressFast` (path 1)
|
|
4
|
+
// with the synchronous-callback guard / automatic body reading + error
|
|
5
|
+
// contexts. Kept OUT of index.ts so the public barrel stays a pure re-export
|
|
6
|
+
// hub. Runtime/server selection lives in src/runtime/server.ts — NOT here.
|
|
7
|
+
|
|
8
|
+
import { decoder } from '../shared/bytes'
|
|
9
|
+
import { generateRequestId } from '../shared/request-id'
|
|
10
|
+
import { readRequestBodyOnce } from './body'
|
|
11
|
+
import { ERR_CODE_BODY_TOO_LARGE, ERR_CODE_REQUEST_TIMEOUT } from './constants'
|
|
12
|
+
import { internalContext, snapshotResult, syntheticContext } from './context'
|
|
13
|
+
import { createIngressFast } from './fast'
|
|
14
|
+
import { buildResponseContext } from './headers/fast-templates'
|
|
15
|
+
import { buildTerminalResponse } from './response/terminal'
|
|
16
|
+
import { assertSyncCallback, DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from './shared'
|
|
17
|
+
import type { IngressContext, IngressHandler, IngressOptions, SyncIngressHandler } from './types'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Synchronous convenience wrapper over `createIngressFast` (path 1).
|
|
21
|
+
*
|
|
22
|
+
* The callback runs on the fast-path result and MUST be synchronous. Use
|
|
23
|
+
* {@link createIngress} when you want automatic body reading + error contexts.
|
|
24
|
+
*
|
|
25
|
+
* @param options fast-path ingress options
|
|
26
|
+
* @returns a sync handler (callback must be synchronous)
|
|
27
|
+
*/
|
|
28
|
+
export function createIngressSync(options: IngressOptions = {}): SyncIngressHandler {
|
|
29
|
+
const fast = createIngressFast(options)
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
run(req, ip, body, requestId, fn) {
|
|
33
|
+
return fast.run(req, ip, body, requestId, (result) => {
|
|
34
|
+
const out = fn(result)
|
|
35
|
+
assertSyncCallback(out, 'createIngressSync().run()')
|
|
36
|
+
return out
|
|
37
|
+
})
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Return a copy of `options` without the security-header keys.
|
|
44
|
+
*
|
|
45
|
+
* `createIngress` applies security headers itself (via `buildResponseContext`),
|
|
46
|
+
* so they must not be forwarded to the underlying fast handler, which rejects
|
|
47
|
+
* them (see `createIngressFast`).
|
|
48
|
+
*/
|
|
49
|
+
function stripSecurityOptions(options: IngressOptions): IngressOptions {
|
|
50
|
+
const copy = { ...options }
|
|
51
|
+
delete copy.security
|
|
52
|
+
delete copy.enableSecurityHeaders
|
|
53
|
+
return copy
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Async ingress factory: reads the request body (size guard + deadline) and
|
|
58
|
+
* returns a snapshot `IngressContext` with a terminal `Response`.
|
|
59
|
+
*
|
|
60
|
+
* Recommended default for serverless/edge-style `fetch` handlers. For maximum
|
|
61
|
+
* throughput use `createIngressFast` / `createIngressSync` directly.
|
|
62
|
+
*
|
|
63
|
+
* @param options fast-path ingress options (security applied here, not forwarded)
|
|
64
|
+
* @returns an async handler returning a snapshot context
|
|
65
|
+
*/
|
|
66
|
+
export function createIngress(options: IngressOptions = {}): IngressHandler {
|
|
67
|
+
const sync = createIngressSync(stripSecurityOptions(options))
|
|
68
|
+
|
|
69
|
+
const guard = options.enableBodySizeGuard !== false
|
|
70
|
+
const max = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
71
|
+
const bodyTimeoutMs = options.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
|
|
72
|
+
|
|
73
|
+
const wantsBody =
|
|
74
|
+
options.readBody === true ||
|
|
75
|
+
options.requireJsonBody === true ||
|
|
76
|
+
options.schema != null ||
|
|
77
|
+
(options.readBody !== false && guard)
|
|
78
|
+
|
|
79
|
+
const responseCtx = buildResponseContext(options)
|
|
80
|
+
|
|
81
|
+
return async function ingressAsync(req: Request, ip?: string): Promise<IngressContext> {
|
|
82
|
+
const requestId = options.enableRequestIds === false ? '' : decoder.decode(generateRequestId())
|
|
83
|
+
options.onRequest?.(req, requestId, ip)
|
|
84
|
+
|
|
85
|
+
// Every terminal outcome (success, 413, 408, 500) flows through here so a
|
|
86
|
+
// hook consumer sees exactly one onResponse per request.
|
|
87
|
+
const emitResponse = (context: IngressContext): IngressContext => {
|
|
88
|
+
options.onResponse?.(req, context, context.status, requestId)
|
|
89
|
+
return context
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
if (guard) {
|
|
94
|
+
const rawLen = req.headers.get('content-length')
|
|
95
|
+
const contentLength = Number(rawLen ?? '0')
|
|
96
|
+
|
|
97
|
+
if (Number.isFinite(contentLength) && contentLength > max) {
|
|
98
|
+
return emitResponse(
|
|
99
|
+
syntheticContext(req, requestId, options, responseCtx, 413, ERR_CODE_BODY_TOO_LARGE),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let body: Uint8Array | null = null
|
|
105
|
+
|
|
106
|
+
if (wantsBody && req.body !== null) {
|
|
107
|
+
try {
|
|
108
|
+
body = await readRequestBodyOnce(req, max, guard, bodyTimeoutMs)
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const code = (err as { code?: string } | null)?.code
|
|
111
|
+
|
|
112
|
+
if (code === 'BODY_TOO_LARGE') {
|
|
113
|
+
return emitResponse(
|
|
114
|
+
syntheticContext(req, requestId, options, responseCtx, 413, ERR_CODE_BODY_TOO_LARGE),
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (code === 'REQUEST_TIMEOUT') {
|
|
119
|
+
// Match the pre-baked route factories: a body-read deadline is a
|
|
120
|
+
// 408, not a 500 (was silently swallowed as 500 before).
|
|
121
|
+
return emitResponse(
|
|
122
|
+
syntheticContext(req, requestId, options, responseCtx, 408, ERR_CODE_REQUEST_TIMEOUT),
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Unknown body-read failure: surface to onError, then fall through
|
|
127
|
+
// to the internal-error context below.
|
|
128
|
+
options.onError?.(err instanceof Error ? err : new Error(String(err)))
|
|
129
|
+
throw err
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return emitResponse(
|
|
134
|
+
sync.run(req, ip, body, requestId, (r) => {
|
|
135
|
+
const snapshot = snapshotResult(r)
|
|
136
|
+
const response = buildTerminalResponse(responseCtx, snapshot, req, requestId)
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
...snapshot,
|
|
140
|
+
response,
|
|
141
|
+
}
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
} catch (err) {
|
|
145
|
+
// Native/other failures: never silent — report to onError and emit a
|
|
146
|
+
// structured 500 context so the request still completes observably.
|
|
147
|
+
options.onError?.(err instanceof Error ? err : new Error(String(err)))
|
|
148
|
+
return emitResponse(internalContext(req, requestId, options, responseCtx))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|