@nifrajs/core 2.8.2 → 2.9.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/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/route-execution.d.ts +8 -2
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/node-direct.d.ts.map +1 -1
- package/dist/node-direct.js +3 -1
- package/dist/node-direct.js.map +1 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +9 -1
- package/dist/router/router.js.map +1 -1
- package/dist/server/context.d.ts +2 -0
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +4 -1
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +16 -3
- package/dist/server/http.js.map +1 -1
- package/dist/server/node-outcome-hook.d.ts +78 -1
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +197 -1
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/node-outcome.d.ts +22 -2
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +86 -11
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/query.d.ts +2 -0
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +27 -1
- package/dist/server/query.js.map +1 -1
- package/dist/server/request-context.d.ts +8 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +23 -2
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +29 -3
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +279 -16
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +17 -3
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +6 -3
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/server-types.d.ts +37 -0
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +151 -7
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1151 -173
- package/dist/server/server.js.map +1 -1
- package/dist/server/static-headers.d.ts +47 -0
- package/dist/server/static-headers.d.ts.map +1 -0
- package/dist/server/static-headers.js +109 -0
- package/dist/server/static-headers.js.map +1 -0
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/transport-codec.d.ts +15 -0
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +49 -10
- package/dist/transport-codec.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/internal/route-execution.ts +19 -1
- package/src/node-direct.ts +7 -1
- package/src/router/router.ts +8 -1
- package/src/server/context.ts +2 -0
- package/src/server/cookies.ts +4 -1
- package/src/server/http.ts +16 -3
- package/src/server/node-outcome-hook.ts +315 -1
- package/src/server/node-outcome.ts +100 -14
- package/src/server/query.ts +26 -1
- package/src/server/request-context.ts +23 -1
- package/src/server/respond.ts +386 -17
- package/src/server/response-contract-lane.ts +17 -3
- package/src/server/server-types.ts +42 -0
- package/src/server/server.ts +1608 -236
- package/src/server/static-headers.ts +142 -0
- package/src/server.ts +9 -0
- package/src/transport-codec.ts +55 -10
package/src/server/respond.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
isResponseResult,
|
|
12
12
|
} from "./runtime-core.ts"
|
|
13
13
|
import type { CtxSet, RawContext } from "./server.ts"
|
|
14
|
+
import { mergeStaticHeaderRecord, type StaticResponseHeaders } from "./static-headers.ts"
|
|
14
15
|
|
|
15
16
|
/** Build the response headers init. The common path (no `c.set`) returns `undefined` so `Response`
|
|
16
17
|
* gets no `headers` at all. Cookies force a `Headers` object - multiple `Set-Cookie`s can't live in a
|
|
@@ -26,15 +27,273 @@ function headersInit(set: CtxSet): Record<string, string> | Headers | undefined
|
|
|
26
27
|
// Keep the fast JSON respond path byte-identical to `Response.json` without probing it at module
|
|
27
28
|
// scope: workerd forbids `Response.json()` during startup. A shared `Headers` is safe to reuse
|
|
28
29
|
// across responses: the Response constructor copies `init.headers` into its own list.
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
})
|
|
30
|
+
const JSON_CONTENT_TYPE = "application/json;charset=utf-8"
|
|
31
|
+
const JSON_CT_HEADERS = new Headers({ "content-type": JSON_CONTENT_TYPE })
|
|
32
32
|
const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Build the per-server shapes for a validated static header record. The JSON init is prebuilt once
|
|
36
|
+
* here so a static-middleware app's fused JSON lane pays a shared init read instead of a header write
|
|
37
|
+
* per declared name per request - the same reuse the hook-free lane already gets from
|
|
38
|
+
* {@link JSON_INIT_200}.
|
|
39
|
+
*/
|
|
40
|
+
export function buildStaticResponseHeaders(
|
|
41
|
+
record: Readonly<Record<string, string>>,
|
|
42
|
+
): StaticResponseHeaders {
|
|
43
|
+
const frozen = Object.freeze({ ...record })
|
|
44
|
+
const jsonHeaders = new Headers({ ...frozen, "content-type": JSON_CONTENT_TYPE })
|
|
45
|
+
let responseJson: ResponseInit | undefined
|
|
46
|
+
return {
|
|
47
|
+
record: frozen,
|
|
48
|
+
entries: Object.freeze(Object.entries(frozen)),
|
|
49
|
+
jsonHeaders,
|
|
50
|
+
jsonInit200: { status: 200, headers: jsonHeaders },
|
|
51
|
+
responseJsonInit200: () => {
|
|
52
|
+
responseJson ??= {
|
|
53
|
+
status: 200,
|
|
54
|
+
headers: new Headers({ ...frozen, "content-type": responseJsonContentType() }),
|
|
55
|
+
}
|
|
56
|
+
return responseJson
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Apply the static defaults to an ALREADY-BUILT response - the framework's error/404/timeout renders
|
|
63
|
+
* and a handler-returned `Response`, none of which go through the header init below. Defaults, so a
|
|
64
|
+
* name the response already carries is left alone. A guarded `Headers` (a raw `fetch()` result a
|
|
65
|
+
* handler returned) rejects every write, so the first `set` throws with nothing applied and the
|
|
66
|
+
* retry lands on a mutable copy.
|
|
67
|
+
*/
|
|
68
|
+
export function applyStaticResponseHeaders(
|
|
69
|
+
response: Response,
|
|
70
|
+
statics: StaticResponseHeaders,
|
|
71
|
+
): Response {
|
|
72
|
+
const headers = response.headers
|
|
73
|
+
if (knownMutableHeaders(headers)) {
|
|
74
|
+
applyStaticDefaults(headers, statics)
|
|
75
|
+
return response
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
applyStaticDefaults(headers, statics)
|
|
79
|
+
} catch {
|
|
80
|
+
const clone = new Response(response.body, response)
|
|
81
|
+
applyStaticDefaults(clone.headers, statics)
|
|
82
|
+
return stamped(clone)
|
|
83
|
+
}
|
|
84
|
+
rememberMutableHeaders(headers)
|
|
85
|
+
return response
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build a JSON response carrying the declared headers, the cheapest way this runtime allows.
|
|
90
|
+
*
|
|
91
|
+
* Everywhere but Deno that is one constructor call with the prebuilt init. Deno charges far more to
|
|
92
|
+
* ingest a header init than to mutate a built response's `Headers` - the same asymmetry the
|
|
93
|
+
* caller-set-headers path above already routes around - and handing it the prebuilt init measured
|
|
94
|
+
* 15% BELOW writing the same headers from a response hook, which would make declaring them a
|
|
95
|
+
* pessimization on that runtime.
|
|
96
|
+
*/
|
|
97
|
+
function staticJsonResponse(
|
|
98
|
+
body: string,
|
|
99
|
+
status: number,
|
|
100
|
+
statics: StaticResponseHeaders,
|
|
101
|
+
contentType: string,
|
|
102
|
+
init: ResponseInit,
|
|
103
|
+
): Response {
|
|
104
|
+
if (IS_DENO) {
|
|
105
|
+
return denoResponseWithJsonBody(
|
|
106
|
+
body,
|
|
107
|
+
status,
|
|
108
|
+
statics.record as Record<string, string>,
|
|
109
|
+
contentType,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
return new Response(body, init)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function withStatics(response: Response, statics: StaticResponseHeaders | undefined): Response {
|
|
116
|
+
return statics === undefined ? response : applyStaticResponseHeaders(response, statics)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function applyStaticDefaults(headers: Headers, statics: StaticResponseHeaders): void {
|
|
120
|
+
for (const [name, value] of statics.entries) {
|
|
121
|
+
if (!headers.has(name)) headers.set(name, value)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Fold the static defaults under whatever shape the request's own headers took. */
|
|
126
|
+
function withStaticHeaderInit(
|
|
127
|
+
own: Record<string, string> | Headers | undefined,
|
|
128
|
+
statics: StaticResponseHeaders,
|
|
129
|
+
): Record<string, string> | Headers {
|
|
130
|
+
if (own === undefined) return statics.record
|
|
131
|
+
if (own instanceof Headers) {
|
|
132
|
+
// Freshly built by `headersInit` for the cookies case, so mutating it in place is safe.
|
|
133
|
+
applyStaticDefaults(own, statics)
|
|
134
|
+
return own
|
|
135
|
+
}
|
|
136
|
+
return mergeStaticHeaderRecord(statics.record, own) as Record<string, string>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Deno's Response/Headers implementation ingests a plain header record much more slowly than it
|
|
140
|
+
// mutates an already-created response (the difference is material on realistic middleware routes
|
|
141
|
+
// that add several security/CORS headers). Bun is faster on the record constructor, so keep the
|
|
142
|
+
// portable constructor path there and select the Deno-specific shape only for Web responses that
|
|
143
|
+
// carry a plain record. The first request lazily probes Deno's native JSON content-type so this
|
|
144
|
+
// preserves the runtime's Response.json contract instead of baking in Bun's charset suffix.
|
|
145
|
+
const IS_DENO = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
|
|
146
|
+
let denoJsonContentType: string | undefined
|
|
147
|
+
|
|
148
|
+
function responseJsonContentType(): string {
|
|
149
|
+
if (denoJsonContentType === undefined) {
|
|
150
|
+
denoJsonContentType = Response.json(0).headers.get("content-type") ?? "application/json"
|
|
151
|
+
}
|
|
152
|
+
return denoJsonContentType
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function denoResponseWithJsonBody(
|
|
156
|
+
body: string,
|
|
157
|
+
status: number,
|
|
158
|
+
headers: Record<string, string>,
|
|
159
|
+
defaultContentType = responseJsonContentType(),
|
|
160
|
+
): Response {
|
|
161
|
+
const response = new Response(body, { status })
|
|
162
|
+
let hasContentType = false
|
|
163
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
164
|
+
response.headers.set(name, value)
|
|
165
|
+
if (name.toLowerCase() === "content-type") {
|
|
166
|
+
hasContentType = true
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (!hasContentType) response.headers.set("content-type", defaultContentType)
|
|
170
|
+
return response
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The framework-buffered-body marker shared with the Node adapter: a Response carrying it exposes
|
|
174
|
+
// its already-serialized bytes without draining. On the Web serving paths it exists ONLY when a
|
|
175
|
+
// registered body/raw hook needs it, so hook-less apps pay nothing.
|
|
176
|
+
const RESPONSE_BODY = Symbol.for("nifra.response.body")
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Headers objects the framework itself constructed - guaranteed mutable, no guard. Response
|
|
180
|
+
* middleware adapters consult this before falling back to a mutability probe, so the hot path
|
|
181
|
+
* (every framework-rendered response) never pays per-request `Headers` operations to learn what
|
|
182
|
+
* construction already knew; only a handler-returned foreign `Response` (a raw `fetch()` result,
|
|
183
|
+
* whose headers are guarded immutable) reaches the probe.
|
|
184
|
+
*/
|
|
185
|
+
const MUTABLE_RESPONSE_HEADERS = new WeakSet<Headers>()
|
|
186
|
+
|
|
187
|
+
export function knownMutableHeaders(headers: Headers): boolean {
|
|
188
|
+
return MUTABLE_RESPONSE_HEADERS.has(headers)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function rememberMutableHeaders(headers: Headers): void {
|
|
192
|
+
MUTABLE_RESPONSE_HEADERS.add(headers)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Stamp a framework-constructed Response's headers as known-mutable, and return it. */
|
|
196
|
+
function stamped(response: Response): Response {
|
|
197
|
+
MUTABLE_RESPONSE_HEADERS.add(response.headers)
|
|
198
|
+
return response
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isBodylessStatus(status: number): boolean {
|
|
202
|
+
return status === 204 || status === 205 || status === 304
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Normalize handler-produced bodyless responses before response middleware sees them. */
|
|
206
|
+
export function normalizeBodylessResponse(response: Response): Response {
|
|
207
|
+
if (!isBodylessStatus(response.status)) return response
|
|
208
|
+
if (response.body === null && !response.headers.has("content-length")) return response
|
|
209
|
+
const headers = new Headers(response.headers)
|
|
210
|
+
headers.delete("content-length")
|
|
211
|
+
return stamped(
|
|
212
|
+
new Response(null, {
|
|
213
|
+
status: response.status,
|
|
214
|
+
statusText: response.statusText,
|
|
215
|
+
headers,
|
|
216
|
+
}),
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
type TaggedResponseMarker = {
|
|
221
|
+
readonly body: string | Uint8Array
|
|
222
|
+
readonly owner?: object
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
type ResponseBodyTagOption = false | true | object | undefined
|
|
226
|
+
|
|
227
|
+
/** The framework-serialized bytes riding a tagged Response, or `undefined` for raw/streamed ones. */
|
|
228
|
+
export function taggedResponseBody(
|
|
229
|
+
response: Response,
|
|
230
|
+
owners?: ReadonlySet<object>,
|
|
231
|
+
): string | Uint8Array | undefined {
|
|
232
|
+
const marker = (response as { [RESPONSE_BODY]?: string | Uint8Array | TaggedResponseMarker })[
|
|
233
|
+
RESPONSE_BODY
|
|
234
|
+
]
|
|
235
|
+
if (owners !== undefined) {
|
|
236
|
+
// A Web response is framework-buffered only when this app (or an intentionally merged app) put
|
|
237
|
+
// the marker on it. The legacy primitive marker remains readable by the Node bridge, but must
|
|
238
|
+
// not let an arbitrary response impersonate a local framework body on the Web hook path.
|
|
239
|
+
if (
|
|
240
|
+
typeof marker !== "object" ||
|
|
241
|
+
marker === null ||
|
|
242
|
+
marker instanceof Uint8Array ||
|
|
243
|
+
marker.owner === undefined ||
|
|
244
|
+
!owners.has(marker.owner)
|
|
245
|
+
) {
|
|
246
|
+
return undefined
|
|
247
|
+
}
|
|
248
|
+
return marker.body
|
|
249
|
+
}
|
|
250
|
+
if (typeof marker === "string" || marker instanceof Uint8Array) return marker
|
|
251
|
+
return marker?.body
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function taggedResponseOwner(response: Response): object | undefined {
|
|
255
|
+
const marker = (response as { [RESPONSE_BODY]?: string | Uint8Array | TaggedResponseMarker })[
|
|
256
|
+
RESPONSE_BODY
|
|
257
|
+
]
|
|
258
|
+
return typeof marker === "object" && marker !== null && "owner" in marker
|
|
259
|
+
? marker.owner
|
|
260
|
+
: undefined
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function markTaggedResponse(
|
|
264
|
+
response: Response,
|
|
265
|
+
body: string | Uint8Array,
|
|
266
|
+
owner?: object,
|
|
267
|
+
): Response {
|
|
268
|
+
Object.defineProperty(response, RESPONSE_BODY, {
|
|
269
|
+
value: owner === undefined ? body : ({ body, owner } satisfies TaggedResponseMarker),
|
|
270
|
+
})
|
|
271
|
+
return response
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function tagged(
|
|
275
|
+
response: Response,
|
|
276
|
+
body: string,
|
|
277
|
+
tagResponseBody: ResponseBodyTagOption,
|
|
278
|
+
): Response {
|
|
279
|
+
stamped(response)
|
|
280
|
+
return tagResponseBody === false || tagResponseBody === undefined
|
|
281
|
+
? response
|
|
282
|
+
: markTaggedResponse(
|
|
283
|
+
response,
|
|
284
|
+
body,
|
|
285
|
+
typeof tagResponseBody === "object" ? tagResponseBody : undefined,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
34
289
|
/** Fused-lane respond when `c.set` is untouched. Bun 1.3's native `Response.json` now beats the
|
|
35
290
|
* older hand-inlined stringify + Response construction on this lane while preserving the exact
|
|
36
291
|
* body/content-type contract; keep the generic fallback for non-JSON values. */
|
|
37
|
-
export function fusedRespondNoSet(
|
|
292
|
+
export function fusedRespondNoSet(
|
|
293
|
+
result: unknown,
|
|
294
|
+
tagResponseBody: ResponseBodyTagOption = false,
|
|
295
|
+
statics?: StaticResponseHeaders,
|
|
296
|
+
): Response {
|
|
38
297
|
if (
|
|
39
298
|
result !== undefined &&
|
|
40
299
|
!(result instanceof Response) &&
|
|
@@ -42,43 +301,153 @@ export function fusedRespondNoSet(result: unknown): Response {
|
|
|
42
301
|
result !== null &&
|
|
43
302
|
!isResponseResult(result)
|
|
44
303
|
) {
|
|
45
|
-
|
|
304
|
+
// Deno's cheapest shape for this lane is its own `Response.json` (which fuses serialization and
|
|
305
|
+
// construction natively, and sets the content-type natively) followed by post-construction header
|
|
306
|
+
// writes - NOT a header init, which measured 15% below writing the same names from a hook. So on
|
|
307
|
+
// Deno the tier's only saving here is the response walk itself; every other runtime keeps the
|
|
308
|
+
// one-call prebuilt init below.
|
|
309
|
+
if (statics !== undefined && IS_DENO && !tagResponseBody) {
|
|
310
|
+
const response = Response.json(result)
|
|
311
|
+
for (const [name, value] of statics.entries) response.headers.set(name, value)
|
|
312
|
+
return stamped(response)
|
|
313
|
+
}
|
|
314
|
+
// With declared static headers the prebuilt init already carries them plus the JSON
|
|
315
|
+
// content-type, so this lane still constructs the response in one call - the whole point of
|
|
316
|
+
// declaring headers statically instead of writing them per response.
|
|
317
|
+
if (tagResponseBody || statics !== undefined) {
|
|
318
|
+
const body = JSON.stringify(result) as string | undefined
|
|
319
|
+
// Each shape carries the content-type of the lane it stands in for: the framework's own for the
|
|
320
|
+
// tagging path this branch already owned, and `Response.json`'s for the construction below that
|
|
321
|
+
// the static tier replaces. They differ on Deno, and a declared header must not change one.
|
|
322
|
+
if (body !== undefined)
|
|
323
|
+
return tagged(
|
|
324
|
+
statics === undefined
|
|
325
|
+
? new Response(body, JSON_INIT_200)
|
|
326
|
+
: tagResponseBody
|
|
327
|
+
? staticJsonResponse(body, 200, statics, JSON_CONTENT_TYPE, statics.jsonInit200)
|
|
328
|
+
: staticJsonResponse(
|
|
329
|
+
body,
|
|
330
|
+
200,
|
|
331
|
+
statics,
|
|
332
|
+
responseJsonContentType(),
|
|
333
|
+
statics.responseJsonInit200(),
|
|
334
|
+
),
|
|
335
|
+
body,
|
|
336
|
+
tagResponseBody,
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
return stamped(
|
|
340
|
+
statics === undefined
|
|
341
|
+
? Response.json(result)
|
|
342
|
+
: Response.json(result, statics.responseJsonInit200()),
|
|
343
|
+
)
|
|
46
344
|
}
|
|
47
|
-
return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS)
|
|
345
|
+
return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS, tagResponseBody, statics)
|
|
48
346
|
}
|
|
49
347
|
|
|
50
348
|
/** Fused-lane respond with a context: read `c.set` once; untouched (the common case) -> the fast
|
|
51
349
|
* JSON respond; touched -> the generic `toResponse` with those controls (statuses, headers, cookies). */
|
|
52
|
-
export function fusedRespond(
|
|
350
|
+
export function fusedRespond(
|
|
351
|
+
result: unknown,
|
|
352
|
+
ctx: RawContext,
|
|
353
|
+
tagResponseBody: ResponseBodyTagOption = false,
|
|
354
|
+
statics?: StaticResponseHeaders,
|
|
355
|
+
): Response {
|
|
53
356
|
const set = ctx[CONTEXT_SET]()
|
|
54
|
-
if (set === undefined) return fusedRespondNoSet(result)
|
|
55
|
-
return toResponse(result as HandlerResult, set)
|
|
357
|
+
if (set === undefined) return fusedRespondNoSet(result, tagResponseBody, statics)
|
|
358
|
+
return toResponse(result as HandlerResult, set, tagResponseBody, statics)
|
|
56
359
|
}
|
|
57
360
|
|
|
58
|
-
export function toResponse(
|
|
361
|
+
export function toResponse(
|
|
362
|
+
result: HandlerResult,
|
|
363
|
+
set: CtxSet,
|
|
364
|
+
tagResponseBody: ResponseBodyTagOption = false,
|
|
365
|
+
statics?: StaticResponseHeaders,
|
|
366
|
+
): Response {
|
|
59
367
|
if (isResponseResult(result)) {
|
|
60
|
-
return
|
|
368
|
+
return withStatics(
|
|
369
|
+
appendCookiesToResponse(normalizeBodylessResponse(result.toResponse()), set),
|
|
370
|
+
statics,
|
|
371
|
+
)
|
|
61
372
|
}
|
|
62
373
|
if (result instanceof Response) {
|
|
63
|
-
return appendCookiesToResponse(result, set)
|
|
374
|
+
return withStatics(appendCookiesToResponse(normalizeBodylessResponse(result), set), statics)
|
|
64
375
|
}
|
|
65
|
-
|
|
376
|
+
// The static tier only changes WHICH prebuilt header shape the branches below reach for: the
|
|
377
|
+
// request's own headers are folded on top of the declared defaults once, here.
|
|
378
|
+
const own = headersInit(set)
|
|
379
|
+
const headers = statics === undefined ? own : withStaticHeaderInit(own, statics)
|
|
380
|
+
const jsonCtHeaders = statics === undefined ? JSON_CT_HEADERS : statics.jsonHeaders
|
|
66
381
|
const status = set.status ?? (result === undefined ? 204 : 200)
|
|
67
|
-
if (
|
|
382
|
+
if (status === 204 || status === 205 || status === 304) {
|
|
383
|
+
return stamped(new Response(null, headers === undefined ? { status } : { status, headers }))
|
|
384
|
+
}
|
|
385
|
+
if (own === undefined && result !== undefined) {
|
|
68
386
|
// Fast respond (profiled ~50 ns/req faster on every plain-JSON return): `JSON.stringify` + a
|
|
69
387
|
// prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical -
|
|
70
388
|
// same body bytes, same probed content-type. `undefined` from stringify (a function/symbol
|
|
71
389
|
// result) delegates to Response.json so its TypeError contract stays the single source.
|
|
72
390
|
const body = JSON.stringify(result) as string | undefined
|
|
73
391
|
if (body !== undefined) {
|
|
74
|
-
|
|
392
|
+
const init: ResponseInit =
|
|
393
|
+
status === 200
|
|
394
|
+
? (statics?.jsonInit200 ?? JSON_INIT_200)
|
|
395
|
+
: { status, headers: jsonCtHeaders }
|
|
396
|
+
return tagged(
|
|
397
|
+
statics === undefined
|
|
398
|
+
? new Response(body, init)
|
|
399
|
+
: staticJsonResponse(body, status, statics, JSON_CONTENT_TYPE, init),
|
|
75
400
|
body,
|
|
76
|
-
|
|
401
|
+
tagResponseBody,
|
|
77
402
|
)
|
|
78
403
|
}
|
|
79
404
|
}
|
|
80
405
|
const init: ResponseInit = headers === undefined ? { status } : { status, headers }
|
|
81
|
-
|
|
406
|
+
if (result === undefined) return stamped(new Response(null, init))
|
|
407
|
+
if (tagResponseBody) {
|
|
408
|
+
const body = JSON.stringify(result) as string | undefined
|
|
409
|
+
if (body !== undefined) {
|
|
410
|
+
const response =
|
|
411
|
+
IS_DENO && !(headers instanceof Headers)
|
|
412
|
+
? denoResponseWithJsonBody(
|
|
413
|
+
body,
|
|
414
|
+
status,
|
|
415
|
+
headers as Record<string, string>,
|
|
416
|
+
JSON_CT_HEADERS.get("content-type") ?? "application/json;charset=utf-8",
|
|
417
|
+
)
|
|
418
|
+
: new Response(body, { status, headers: withJsonContentType(headers) })
|
|
419
|
+
return tagged(
|
|
420
|
+
response,
|
|
421
|
+
body,
|
|
422
|
+
typeof tagResponseBody === "object" ? tagResponseBody : undefined,
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (IS_DENO && headers !== undefined && !(headers instanceof Headers)) {
|
|
427
|
+
const body = JSON.stringify(result) as string | undefined
|
|
428
|
+
if (body !== undefined) return stamped(denoResponseWithJsonBody(body, status, headers))
|
|
429
|
+
}
|
|
430
|
+
return stamped(Response.json(result, init))
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Add a JSON content-type without pre-building a `Headers` instance just to check-and-set one key:
|
|
434
|
+
// `new Headers(record)` measured ~8us/call on V8 (Deno) for a handful of header entries - it's a
|
|
435
|
+
// full parse-and-validate of every entry, done here only to ask "is content-type present," when
|
|
436
|
+
// `new Response(body, { headers })` a few lines up already does that same ingestion once, for free,
|
|
437
|
+
// on whatever shape is handed to it. A plain `Record` (the common case - no cookies queued) gets a
|
|
438
|
+
// shallow copy; an existing `Headers` (the cookies case - freshly built by `headersInit`, not
|
|
439
|
+
// exposed elsewhere) is mutated in place, same as before.
|
|
440
|
+
function withJsonContentType(
|
|
441
|
+
headers: Record<string, string> | Headers | undefined,
|
|
442
|
+
): Record<string, string> | Headers {
|
|
443
|
+
if (headers === undefined) return JSON_CT_HEADERS
|
|
444
|
+
if (headers instanceof Headers) {
|
|
445
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json;charset=utf-8")
|
|
446
|
+
return headers
|
|
447
|
+
}
|
|
448
|
+
return Object.hasOwn(headers, "content-type")
|
|
449
|
+
? headers
|
|
450
|
+
: { ...headers, "content-type": "application/json;charset=utf-8" }
|
|
82
451
|
}
|
|
83
452
|
|
|
84
453
|
export function appendCookiesToResponse(response: Response, set: CtxSet): Response {
|
|
@@ -29,6 +29,17 @@
|
|
|
29
29
|
* That is not an inconsistency to paper over: a strict schema has already declared that extra fields
|
|
30
30
|
* are an error, and a stripping one has declared they are ignorable. Enforcement honours what the
|
|
31
31
|
* author wrote rather than overriding it.
|
|
32
|
+
*
|
|
33
|
+
* ## What enforcement costs
|
|
34
|
+
*
|
|
35
|
+
* Less than it looks like it should. With a compiled validator (`@nifrajs/schema`'s `t` compiles at
|
|
36
|
+
* construction; zod/valibot equivalents are similarly cheap), the check itself measures in the
|
|
37
|
+
* ~100ns-per-response range - on a realistic middleware-carrying route, enforce mode benchmarks
|
|
38
|
+
* within measurement noise of the same route with no contract at all, on Bun and Node alike. The
|
|
39
|
+
* one real cost is structural: a contracted route cannot take the bare-route fused lane, because
|
|
40
|
+
* the check needs the handler's VALUE before it becomes bytes. A route with any middleware, derive,
|
|
41
|
+
* or lifecycle hook has already left that lane, so for the routes that look like production the
|
|
42
|
+
* contract is effectively free - declare it.
|
|
32
43
|
*/
|
|
33
44
|
import type { StandardIssue, StandardResult, StandardSchemaV1 } from "../schema/standard.ts"
|
|
34
45
|
import { INSTALL_RESPONSE_CONTRACT } from "./install.ts"
|
|
@@ -147,9 +158,12 @@ interface ResponseContractInstallable {
|
|
|
147
158
|
* Install it before the routes it should cover - like `idempotency()`, the decision is made per route
|
|
148
159
|
* at registration, so routes registered earlier are not retroactively covered.
|
|
149
160
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* is
|
|
161
|
+
* The check itself is cheap - with a compiled validator it measures in the ~100ns-per-response
|
|
162
|
+
* range, within benchmark noise of an uncontracted route on any route that carries middleware or a
|
|
163
|
+
* derive. What a contracted route does give up is the bare-route fused lane (the check needs the
|
|
164
|
+
* handler's value before it becomes bytes), which only a route with NO other lifecycle steps was
|
|
165
|
+
* taking anyway. Opt-in because not installing the plugin keeps the lane out of the bundle, not
|
|
166
|
+
* because enforcement is expensive.
|
|
153
167
|
*/
|
|
154
168
|
export function responseContract(mode: ResponseContractMode = "warn"): IdentityPlugin {
|
|
155
169
|
const runtime: ResponseContractRuntime = {
|
|
@@ -9,6 +9,12 @@ import type { Method } from "../router/router.ts"
|
|
|
9
9
|
import type { ClientIpTrust } from "./client-ip.ts"
|
|
10
10
|
import type { Context, Platform, RouteSchema } from "./context.ts"
|
|
11
11
|
import type { Logger } from "./logger.ts"
|
|
12
|
+
import type {
|
|
13
|
+
NodeRequestHook,
|
|
14
|
+
NodeResponseHook,
|
|
15
|
+
ResponseBodyHook,
|
|
16
|
+
ResponseHeadersHook,
|
|
17
|
+
} from "./node-outcome-hook.ts"
|
|
12
18
|
import type { MaybePromise, OnRequestResult } from "./server.ts"
|
|
13
19
|
|
|
14
20
|
/**
|
|
@@ -187,10 +193,46 @@ export interface RunningServer {
|
|
|
187
193
|
export interface Middleware {
|
|
188
194
|
readonly name?: string
|
|
189
195
|
readonly onRequest?: (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
|
|
196
|
+
/** Allocation-light Node equivalent of `onRequest` for header-only hooks. */
|
|
197
|
+
readonly onNodeRequest?: NodeRequestHook
|
|
190
198
|
readonly around?: <T>(context: Context, next: () => MaybePromise<T>) => MaybePromise<T>
|
|
191
199
|
readonly beforeHandle?: (context: Context) => MaybePromise<unknown>
|
|
192
200
|
readonly afterHandle?: (result: unknown, context: Context) => MaybePromise<unknown>
|
|
193
201
|
readonly onResponse?: (response: Response, req: Request) => MaybePromise<Response>
|
|
202
|
+
/**
|
|
203
|
+
* Header-only equivalent of `onResponse` for the Node-direct serializer. It is used only when every
|
|
204
|
+
* response hook in the app supplies this paired native implementation; arbitrary response transforms
|
|
205
|
+
* continue through the Web Response path.
|
|
206
|
+
*/
|
|
207
|
+
readonly onNodeResponse?: NodeResponseHook
|
|
208
|
+
/**
|
|
209
|
+
* Response headers with NO per-request decision behind them. Declared here instead of written by a
|
|
210
|
+
* hook, they are folded into response construction, so a bundle that only sets static headers does
|
|
211
|
+
* not cost the app its fused/native lanes (see {@link Server.responseHeaders}). They are defaults:
|
|
212
|
+
* anything the request produced wins. Reach for `onResponseHeaders` the moment a value depends on
|
|
213
|
+
* the request, the route, or the response.
|
|
214
|
+
*/
|
|
215
|
+
readonly responseHeaders?: Readonly<Record<string, string>>
|
|
216
|
+
/**
|
|
217
|
+
* PORTABLE header-only response hook - the recommended shape when the middleware only reads or
|
|
218
|
+
* writes headers. One implementation runs on every runtime: inside the Web `onResponse` walk
|
|
219
|
+
* against the response's own `Headers`, and on Node as a self-paired native hook against the
|
|
220
|
+
* outcome record - so it never forces the Node adapter off its direct writer. Use `onResponse` +
|
|
221
|
+
* an optional `onNodeResponse` twin only when the hook must see a real `Response`.
|
|
222
|
+
*/
|
|
223
|
+
readonly onResponseHeaders?: ResponseHeadersHook
|
|
224
|
+
/**
|
|
225
|
+
* PORTABLE post-serialization body hook - the payload tier. Receives the final
|
|
226
|
+
* framework-serialized bytes (already resident on every runtime; no stream is drained) plus the
|
|
227
|
+
* header view and status, and may return replacement bytes. Raw handler-returned `Response`s
|
|
228
|
+
* (proxies, SSE, streamed SSR) are skipped by contract - transforming those needs `onResponse`.
|
|
229
|
+
*/
|
|
230
|
+
readonly onResponseBody?: ResponseBodyHook
|
|
231
|
+
/**
|
|
232
|
+
* Response transform for untagged/raw responses (streams, proxied fetches, and framework errors).
|
|
233
|
+
* Framework-serialized payloads use `onResponseBody` and remain on the Node direct lane.
|
|
234
|
+
*/
|
|
235
|
+
readonly onResponseRaw?: (response: Response, req: Request) => MaybePromise<Response>
|
|
194
236
|
readonly onResponseFinalized?: (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
|
|
195
237
|
readonly onError?: (error: unknown, context: Context) => MaybePromise<unknown>
|
|
196
238
|
}
|