@nifrajs/core 2.9.0 → 2.10.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/src/range.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `Range: bytes=...` parsing - the shared owner for every byte-range surface in the framework.
3
+ *
4
+ * This lives in core rather than in the middleware package because two unrelated layers need the
5
+ * exact same answer: `@nifrajs/middleware`'s `rangeResponse` for caller-owned buffers, and
6
+ * `@nifrajs/web`'s `public/` static handler for files on disk. A second implementation that happens
7
+ * to agree today is how a media server ends up serving byte 0 of a seek request in one code path and
8
+ * byte N in the other.
9
+ *
10
+ * The input is request-controlled on every call, so the parser is bounded before it is clever: a
11
+ * capped header length and a capped specifier count keep a malformed range-set from turning a sort
12
+ * and merge into an allocation amplifier.
13
+ */
14
+
15
+ export interface ByteRange {
16
+ readonly start: number
17
+ readonly end: number
18
+ }
19
+
20
+ export type ByteRangeResult =
21
+ | { readonly kind: "none" }
22
+ | { readonly kind: "unsatisfiable" }
23
+ | { readonly kind: "satisfiable"; readonly ranges: readonly ByteRange[] }
24
+
25
+ const INTEGER = /^(?:0|[1-9]\d*)$/
26
+ const MAX_RANGE_HEADER_LENGTH = 16 * 1024
27
+ const MAX_RANGE_SPECIFIERS = 16
28
+
29
+ function validSize(size: number): void {
30
+ if (!Number.isSafeInteger(size) || size < 0) {
31
+ throw new RangeError("parseByteRange: size must be a non-negative safe integer")
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Parse an HTTP `Range: bytes=...` header against a known representation size.
37
+ *
38
+ * Three outcomes, and the distinction matters to the caller's status code: `none` means the header
39
+ * was absent or syntactically invalid and must be ignored (200), `unsatisfiable` means a well-formed
40
+ * range-set selected nothing (416), `satisfiable` carries the coalesced ranges (206).
41
+ */
42
+ export function parseByteRange(value: string | null, size: number): ByteRangeResult {
43
+ validSize(size)
44
+ if (value === null) return { kind: "none" }
45
+ const match = /^bytes\s*=\s*(.+)$/i.exec(value)
46
+ if (match === null) return { kind: "none" }
47
+ const source = match[1]!
48
+ if (source.length > MAX_RANGE_HEADER_LENGTH) return { kind: "none" }
49
+ let separators = 0
50
+ for (const character of source) {
51
+ if (character === "," && ++separators >= MAX_RANGE_SPECIFIERS) return { kind: "none" }
52
+ }
53
+
54
+ const ranges: ByteRange[] = []
55
+ let validRangeSpec = false
56
+ for (const raw of source.split(",")) {
57
+ const item = raw.trim()
58
+ const dash = item.indexOf("-")
59
+ if (dash < 0) continue
60
+ const left = item.slice(0, dash).trim()
61
+ const right = item.slice(dash + 1).trim()
62
+ if (left === "" && (!INTEGER.test(right) || right === "0")) continue
63
+ if (left !== "" && !INTEGER.test(left)) continue
64
+ if (right !== "" && !INTEGER.test(right)) continue
65
+ validRangeSpec = true
66
+
67
+ let start: number
68
+ let end: number
69
+ if (left === "") {
70
+ const suffix = Number(right)
71
+ start = suffix >= size ? 0 : size - suffix
72
+ end = size - 1
73
+ } else {
74
+ start = Number(left)
75
+ end = right === "" ? size - 1 : Math.min(Number(right), size - 1)
76
+ }
77
+ if (start > end || start >= size || size === 0) continue
78
+ ranges.push({ start, end })
79
+ }
80
+
81
+ // A syntactically invalid Range header is ignored; a valid range-set with no overlap is 416.
82
+ if (ranges.length === 0) return validRangeSpec ? { kind: "unsatisfiable" } : { kind: "none" }
83
+ ranges.sort((a, b) => a.start - b.start || a.end - b.end)
84
+ const merged: ByteRange[] = []
85
+ for (const range of ranges) {
86
+ const previous = merged[merged.length - 1]
87
+ if (previous !== undefined && range.start <= previous.end + 1) {
88
+ merged[merged.length - 1] = { start: previous.start, end: Math.max(previous.end, range.end) }
89
+ } else {
90
+ merged.push(range)
91
+ }
92
+ }
93
+ return { kind: "satisfiable", ranges: merged }
94
+ }
@@ -230,7 +230,9 @@ function resolve<T>(
230
230
  const upper = method.toUpperCase()
231
231
  const cached = upper === method ? undefined : terminal.staticMatches.get(upper)
232
232
  if (cached !== undefined) return cached
233
- if (!terminal.handlers.has(upper)) return resolveDirect(terminal, method, params)
233
+ const cacheable =
234
+ terminal.handlers.has(upper) || (upper === "HEAD" && terminal.handlers.has("GET"))
235
+ if (!cacheable) return resolveDirect(terminal, method, params)
234
236
 
235
237
  const res = resolveDirect(terminal, method, params)
236
238
  terminal.staticMatches.set(upper, res)
@@ -248,11 +250,20 @@ function resolveDirect<T>(
248
250
  return { found: true, payload: terminal.handlers.get(method)!, params }
249
251
  }
250
252
  const upper = method.toUpperCase()
251
- if (upper === method || !terminal.handlers.has(upper)) {
252
- return { found: false, reason: "method-not-allowed", allowed: [...terminal.handlers.keys()] }
253
+ if (upper !== method && terminal.handlers.has(upper)) {
254
+ return { found: true, payload: terminal.handlers.get(upper)!, params }
253
255
  }
254
- const payload = terminal.handlers.get(upper)! // present by the has() check above
255
- return { found: true, payload, params }
256
+ // RFC 9110 §9.3.2: HEAD is GET without the body. A route registered for GET answers HEAD with
257
+ // the GET handler; the serving runtime strips the body, so status + headers mirror GET exactly.
258
+ // An explicitly registered HEAD handler takes precedence via the has() checks above.
259
+ if (upper === "HEAD") {
260
+ const get = terminal.handlers.get("GET")
261
+ if (get !== undefined) return { found: true, payload: get, params }
262
+ }
263
+ const allowed = [...terminal.handlers.keys()]
264
+ // Advertise the implicit HEAD support granted above.
265
+ if (terminal.handlers.has("GET") && !terminal.handlers.has("HEAD")) allowed.push("HEAD")
266
+ return { found: false, reason: "method-not-allowed", allowed }
256
267
  }
257
268
 
258
269
  /**
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Thin adapters for platforms that do not expose a Web `fetch` handler directly.
3
+ *
4
+ * The app remains a Web-standard handler. These adapters only translate the platform event envelope;
5
+ * they do not own identity, persistence, retries, or platform credentials.
6
+ */
7
+
8
+ import type { Platform } from "./context.ts"
9
+ import type { MaybePromise } from "./server.ts"
10
+
11
+ export interface FetchApp<Env = unknown> {
12
+ fetch(request: Request, platform?: Platform<Env>): MaybePromise<Response>
13
+ }
14
+
15
+ export type VercelHandler = (request: Request) => MaybePromise<Response>
16
+
17
+ /** Adapt a Web-standard app to a Vercel Edge Function default export. */
18
+ export function toVercelHandler<Env = unknown>(app: FetchApp<Env>): VercelHandler {
19
+ return (request) => app.fetch(request)
20
+ }
21
+
22
+ type HeaderValue = string | readonly string[] | undefined
23
+ type HeaderRecord = Readonly<Record<string, HeaderValue>>
24
+
25
+ function decodeBase64(value: string): Uint8Array<ArrayBuffer> {
26
+ const binary = atob(value)
27
+ const bytes = new Uint8Array(new ArrayBuffer(binary.length))
28
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index)
29
+ return bytes
30
+ }
31
+
32
+ function encodeBase64(bytes: Uint8Array): string {
33
+ let binary = ""
34
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
35
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000))
36
+ }
37
+ return btoa(binary)
38
+ }
39
+
40
+ function headersFromRecords(
41
+ single: HeaderRecord | undefined,
42
+ multi: HeaderRecord | undefined,
43
+ ): Headers {
44
+ const headers = new Headers()
45
+ for (const [name, value] of Object.entries(single ?? {})) {
46
+ if (value === undefined) continue
47
+ for (const item of Array.isArray(value) ? value : [value]) headers.append(name, item)
48
+ }
49
+ for (const [name, value] of Object.entries(multi ?? {})) {
50
+ headers.delete(name)
51
+ if (value === undefined) continue
52
+ for (const item of Array.isArray(value) ? value : [value]) headers.append(name, item)
53
+ }
54
+ return headers
55
+ }
56
+
57
+ function absoluteUrl(
58
+ raw: string | undefined,
59
+ path: string,
60
+ query: string | undefined,
61
+ headers: Headers,
62
+ ): string {
63
+ if (raw !== undefined) {
64
+ try {
65
+ return new URL(raw).toString()
66
+ } catch {
67
+ // Fall through to the structured event fields.
68
+ }
69
+ }
70
+ const host = headers.get("host") ?? "nifra.invalid"
71
+ const url = new URL(path || "/", `https://${host}`)
72
+ if (query !== undefined && query !== "") url.search = query.startsWith("?") ? query : `?${query}`
73
+ return url.toString()
74
+ }
75
+
76
+ function requestBody(
77
+ body: string | null | undefined,
78
+ encoded: boolean | undefined,
79
+ ): string | Uint8Array<ArrayBuffer> | undefined {
80
+ if (body === undefined || body === null) return undefined
81
+ return encoded === true ? decodeBase64(body) : body
82
+ }
83
+
84
+ /**
85
+ * Every `set-cookie` on a response, as separate values.
86
+ *
87
+ * `Headers.forEach` and `Headers.get` collapse repeated `set-cookie` into one comma-joined string,
88
+ * and a cookie's `Expires` attribute contains a comma - so the joined form cannot be split back
89
+ * apart, and a plain record keyed by header name keeps only one cookie regardless. Both platforms
90
+ * have a dedicated channel for this; `getSetCookie` is how we fill it.
91
+ */
92
+ function setCookiesOf(headers: Headers): readonly string[] {
93
+ const getSetCookie = (headers as { getSetCookie?: () => string[] }).getSetCookie
94
+ if (typeof getSetCookie === "function") return getSetCookie.call(headers)
95
+ const single = headers.get("set-cookie")
96
+ return single === null ? [] : [single]
97
+ }
98
+
99
+ async function platformResponse(
100
+ response: Response,
101
+ /** `cookies` is API Gateway v2's dedicated array; v1 and Netlify carry them in multi-value headers. */
102
+ cookieChannel: "cookies" | "multiValueHeaders",
103
+ ): Promise<PlatformResponse> {
104
+ const bytes = new Uint8Array(await response.arrayBuffer())
105
+ const headers: Record<string, string> = {}
106
+ response.headers.forEach((value, name) => {
107
+ if (name.toLowerCase() === "set-cookie") return
108
+ headers[name] = value
109
+ })
110
+ const cookies = setCookiesOf(response.headers)
111
+ return {
112
+ statusCode: response.status,
113
+ headers,
114
+ ...(cookies.length === 0
115
+ ? {}
116
+ : cookieChannel === "cookies"
117
+ ? { cookies }
118
+ : { multiValueHeaders: { "set-cookie": cookies } }),
119
+ body: encodeBase64(bytes),
120
+ isBase64Encoded: true,
121
+ }
122
+ }
123
+
124
+ export interface NetlifyEvent {
125
+ readonly httpMethod: string
126
+ readonly path: string
127
+ readonly rawUrl?: string
128
+ readonly rawQuery?: string
129
+ readonly headers?: HeaderRecord
130
+ readonly multiValueHeaders?: HeaderRecord
131
+ readonly body?: string | null
132
+ readonly isBase64Encoded?: boolean
133
+ readonly cookies?: readonly string[]
134
+ }
135
+
136
+ export interface PlatformResponse {
137
+ readonly statusCode: number
138
+ /** Single-valued headers. `set-cookie` never appears here - see the two fields below. */
139
+ readonly headers: Readonly<Record<string, string>>
140
+ /**
141
+ * Repeated headers, currently only `set-cookie`. Netlify Functions and API Gateway REST (v1) read
142
+ * this; omitted when the response sets no cookies.
143
+ */
144
+ readonly multiValueHeaders?: Readonly<Record<string, readonly string[]>>
145
+ /** API Gateway HTTP API (v2) takes cookies here rather than in a header. Omitted when there are none. */
146
+ readonly cookies?: readonly string[]
147
+ readonly body: string
148
+ readonly isBase64Encoded: true
149
+ }
150
+
151
+ export type NetlifyHandler = (event: NetlifyEvent) => Promise<PlatformResponse>
152
+
153
+ /** Adapt a Web-standard app to a Netlify Functions event handler. */
154
+ export function toNetlifyHandler<Env = unknown>(app: FetchApp<Env>): NetlifyHandler {
155
+ return async (event) => {
156
+ const headers = headersFromRecords(event.headers, event.multiValueHeaders)
157
+ if (event.cookies !== undefined && !headers.has("cookie")) {
158
+ headers.set("cookie", event.cookies.join("; "))
159
+ }
160
+ const init: RequestInit = {
161
+ method: event.httpMethod,
162
+ headers,
163
+ }
164
+ const body = requestBody(event.body, event.isBase64Encoded)
165
+ if (body !== undefined) init.body = body
166
+ const request = new Request(
167
+ absoluteUrl(event.rawUrl, event.path, event.rawQuery, headers),
168
+ init,
169
+ )
170
+ return platformResponse(await app.fetch(request), "multiValueHeaders")
171
+ }
172
+ }
173
+
174
+ export interface LambdaV2Event {
175
+ readonly version?: "2.0"
176
+ readonly rawPath?: string
177
+ readonly rawQueryString?: string
178
+ readonly headers?: HeaderRecord
179
+ readonly cookies?: readonly string[]
180
+ readonly body?: string | null
181
+ readonly isBase64Encoded?: boolean
182
+ readonly requestContext?: {
183
+ readonly domainName?: string
184
+ readonly http?: { readonly method: string; readonly path?: string }
185
+ }
186
+ }
187
+
188
+ export interface LambdaV1Event {
189
+ readonly httpMethod: string
190
+ readonly path: string
191
+ readonly headers?: HeaderRecord
192
+ readonly multiValueHeaders?: HeaderRecord
193
+ readonly queryStringParameters?: Readonly<Record<string, string | undefined>>
194
+ readonly multiValueQueryStringParameters?: Readonly<Record<string, readonly string[] | undefined>>
195
+ readonly body?: string | null
196
+ readonly isBase64Encoded?: boolean
197
+ readonly requestContext?: { readonly domainName?: string }
198
+ }
199
+
200
+ export type LambdaEvent = LambdaV2Event | LambdaV1Event
201
+ export type LambdaResponse = PlatformResponse
202
+ export type LambdaHandler = (event: LambdaEvent, context?: unknown) => Promise<LambdaResponse>
203
+
204
+ function isLambdaV2(event: LambdaEvent): event is LambdaV2Event {
205
+ return "version" in event || "rawPath" in event || "rawQueryString" in event
206
+ }
207
+
208
+ function queryFromLambdaV1(event: LambdaV1Event): string {
209
+ const params = new URLSearchParams()
210
+ for (const [name, value] of Object.entries(event.multiValueQueryStringParameters ?? {})) {
211
+ for (const item of value ?? []) params.append(name, item)
212
+ }
213
+ for (const [name, value] of Object.entries(event.queryStringParameters ?? {})) {
214
+ if (value !== undefined && !params.has(name)) params.set(name, value)
215
+ }
216
+ return params.toString()
217
+ }
218
+
219
+ /** Adapt API Gateway HTTP API (v2) and REST API (v1) events to a Web-standard app. */
220
+ export function toLambdaHandler<Env = unknown>(app: FetchApp<Env>): LambdaHandler {
221
+ return async (event) => {
222
+ const v2 = isLambdaV2(event)
223
+ const headers = headersFromRecords(event.headers, v2 ? undefined : event.multiValueHeaders)
224
+ let path: string
225
+ let query: string
226
+ let method: string
227
+ let rawUrl: string | undefined
228
+ if (v2) {
229
+ method = event.requestContext?.http?.method ?? "GET"
230
+ path = event.rawPath ?? event.requestContext?.http?.path ?? "/"
231
+ query = event.rawQueryString ?? ""
232
+ const domain = event.requestContext?.domainName
233
+ rawUrl =
234
+ domain === undefined ? undefined : `https://${domain}${path}${query ? `?${query}` : ""}`
235
+ if (event.cookies !== undefined && !headers.has("cookie"))
236
+ headers.set("cookie", event.cookies.join("; "))
237
+ } else {
238
+ method = event.httpMethod
239
+ path = event.path
240
+ query = queryFromLambdaV1(event)
241
+ const domain = event.requestContext?.domainName
242
+ rawUrl =
243
+ domain === undefined ? undefined : `https://${domain}${path}${query ? `?${query}` : ""}`
244
+ }
245
+ const body = event.body
246
+ const init: RequestInit = {
247
+ method,
248
+ headers,
249
+ }
250
+ const requestBodyValue = requestBody(body, event.isBase64Encoded)
251
+ if (requestBodyValue !== undefined) init.body = requestBodyValue
252
+ const request = new Request(absoluteUrl(rawUrl, path, query, headers), init)
253
+ return platformResponse(await app.fetch(request), v2 ? "cookies" : "multiValueHeaders")
254
+ }
255
+ }
@@ -2501,7 +2501,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2501
2501
  ): MaybePromise<T> {
2502
2502
  const hooks = this.onRequestHooks
2503
2503
  const originalRequest = requestOf(source)
2504
- this.responseSources.set(source as object, originalRequest)
2504
+ // A Web Request is already the exact object visible to the hooks and the response walk. Only
2505
+ // adapter sources need the source→request mapping; avoiding the WeakMap write keeps the common
2506
+ // Bun/Deno/edge lifecycle allocation-light while preserving Node's lazy Request identity.
2507
+ if (source !== originalRequest) this.responseSources.set(source as object, originalRequest)
2505
2508
  let current: RequestSource = source
2506
2509
  for (let i = 0; i < hooks.length; i++) {
2507
2510
  const outcome = (hooks[i] as RawOnRequest)(requestOf(current), platform)
@@ -4447,7 +4450,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4447
4450
  )
4448
4451
  count += 1
4449
4452
  }
4450
- return count === 0 ? undefined : routes
4453
+ if (count === 0) return undefined
4454
+ // RFC 9110 §9.3.2: a GET route answers HEAD with identical status + headers (Bun strips the
4455
+ // body on the wire). Alias the compiled GET handler under HEAD so HEAD stays on the native
4456
+ // lane instead of falling through to the portable dispatcher; the router applies the same
4457
+ // fallback there, so both lanes agree. An explicit HEAD registration wins via the ??=.
4458
+ for (const path of Object.keys(routes)) {
4459
+ const methods = routes[path]!
4460
+ if (methods.GET !== undefined) methods.HEAD ??= methods.GET
4461
+ }
4462
+ return routes
4451
4463
  }
4452
4464
 
4453
4465
  /**
package/src/server.ts CHANGED
@@ -53,6 +53,21 @@ export {
53
53
  redactLogFields,
54
54
  silentLogger,
55
55
  } from "./server/logger.ts"
56
+ export {
57
+ type FetchApp,
58
+ type LambdaEvent,
59
+ type LambdaHandler,
60
+ type LambdaResponse,
61
+ type LambdaV1Event,
62
+ type LambdaV2Event,
63
+ type NetlifyEvent,
64
+ type NetlifyHandler,
65
+ type PlatformResponse,
66
+ toLambdaHandler,
67
+ toNetlifyHandler,
68
+ toVercelHandler,
69
+ type VercelHandler,
70
+ } from "./server/platform-adapters.ts"
56
71
  export type { Registry, RouteInfo } from "./server/registry.ts"
57
72
  export {
58
73
  type AdmissionController,