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.
Files changed (120) hide show
  1. package/README.md +153 -630
  2. package/castrum.linux-x64-gnu.node +0 -0
  3. package/dist/index.d.ts +1653 -1817
  4. package/dist/index.js +6172 -4294
  5. package/docs/API.md +337 -0
  6. package/docs/ARCHITECTURE.md +111 -17
  7. package/docs/BENCHMARKS.md +117 -49
  8. package/docs/CASE_STUDY.md +243 -0
  9. package/docs/ENVIRONMENT.md +24 -7
  10. package/docs/FFI_BUN_GUIDE.md +335 -0
  11. package/docs/GETTING_STARTED.md +18 -34
  12. package/docs/HOW_TO_ADD_AN_OP.md +180 -0
  13. package/docs/INGRESS-ROUTER.md +82 -0
  14. package/docs/INGRESS.md +11 -7
  15. package/docs/NATIVE-ROUTE.md +77 -0
  16. package/docs/REPO_MAP.md +103 -53
  17. package/docs/adr/0001-two-wire-formats.md +1 -1
  18. package/docs/adr/0003-bun-builtins-delegation.md +6 -5
  19. package/docs/ai/LOCAL_DEV.md +81 -0
  20. package/docs/ai/TREE.md +1070 -0
  21. package/docs/bun-builtins-decision-matrix.md +25 -18
  22. package/index.ts +9 -16
  23. package/package.json +41 -28
  24. package/src/ingress/body.ts +167 -27
  25. package/src/ingress/decode/result-base.ts +10 -2
  26. package/src/ingress/handlers.ts +116 -285
  27. package/src/ingress/headers/security.ts +41 -0
  28. package/src/ingress/health.ts +1 -0
  29. package/src/ingress/index.ts +45 -169
  30. package/src/ingress/native-route.ts +182 -0
  31. package/src/ingress/packing/gather-raw-headers.ts +110 -22
  32. package/src/ingress/packing/header-packing.ts +5 -5
  33. package/src/ingress/packing/input-packer.ts +17 -3
  34. package/src/ingress/packing/route-wire.ts +197 -0
  35. package/src/ingress/packing/select-headers.ts +20 -7
  36. package/src/ingress/path-matcher.ts +115 -0
  37. package/src/ingress/response/baked-response.ts +302 -0
  38. package/src/ingress/router.ts +277 -0
  39. package/src/ingress/routes/common.ts +50 -1
  40. package/src/ingress/routes/fallback.ts +2 -2
  41. package/src/ingress/routes/head.ts +3 -13
  42. package/src/ingress/routes/index.ts +5 -4
  43. package/src/ingress/routes/json-write.ts +26 -47
  44. package/src/ingress/routes/native.ts +130 -0
  45. package/src/ingress/routes/options.ts +2 -12
  46. package/src/ingress/routes/read.ts +3 -13
  47. package/src/ingress/routes/responder.ts +159 -0
  48. package/src/ingress/server-node.ts +7 -7
  49. package/src/ingress/server.ts +83 -112
  50. package/src/ingress/sync.ts +151 -0
  51. package/src/ingress/types.ts +44 -1
  52. package/src/integration/pipeline.ts +3 -0
  53. package/src/integration/streaming.ts +2 -3
  54. package/src/integration/websocket.ts +4 -3
  55. package/src/loader/batch.ts +4 -0
  56. package/src/loader/cost.ts +1 -0
  57. package/src/loader/index.ts +43 -228
  58. package/src/loader/state.ts +84 -0
  59. package/src/loader/types.ts +145 -0
  60. package/src/native/ffi/build/codecs.ts +736 -0
  61. package/src/native/ffi/build/compress.ts +248 -0
  62. package/src/native/ffi/build/instances.ts +598 -0
  63. package/src/native/ffi/build/parse.ts +539 -0
  64. package/src/native/ffi/build/util.ts +140 -0
  65. package/src/native/ffi/build.ts +94 -0
  66. package/src/native/ffi/constants.ts +58 -0
  67. package/src/native/ffi/selftest.ts +22 -0
  68. package/src/native/ffi/types.ts +499 -0
  69. package/src/native/ffi.ts +307 -1357
  70. package/src/native/loader.ts +52 -6
  71. package/src/native/types/instances.ts +279 -0
  72. package/src/native/types.ts +41 -238
  73. package/src/runtime/builtins.ts +131 -0
  74. package/src/runtime/codec.ts +147 -0
  75. package/src/runtime/detect.ts +36 -0
  76. package/src/runtime/index.ts +28 -0
  77. package/src/runtime/native.ts +35 -0
  78. package/src/runtime/server.ts +31 -0
  79. package/src/runtime/transport.ts +64 -0
  80. package/src/runtime/types.ts +173 -0
  81. package/src/runtime/uuid.ts +45 -0
  82. package/src/runtime/websocket.ts +30 -0
  83. package/src/rust-ffi/addon.ts +1 -1
  84. package/src/rust-ffi/batch/build.ts +155 -0
  85. package/src/rust-ffi/batch/index.ts +8 -0
  86. package/src/rust-ffi/batch/types.ts +92 -0
  87. package/src/rust-ffi/client.ts +5 -6
  88. package/src/rust-ffi/context.ts +39 -10
  89. package/src/rust-ffi/index.ts +28 -17
  90. package/src/rust-ffi/into.ts +21 -0
  91. package/src/rust-ffi/options.ts +1 -1
  92. package/src/rust-ffi/packed.ts +40 -2
  93. package/src/rust-ffi/proven.ts +16 -30
  94. package/src/rust-ffi/scalar/crypto.ts +188 -107
  95. package/src/rust-ffi/scalar/factories.ts +257 -27
  96. package/src/rust-ffi/scalar/hashing.ts +43 -41
  97. package/src/rust-ffi/scalar/http.ts +208 -77
  98. package/src/rust-ffi/scalar/index.ts +3 -3
  99. package/src/rust-ffi/scalar/interface.ts +88 -213
  100. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  101. package/src/rust-ffi/scalar/json.ts +28 -12
  102. package/src/rust-ffi/scalar/payload.ts +159 -42
  103. package/src/rust-ffi/text.ts +31 -5
  104. package/src/selection.ts +27 -31
  105. package/src/shared/adaptive.ts +1 -2
  106. package/src/shared/buffer-pool.ts +72 -17
  107. package/src/shared/bytes.ts +75 -6
  108. package/src/shared/codec.ts +78 -0
  109. package/src/shared/env.ts +4 -4
  110. package/src/shared/log.ts +1 -0
  111. package/src/shared/packed/index.ts +10 -0
  112. package/src/shared/packed/parsers.ts +31 -0
  113. package/src/shared/packed/schema.ts +23 -0
  114. package/src/shared/{packed.ts → packed/wire.ts} +31 -39
  115. package/src/shared/proven.ts +189 -496
  116. package/src/shared/runtime.ts +20 -25
  117. package/src/shared/uuid.ts +12 -28
  118. package/src/data/json-rows.ts +0 -33
  119. package/src/rust-ffi/batch.ts +0 -431
  120. 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 { resolveIp, type BakedHandlerOptions } from './common'
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: ResponseInit = {
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 (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
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 { Readable, type Duplex } from 'node:stream'
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. */
@@ -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
- readHandler,
5
- headHandler,
6
- jsonWriteHandler,
5
+ deleteHandler,
7
6
  echoHandler,
8
7
  fallbackHandler,
9
- deleteHandler,
8
+ headHandler,
9
+ jsonWriteHandler,
10
10
  optionsHandler,
11
+ readHandler,
11
12
  } from './routes'
12
13
  import type { BakedHandlerOptions } from './routes/common'
13
- import type { OptimizedIngressHandler } from './types'
14
- import { isBun } from '../shared/runtime'
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
- /** A matched dynamic route: the handler map + the extracted path params. */
273
- export interface PathMatch {
274
- methods: Record<string, unknown>
275
- params: Record<string, string>
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
- const paramNames: string[] = []
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
+ }