@nifrajs/core 5.4.0 → 5.5.1
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/CHANGELOG.md +35 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +2 -1
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/route-program.d.ts +5 -0
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +36 -15
- package/dist/internal/route-program.js.map +1 -1
- package/dist/router/router.d.ts +3 -2
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +22 -6
- package/dist/router/router.js.map +1 -1
- package/dist/security/baseline.d.ts.map +1 -1
- package/dist/security/baseline.js +4 -3
- package/dist/security/baseline.js.map +1 -1
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +4 -3
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body.d.ts +2 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +5 -0
- package/dist/server/body.js.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +9 -2
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.js +1 -1
- package/dist/server/idempotency-lane.d.ts +11 -12
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +301 -151
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/methods.d.ts +3 -2
- package/dist/server/methods.d.ts.map +1 -1
- package/dist/server/methods.js.map +1 -1
- package/dist/server/multipart.d.ts.map +1 -1
- package/dist/server/multipart.js +42 -13
- package/dist/server/multipart.js.map +1 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +2 -2
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/query.d.ts +1 -1
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +13 -5
- package/dist/server/query.js.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +3 -3
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +17 -0
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +81 -3
- package/dist/server/respond.js.map +1 -1
- package/dist/server/runtime-core.d.ts +15 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +41 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +5 -3
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +14 -5
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +179 -59
- package/dist/server/server.js.map +1 -1
- package/dist/server/transport-lane.d.ts +2 -2
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +20 -20
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +7 -1
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/static-file.d.ts +29 -6
- package/dist/static-file.d.ts.map +1 -1
- package/dist/static-file.js +43 -12
- package/dist/static-file.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/internal/route-lanes.ts +18 -16
- package/src/internal/route-program.ts +76 -12
- package/src/router/router.ts +26 -5
- package/src/security/baseline.ts +4 -3
- package/src/server/body-lane.ts +4 -4
- package/src/server/body.ts +6 -0
- package/src/server/cookies.ts +7 -2
- package/src/server/core-copies.ts +1 -1
- package/src/server/idempotency-lane.ts +343 -209
- package/src/server/methods.ts +3 -2
- package/src/server/multipart.ts +62 -14
- package/src/server/node-outcome.ts +2 -1
- package/src/server/query.ts +12 -5
- package/src/server/request-context.ts +6 -3
- package/src/server/respond.ts +108 -6
- package/src/server/runtime-core.ts +54 -1
- package/src/server/server-types.ts +5 -3
- package/src/server/server.ts +249 -99
- package/src/server/transport-lane.ts +28 -22
- package/src/server/ws-bun.ts +12 -1
- package/src/static-file.ts +72 -16
package/src/server/multipart.ts
CHANGED
|
@@ -71,6 +71,8 @@ const FALLBACK_FILE_NAME = "file"
|
|
|
71
71
|
const MAX_FILE_NAME_LENGTH = 255
|
|
72
72
|
|
|
73
73
|
const DASH = 45
|
|
74
|
+
const CR = 13
|
|
75
|
+
const LF = 10
|
|
74
76
|
const QUOTE = 34
|
|
75
77
|
const BACKSLASH = 92
|
|
76
78
|
const SLASH = 47
|
|
@@ -148,41 +150,74 @@ function failureTable(pattern: Uint8Array): Uint8Array {
|
|
|
148
150
|
return table
|
|
149
151
|
}
|
|
150
152
|
|
|
151
|
-
/**
|
|
153
|
+
/**
|
|
154
|
+
* What a scan for `--boundary` found: how many, how many of them close a part with no content, and
|
|
155
|
+
* whether the last one closes the body.
|
|
156
|
+
*/
|
|
152
157
|
interface Delimiters {
|
|
153
158
|
readonly count: number
|
|
159
|
+
readonly empty: number
|
|
154
160
|
readonly closed: boolean
|
|
155
161
|
}
|
|
156
162
|
|
|
163
|
+
/** The most delimiters a scan counts: past either budget the body is refused unparsed. */
|
|
164
|
+
interface DelimiterLimits {
|
|
165
|
+
readonly filled: number
|
|
166
|
+
readonly empty: number
|
|
167
|
+
}
|
|
168
|
+
|
|
157
169
|
/** `end` is the index after the last delimiter, or -1 when there was none. */
|
|
158
|
-
function delimiters(bytes: Uint8Array, count: number, end: number): Delimiters {
|
|
159
|
-
return { count, closed: end !== -1 && bytes[end] === DASH && bytes[end + 1] === DASH }
|
|
170
|
+
function delimiters(bytes: Uint8Array, count: number, empty: number, end: number): Delimiters {
|
|
171
|
+
return { count, empty, closed: end !== -1 && bytes[end] === DASH && bytes[end + 1] === DASH }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Whether the delimiter at `at` closes a part with no content: the blank line ending the part's
|
|
176
|
+
* headers is followed at once by the CRLF that opens the delimiter. A part whose own content ends
|
|
177
|
+
* in a blank line reads the same, which only spends the empty budget instead of the filled one.
|
|
178
|
+
*/
|
|
179
|
+
function closesEmptyPart(bytes: Uint8Array, at: number): boolean {
|
|
180
|
+
return (
|
|
181
|
+
at >= 6 &&
|
|
182
|
+
bytes[at - 1] === LF &&
|
|
183
|
+
bytes[at - 2] === CR &&
|
|
184
|
+
bytes[at - 3] === LF &&
|
|
185
|
+
bytes[at - 4] === CR &&
|
|
186
|
+
bytes[at - 5] === LF &&
|
|
187
|
+
bytes[at - 6] === CR
|
|
188
|
+
)
|
|
160
189
|
}
|
|
161
190
|
|
|
191
|
+
const overLimits = (count: number, empty: number, limits: DelimiterLimits): boolean =>
|
|
192
|
+
count - empty > limits.filled || empty > limits.empty
|
|
193
|
+
|
|
162
194
|
/** Knuth-Morris-Pratt from `start`: linear in the input whatever the pattern repeats. */
|
|
163
195
|
function scanFrom(
|
|
164
196
|
bytes: Uint8Array,
|
|
165
197
|
pattern: Uint8Array,
|
|
166
198
|
start: number,
|
|
167
199
|
counted: number,
|
|
200
|
+
countedEmpty: number,
|
|
168
201
|
lastEnd: number,
|
|
169
|
-
|
|
202
|
+
limits: DelimiterLimits,
|
|
170
203
|
): Delimiters {
|
|
171
204
|
const table = failureTable(pattern)
|
|
172
205
|
const size = pattern.length
|
|
173
206
|
let count = counted
|
|
207
|
+
let empty = countedEmpty
|
|
174
208
|
let end = lastEnd
|
|
175
209
|
for (let i = start, k = 0; i < bytes.length; i++) {
|
|
176
210
|
const byte = bytes[i]
|
|
177
211
|
while (k > 0 && byte !== pattern[k]) k = table[k - 1]!
|
|
178
212
|
if (byte === pattern[k]) k++
|
|
179
213
|
if (k === size) {
|
|
214
|
+
if (count > 0 && closesEmptyPart(bytes, i + 1 - size)) empty++
|
|
180
215
|
end = i + 1
|
|
181
|
-
if (++count
|
|
216
|
+
if (overLimits(++count, empty, limits)) break
|
|
182
217
|
k = 0
|
|
183
218
|
}
|
|
184
219
|
}
|
|
185
|
-
return delimiters(bytes, count, end)
|
|
220
|
+
return delimiters(bytes, count, empty, end)
|
|
186
221
|
}
|
|
187
222
|
|
|
188
223
|
/**
|
|
@@ -194,10 +229,15 @@ function scanFrom(
|
|
|
194
229
|
* the verify loop re-read the same bytes (dashes against a boundary of dashes) spends a budget of
|
|
195
230
|
* one pass over the input, then the scan continues with the linear matcher.
|
|
196
231
|
*/
|
|
197
|
-
function scanDelimiters(
|
|
232
|
+
function scanDelimiters(
|
|
233
|
+
bytes: Uint8Array,
|
|
234
|
+
pattern: Uint8Array,
|
|
235
|
+
limits: DelimiterLimits,
|
|
236
|
+
): Delimiters {
|
|
198
237
|
const size = pattern.length
|
|
199
238
|
const last = bytes.length - size
|
|
200
239
|
let count = 0
|
|
240
|
+
let empty = 0
|
|
201
241
|
let end = -1
|
|
202
242
|
let work = 0
|
|
203
243
|
let i = 0
|
|
@@ -207,16 +247,17 @@ function scanDelimiters(bytes: Uint8Array, pattern: Uint8Array, limit: number):
|
|
|
207
247
|
let k = 1
|
|
208
248
|
while (k < size && bytes[i + k] === pattern[k]) k++
|
|
209
249
|
if (k === size) {
|
|
250
|
+
if (count > 0 && closesEmptyPart(bytes, i)) empty++
|
|
210
251
|
i += size
|
|
211
252
|
end = i
|
|
212
|
-
if (++count
|
|
253
|
+
if (overLimits(++count, empty, limits)) break
|
|
213
254
|
continue
|
|
214
255
|
}
|
|
215
256
|
work += k
|
|
216
|
-
if (work > bytes.length) return scanFrom(bytes, pattern, i, count, end,
|
|
257
|
+
if (work > bytes.length) return scanFrom(bytes, pattern, i, count, empty, end, limits)
|
|
217
258
|
i++
|
|
218
259
|
}
|
|
219
|
-
return delimiters(bytes, count, end)
|
|
260
|
+
return delimiters(bytes, count, empty, end)
|
|
220
261
|
}
|
|
221
262
|
|
|
222
263
|
/** True when `value` takes more than `max` bytes as UTF-8. */
|
|
@@ -308,8 +349,13 @@ function createReader(
|
|
|
308
349
|
const maxFiles = limitOf(limits.maxFiles, DEFAULT_MAX_FILES, "maxFiles")
|
|
309
350
|
const maxFieldBytes = limitOf(limits.maxFieldBytes, DEFAULT_MAX_FIELD_BYTES, "maxFieldBytes")
|
|
310
351
|
const maxFileBytes = limitOf(limits.maxFileBytes, UNLIMITED_BODY_BYTES, "maxFileBytes")
|
|
311
|
-
// Every part sits between two delimiters, and the last delimiter closes the body.
|
|
312
|
-
|
|
352
|
+
// Every part sits between two delimiters, and the last delimiter closes the body. A part with no
|
|
353
|
+
// content - a file input left empty, which is dropped below - spends a budget of its own, so an
|
|
354
|
+
// untouched optional input does not count against the parts that carry something.
|
|
355
|
+
const delimiterLimits: DelimiterLimits = {
|
|
356
|
+
filled: maxFields + maxFiles + 1,
|
|
357
|
+
empty: maxFields + maxFiles,
|
|
358
|
+
}
|
|
313
359
|
|
|
314
360
|
const read: SchemaBodyReader = async (source, contentType, maxBodyBytes, protoPoisoning) => {
|
|
315
361
|
const boundary = boundaryOf(contentType)
|
|
@@ -335,8 +381,10 @@ function createReader(
|
|
|
335
381
|
delimiter[0] = DASH
|
|
336
382
|
delimiter[1] = DASH
|
|
337
383
|
for (let i = 0; i < boundary.length; i++) delimiter[i + 2] = boundary.charCodeAt(i)
|
|
338
|
-
const found = scanDelimiters(bytes, delimiter,
|
|
339
|
-
if (found.count
|
|
384
|
+
const found = scanDelimiters(bytes, delimiter, delimiterLimits)
|
|
385
|
+
if (overLimits(found.count, found.empty, delimiterLimits)) {
|
|
386
|
+
return plainError(413, "too_many_parts")
|
|
387
|
+
}
|
|
340
388
|
// A body is over when `--boundary--` says so. One that stops short is refused here, because a
|
|
341
389
|
// parser that tolerates it hands back the parts it saw as if they were the whole form.
|
|
342
390
|
if (!found.closed) return INVALID()
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
normalizeBodylessResponse,
|
|
11
11
|
RESPONSE_OWNED_HEADERS,
|
|
12
12
|
rememberRenderedResponse,
|
|
13
|
+
responseBodyUsed,
|
|
13
14
|
SENT_BODY,
|
|
14
15
|
taggedResponseBody,
|
|
15
16
|
} from "./respond.ts"
|
|
@@ -83,7 +84,7 @@ export function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutc
|
|
|
83
84
|
)
|
|
84
85
|
}
|
|
85
86
|
if (result instanceof Response) {
|
|
86
|
-
if (result
|
|
87
|
+
if (responseBodyUsed(result)) return plainNodeOutcome(SENT_BODY.plain!, EMPTY_RESPONSE_CONTROLS)
|
|
87
88
|
return nodeOutcomeFromResponse(applySetToResponse(normalizeBodylessResponse(result), set))
|
|
88
89
|
}
|
|
89
90
|
const status = set.status ?? (result === undefined ? 204 : 200)
|
package/src/server/query.ts
CHANGED
|
@@ -98,8 +98,10 @@ export function queryObjectOf(search: string): Record<string, QueryValue> {
|
|
|
98
98
|
const amp = search.indexOf("&", pos)
|
|
99
99
|
const end = amp === -1 ? search.length : amp
|
|
100
100
|
if (end > pos) {
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
// Bounded by the segment, so a run of bare segments (`a&a&a...`) stays linear; keys are short,
|
|
102
|
+
// so the loop also beats an `indexOf` call.
|
|
103
|
+
let split = pos
|
|
104
|
+
while (split < end && search.charCodeAt(split) !== 61 /* = */) split++
|
|
103
105
|
const key = search.slice(pos, split)
|
|
104
106
|
const value = split === end ? "" : search.slice(split + 1, end)
|
|
105
107
|
setQueryValue(out, key, value)
|
|
@@ -159,12 +161,17 @@ export function isJsonMediaType(contentType: string): boolean {
|
|
|
159
161
|
)
|
|
160
162
|
}
|
|
161
163
|
|
|
162
|
-
/** `application/x-www-form-urlencoded`, with or without
|
|
164
|
+
/** `application/x-www-form-urlencoded`, with or without parameters, in any case (RFC 9110 8.3.1). */
|
|
163
165
|
export function isUrlEncodedForm(contentType: string): boolean {
|
|
164
|
-
|
|
166
|
+
if (
|
|
165
167
|
contentType === "application/x-www-form-urlencoded" ||
|
|
166
168
|
contentType.startsWith("application/x-www-form-urlencoded;")
|
|
167
|
-
)
|
|
169
|
+
) {
|
|
170
|
+
return true
|
|
171
|
+
}
|
|
172
|
+
const semi = contentType.indexOf(";")
|
|
173
|
+
const essence = (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase()
|
|
174
|
+
return essence === "application/x-www-form-urlencoded"
|
|
168
175
|
}
|
|
169
176
|
|
|
170
177
|
const FORM_DECODER = new TextDecoder()
|
|
@@ -14,7 +14,7 @@ import { headerObjectOf } from "./headers.ts"
|
|
|
14
14
|
import { plainError } from "./http.ts"
|
|
15
15
|
import type { ProtoPoisoning } from "./proto-guard.ts"
|
|
16
16
|
import { searchOf } from "./query.ts"
|
|
17
|
-
import { responseJsonContentType } from "./respond.ts"
|
|
17
|
+
import { residentText, responseJsonContentType } from "./respond.ts"
|
|
18
18
|
import {
|
|
19
19
|
CONTEXT_REQUEST,
|
|
20
20
|
CONTEXT_SEARCH,
|
|
@@ -378,9 +378,12 @@ export class RequestContext implements RawContext {
|
|
|
378
378
|
}
|
|
379
379
|
}
|
|
380
380
|
// Default to text/plain; if the caller passes their own headers, they own the content-type.
|
|
381
|
-
if (i?.headers !== undefined) return markResidentBody(new Response(body, i))
|
|
381
|
+
if (i?.headers !== undefined) return markResidentBody(residentText(new Response(body, i), body))
|
|
382
382
|
return markResidentBody(
|
|
383
|
-
|
|
383
|
+
residentText(
|
|
384
|
+
new Response(body, { ...i, headers: { "content-type": TEXT_CONTENT_TYPE } }),
|
|
385
|
+
body,
|
|
386
|
+
),
|
|
384
387
|
)
|
|
385
388
|
}
|
|
386
389
|
|
package/src/server/respond.ts
CHANGED
|
@@ -314,6 +314,37 @@ export function writableResponse(response: Response, request?: Request): Respons
|
|
|
314
314
|
return copiedResponse(response, new Response(response.body, response), true)
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
// Body-bearing responses a hook handed over as a request's answer. The Node writer sends a buffered
|
|
318
|
+
// body without reading it, so `bodyUsed` alone does not show that one was already sent.
|
|
319
|
+
const HANDED_OVER = new WeakSet<Response>()
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Whether `response`, handed over by a hook as this request's answer, may be sent: not when its body
|
|
323
|
+
* already answered a request or was handed over for one. A body-bearing response is claimed here.
|
|
324
|
+
*/
|
|
325
|
+
export function claimHandedOver(response: Response): boolean {
|
|
326
|
+
if (responseBodyUsed(response) || HANDED_OVER.has(response)) return false
|
|
327
|
+
if (
|
|
328
|
+
hasResidentBody(response) ||
|
|
329
|
+
WRITABLE_BODY_RESPONSES.has(response) ||
|
|
330
|
+
response.body !== null
|
|
331
|
+
) {
|
|
332
|
+
HANDED_OVER.add(response)
|
|
333
|
+
}
|
|
334
|
+
return true
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* `writableResponse` for a Response a hook hands back in place of the one it was given, with the
|
|
339
|
+
* single-use check a handler's result gets: a reused body is the internal error a reused handler
|
|
340
|
+
* result is, carrying none of its earlier request's headers.
|
|
341
|
+
*/
|
|
342
|
+
export function handedBackResponse(response: Response, request: Request): Response {
|
|
343
|
+
return claimHandedOver(response)
|
|
344
|
+
? writableResponse(response, request)
|
|
345
|
+
: toResponse(SENT_BODY, EMPTY_RESPONSE_CONTROLS, false, undefined, request)
|
|
346
|
+
}
|
|
347
|
+
|
|
317
348
|
/** `copy`, recorded and carrying `original`'s buffered-body marker: the Node writer still sends the
|
|
318
349
|
* serialized bytes directly, and a marked body-less Response keeps the body only the marker holds. */
|
|
319
350
|
function copiedResponse(original: Response, copy: Response, hasBody: boolean): Response {
|
|
@@ -385,6 +416,63 @@ export function taggedResponseOwner(response: Response): object | undefined {
|
|
|
385
416
|
: undefined
|
|
386
417
|
}
|
|
387
418
|
|
|
419
|
+
// The text each c.text() response was built from; null once adopted by the body tier. A hook can
|
|
420
|
+
// substitute its body without consuming the original stream, but that source is still single-use.
|
|
421
|
+
const RESIDENT_TEXTS = new WeakMap<Response, string | null>()
|
|
422
|
+
let residentTextsRecorded = false
|
|
423
|
+
|
|
424
|
+
/** From now on, keep the string of each `c.text()` response for the body tier. */
|
|
425
|
+
export function recordResidentTexts(): void {
|
|
426
|
+
residentTextsRecorded = true
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** `response`, which `c.text()` built from `body`. */
|
|
430
|
+
export function residentText(response: Response, body: string): Response {
|
|
431
|
+
if (residentTextsRecorded) RESIDENT_TEXTS.set(response, body)
|
|
432
|
+
return response
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Whether the actual body, or its body-tier substitute, has already been handed to a request. */
|
|
436
|
+
export function responseBodyUsed(response: Response): boolean {
|
|
437
|
+
return response.bodyUsed || RESIDENT_TEXTS.get(response) === null
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** `response` tagged with the text it was built from, so this app's body-tier hooks get the string. */
|
|
441
|
+
function withResidentText(
|
|
442
|
+
response: Response,
|
|
443
|
+
source: Response,
|
|
444
|
+
owner: object | undefined,
|
|
445
|
+
): Response {
|
|
446
|
+
if (owner === undefined) return response
|
|
447
|
+
const text = RESIDENT_TEXTS.get(source)
|
|
448
|
+
if (text === undefined || text === null) return response
|
|
449
|
+
RESIDENT_TEXTS.set(source, null)
|
|
450
|
+
return taggedResponseBody(response) !== undefined
|
|
451
|
+
? response
|
|
452
|
+
: markTaggedResponse(response, text, owner)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** A buffered page built from the body it reports, tagged for this app's body-tier hooks. */
|
|
456
|
+
function bufferedPageResponse(
|
|
457
|
+
page: {
|
|
458
|
+
readonly status: number
|
|
459
|
+
readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
|
|
460
|
+
readonly body: string
|
|
461
|
+
},
|
|
462
|
+
owner: object,
|
|
463
|
+
): Response {
|
|
464
|
+
const headers = new Headers()
|
|
465
|
+
for (const [name, value] of Object.entries(page.headers ?? {})) {
|
|
466
|
+
if (typeof value === "string") headers.set(name, value)
|
|
467
|
+
else for (const item of value) headers.append(name, item)
|
|
468
|
+
}
|
|
469
|
+
return markTaggedResponse(
|
|
470
|
+
withBody(new Response(page.body, { status: page.status, headers })),
|
|
471
|
+
page.body,
|
|
472
|
+
owner,
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
|
|
388
476
|
export function markTaggedResponse(
|
|
389
477
|
response: Response,
|
|
390
478
|
body: string | Uint8Array,
|
|
@@ -546,20 +634,34 @@ export function toResponse(
|
|
|
546
634
|
request,
|
|
547
635
|
)
|
|
548
636
|
}
|
|
637
|
+
// A buffered page is built from the body it reports, so the bytes its hooks see are the ones sent.
|
|
638
|
+
const owner = typeof tagResponseBody === "object" ? tagResponseBody : undefined
|
|
639
|
+
const page = owner === undefined ? undefined : result.toNodeBody?.()
|
|
640
|
+
const response =
|
|
641
|
+
owner !== undefined &&
|
|
642
|
+
page !== undefined &&
|
|
643
|
+
typeof page.body === "string" &&
|
|
644
|
+
!isBodylessStatus(page.status)
|
|
645
|
+
? bufferedPageResponse({ ...page, body: page.body }, owner)
|
|
646
|
+
: result.toResponse()
|
|
549
647
|
return withStatics(
|
|
550
|
-
applySetToResponse(normalizeBodylessResponse(
|
|
648
|
+
applySetToResponse(normalizeBodylessResponse(response, request), set, request),
|
|
551
649
|
statics,
|
|
552
650
|
request,
|
|
553
651
|
)
|
|
554
652
|
}
|
|
555
653
|
if (result instanceof Response) {
|
|
556
|
-
if (result
|
|
654
|
+
if (responseBodyUsed(result)) {
|
|
557
655
|
return toResponse(SENT_BODY, EMPTY_RESPONSE_CONTROLS, false, statics, request)
|
|
558
656
|
}
|
|
559
|
-
return
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
657
|
+
return withResidentText(
|
|
658
|
+
withStatics(
|
|
659
|
+
applySetToResponse(normalizeBodylessResponse(result, request), set, request),
|
|
660
|
+
statics,
|
|
661
|
+
request,
|
|
662
|
+
),
|
|
663
|
+
result,
|
|
664
|
+
typeof tagResponseBody === "object" ? tagResponseBody : undefined,
|
|
563
665
|
)
|
|
564
666
|
}
|
|
565
667
|
return renderValue(result, set, tagResponseBody, statics, request)
|
|
@@ -31,6 +31,16 @@ export interface PreDecodedBody {
|
|
|
31
31
|
readonly value: unknown
|
|
32
32
|
/** The encoded size, so the route's own body limit still applies to a body decoded before routing. */
|
|
33
33
|
readonly byteLength: number
|
|
34
|
+
/** The bytes as sent and their media type: the stand-in body is not the payload, so an
|
|
35
|
+
* idempotency fingerprint must be taken over these. */
|
|
36
|
+
readonly encoded: Uint8Array
|
|
37
|
+
readonly contentType: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The stash a transport codec left on a request, if one decoded its body. */
|
|
41
|
+
export function preDecodedBody(request: object): PreDecodedBody | undefined {
|
|
42
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: only the codec lane and the idempotency prepare step define this key, each with a PreDecodedBody.
|
|
43
|
+
return (request as { [PRE_DECODED_BODY]?: PreDecodedBody })[PRE_DECODED_BODY]
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
/**
|
|
@@ -181,12 +191,55 @@ export function requestOf(source: RequestSource): Request {
|
|
|
181
191
|
return source.request ?? (source as unknown as Request)
|
|
182
192
|
}
|
|
183
193
|
|
|
194
|
+
// On Deno, reading `headers` builds the request's whole header list: ~4% of a bare route's
|
|
195
|
+
// throughput for one header. A `Deno.serve` request's inner record (Deno's own "request" slot)
|
|
196
|
+
// reads one header off the parsed request: the value `headers.get` would return, except that a name
|
|
197
|
+
// no header can carry reads as absent instead of throwing, as on the Node source. It is used only
|
|
198
|
+
// while the `Headers` are unbuilt (Deno's "headers cache" slot is empty), so the two never disagree;
|
|
199
|
+
// on a closed request both throw Deno's "Request closed". A request Deno did not parse - constructed,
|
|
200
|
+
// cloned, rebuilt - reads `headers`, as before.
|
|
201
|
+
const denoSlots = "Deno" in globalThis ? denoRequestSlots(new Request("http://x/")) : undefined
|
|
202
|
+
|
|
203
|
+
/** @internal Deno's own slots on a `Request`, found by the names Deno gives them. */
|
|
204
|
+
export interface DenoSlots {
|
|
205
|
+
readonly inner: symbol
|
|
206
|
+
readonly headers: symbol
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** @internal */
|
|
210
|
+
export function denoRequestSlots(request: object): DenoSlots | undefined {
|
|
211
|
+
let inner: symbol | undefined
|
|
212
|
+
let headers: symbol | undefined
|
|
213
|
+
for (const key of Object.getOwnPropertySymbols(request)) {
|
|
214
|
+
if (key.description === "request") inner = key
|
|
215
|
+
else if (key.description === "headers cache") headers = key
|
|
216
|
+
}
|
|
217
|
+
return inner === undefined || headers === undefined ? undefined : { inner, headers }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** @internal The header as received, or `undefined` when only `headers` can answer. */
|
|
221
|
+
export function denoWireHeader(
|
|
222
|
+
request: object,
|
|
223
|
+
slots: DenoSlots,
|
|
224
|
+
name: string,
|
|
225
|
+
): string | null | undefined {
|
|
226
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: only the two symbol slots are read, and each value is checked before it is used.
|
|
227
|
+
const slotted = request as Record<symbol, { readonly header?: unknown } | undefined>
|
|
228
|
+
if (slotted[slots.headers] !== undefined) return undefined
|
|
229
|
+
const inner = slotted[slots.inner]
|
|
230
|
+
if (typeof inner?.header !== "function") return undefined
|
|
231
|
+
const value: unknown = inner.header(name)
|
|
232
|
+
return value === null || typeof value === "string" ? value : undefined
|
|
233
|
+
}
|
|
234
|
+
|
|
184
235
|
/** Read one request header. `header()` answers authoritatively when the source implements it - its
|
|
185
236
|
* `null` means ABSENT, not "ask `headers` instead". Falling through on `null` would materialize the
|
|
186
237
|
* lazy sources' full `Headers` object on every absent-header probe (measured at ~4% of request CPU
|
|
187
238
|
* on the Node POST lane, which checks `transfer-encoding` on every request). */
|
|
188
239
|
export function headerOf(source: RequestSource, name: string): string | null {
|
|
189
|
-
|
|
240
|
+
if (source.header !== undefined) return source.header(name)
|
|
241
|
+
const wire = denoSlots === undefined ? undefined : denoWireHeader(source, denoSlots, name)
|
|
242
|
+
return wire !== undefined ? wire : source.headers.get(name)
|
|
190
243
|
}
|
|
191
244
|
|
|
192
245
|
/** Off-edge `waitUntil`: run the background work fire-and-forget, never leaking an unhandled
|
|
@@ -91,9 +91,11 @@ export interface ServerOptions {
|
|
|
91
91
|
readonly trustBodyFraming?: boolean
|
|
92
92
|
/**
|
|
93
93
|
* Max inbound WebSocket message size (bytes). A larger message never reaches the handler and the
|
|
94
|
-
* socket closes with `1009
|
|
95
|
-
*
|
|
96
|
-
*
|
|
94
|
+
* socket closes with `1009`; when Bun's own frame limit refuses it, Bun drops the connection and the
|
|
95
|
+
* client sees `1006`, while the `close` handler still gets `1009`. Default: `maxBodyBytes` (1 MB).
|
|
96
|
+
* Bun and Node stop buffering at the cap, so a huge frame is never held in memory; on Bun a mounted
|
|
97
|
+
* app's smaller cap, and on Deno and Workers every cap, is checked after the runtime has buffered
|
|
98
|
+
* the whole message.
|
|
97
99
|
*/
|
|
98
100
|
readonly wsMaxPayloadBytes?: number
|
|
99
101
|
/** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
|