@nifrajs/core 2.9.1 → 2.11.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.
@@ -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)
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,