@nifrajs/core 2.8.2 → 2.9.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/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 +26 -6
- 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 +1163 -174
- 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 +24 -6
- 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 +1618 -237
- package/src/server/static-headers.ts +142 -0
- package/src/server.ts +9 -0
- package/src/transport-codec.ts +55 -10
|
@@ -1,9 +1,323 @@
|
|
|
1
1
|
/** Lazy Node-direct renderer seam. The base server retains the typed method but not its renderer. */
|
|
2
|
+
|
|
3
|
+
import type { Platform } from "./context.ts"
|
|
2
4
|
import type { NodeServeOutcome } from "./node-outcome.ts"
|
|
3
|
-
import type { CtxSet } from "./server.ts"
|
|
5
|
+
import type { CtxSet, MaybePromise } from "./server.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Allocation-light request view used by Node-native header middleware.
|
|
9
|
+
*
|
|
10
|
+
* Identity contract: within one request, the SAME object is passed to every `onNodeRequest` hook
|
|
11
|
+
* and every `onNodeResponse` hook (the native lanes engage together - see the server's gate), so a
|
|
12
|
+
* middleware may use it as a `WeakMap` key to carry per-request state from its request twin to its
|
|
13
|
+
* response twin.
|
|
14
|
+
*/
|
|
15
|
+
export interface NodeRequestContext {
|
|
16
|
+
readonly method: string
|
|
17
|
+
readonly url: string
|
|
18
|
+
readonly header: (name: string) => string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The mutable, case-insensitive header surface a portable {@link ResponseHeadersHook} writes
|
|
23
|
+
* through. Deliberately the subset of the Web `Headers` interface every runtime can satisfy
|
|
24
|
+
* natively: on the Web paths the hook receives the response's own `Headers` object directly (which
|
|
25
|
+
* structurally implements this), and on the Node direct-writer path it receives a thin view over
|
|
26
|
+
* the outcome's plain header record - so ONE hook implementation is fast everywhere.
|
|
27
|
+
*/
|
|
28
|
+
export interface ResponseHeadersView {
|
|
29
|
+
get(name: string): string | null
|
|
30
|
+
has(name: string): boolean
|
|
31
|
+
set(name: string, value: string): void
|
|
32
|
+
append(name: string, value: string): void
|
|
33
|
+
delete(name: string): void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A portable header-only response hook - the recommended shape for response middleware that only
|
|
38
|
+
* reads or writes headers (security headers, CORS reflection, cache directives, negotiation). It
|
|
39
|
+
* runs on every runtime from one implementation: the server adapts it into the Web `onResponse`
|
|
40
|
+
* walk AND the Node-native response lane, so registering one never forces the Node adapter off its
|
|
41
|
+
* direct socket writer the way a full `onResponse(res: Response)` hook does. It cannot replace the
|
|
42
|
+
* response or touch the body - middleware needing that keeps the full `onResponse` contract and
|
|
43
|
+
* its cost.
|
|
44
|
+
*/
|
|
45
|
+
export type ResponseHeadersHook = (
|
|
46
|
+
headers: ResponseHeadersView,
|
|
47
|
+
req: NodeRequestContext,
|
|
48
|
+
status: number,
|
|
49
|
+
) => MaybePromise<void>
|
|
50
|
+
|
|
51
|
+
/** Native equivalent of a paired `onRequest` hook. It may short-circuit, but cannot rewrite a request. */
|
|
52
|
+
export type NodeRequestHook = (
|
|
53
|
+
request: NodeRequestContext,
|
|
54
|
+
platform?: Platform,
|
|
55
|
+
) => MaybePromise<Response | undefined>
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Response view used by Node-direct middleware. Header hooks mutate `headers`; a BODY hook
|
|
59
|
+
* (adapted from the portable `onResponseBody`) may replace `body` - the already-serialized bytes
|
|
60
|
+
* the direct writer is about to send - and, through its structured replacement, the status
|
|
61
|
+
* (an ETag 304 being the canonical case).
|
|
62
|
+
*/
|
|
63
|
+
export interface NodeResponseContext {
|
|
64
|
+
/** Mutable ONLY through a body hook's replacement object (e.g. an ETag 304); header hooks read. */
|
|
65
|
+
status: number
|
|
66
|
+
headers: Record<string, string | readonly string[]> | undefined
|
|
67
|
+
readonly cookies: readonly string[] | undefined
|
|
68
|
+
/** The framework-serialized body bytes (`null` for a bodiless render). Replaceable by body hooks. */
|
|
69
|
+
body: string | Uint8Array | null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A body hook's structured replacement: new bytes (or `null` to drop the body) and/or a status. */
|
|
73
|
+
export interface ResponseBodyReplacement {
|
|
74
|
+
readonly body?: string | Uint8Array | null
|
|
75
|
+
readonly status?: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A portable post-serialization body hook - the Fastify-`onSend`-shaped tier. The hook receives
|
|
80
|
+
* the FINAL framework-serialized bytes plus the header view, and may return replacement bytes
|
|
81
|
+
* (`undefined` keeps the body unchanged). It runs at the framework's cheapest point on every
|
|
82
|
+
* runtime: the bytes are already resident before any Web `Response` exists, so no body stream is
|
|
83
|
+
* ever drained. A handler-returned raw `Response` (a proxied fetch, SSE, streamed SSR) is SKIPPED
|
|
84
|
+
* by definition - transforming those is exactly what the full `onResponse` contract is for.
|
|
85
|
+
*/
|
|
86
|
+
export type ResponseBodyHook = (
|
|
87
|
+
body: string | Uint8Array,
|
|
88
|
+
headers: ResponseHeadersView,
|
|
89
|
+
req: NodeRequestContext,
|
|
90
|
+
status: number,
|
|
91
|
+
) => MaybePromise<string | Uint8Array | ResponseBodyReplacement | undefined>
|
|
92
|
+
|
|
93
|
+
/** Native equivalent of a paired `onResponse` hook. It must preserve the Web hook's header semantics. */
|
|
94
|
+
export type NodeResponseHook = (
|
|
95
|
+
response: NodeResponseContext,
|
|
96
|
+
req: NodeRequestContext,
|
|
97
|
+
) => MaybePromise<void>
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A {@link ResponseHeadersView} over a Node outcome's plain header record - the direct-writer
|
|
101
|
+
* counterpart of handing a Web hook the response's `Headers`. Mutations write lowercase names (the
|
|
102
|
+
* wire form every runtime emits) and reads are case-insensitive across whatever casing earlier
|
|
103
|
+
* writers used; repeated values join with `", "` on read, matching `Headers.get`. A class with
|
|
104
|
+
* prototype methods (not per-call closures), memoized per context, so a request running several
|
|
105
|
+
* portable hooks allocates ONE small view total.
|
|
106
|
+
*
|
|
107
|
+
* Name resolution is prepared ONCE, the first time this view touches the record, rather than per
|
|
108
|
+
* operation. Resolving each name by scanning the record - the straightforward way to stay
|
|
109
|
+
* case-insensitive - allocates an `Object.keys` array and lowercases every stored key on every
|
|
110
|
+
* get/set, and that is paid per header, so a middleware writing a handful of them (security
|
|
111
|
+
* headers, CORS) paid it several times per request: profiling a realistic Node route put that scan
|
|
112
|
+
* at roughly a fifth of the framework's own CPU time. Instead one pass records an alias only for
|
|
113
|
+
* keys whose casing differs from their lowercase form - none, for the records the framework itself
|
|
114
|
+
* builds - after which every operation is a direct property access and the common all-lowercase
|
|
115
|
+
* record allocates no index at all. Stored casing is left exactly as the writer chose it.
|
|
116
|
+
*/
|
|
117
|
+
class RecordHeadersView implements ResponseHeadersView {
|
|
118
|
+
readonly #target: NodeResponseContext
|
|
119
|
+
#prepared = false
|
|
120
|
+
/** lowercase name -> the differently-cased key actually stored. Absent when none differ. */
|
|
121
|
+
#alias: Map<string, string> | undefined
|
|
122
|
+
|
|
123
|
+
constructor(target: NodeResponseContext) {
|
|
124
|
+
this.#target = target
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#prepare(record: Record<string, string | readonly string[]>): void {
|
|
128
|
+
this.#prepared = true
|
|
129
|
+
// `Object.keys`, not `for...in`: the record is a `{}`-prototype literal on the render paths, and
|
|
130
|
+
// a for-in would let an enumerable `Object.prototype` property become a header on the wire.
|
|
131
|
+
const keys = Object.keys(record)
|
|
132
|
+
for (let i = 0; i < keys.length; i++) {
|
|
133
|
+
const key = keys[i] as string
|
|
134
|
+
// `key.toLowerCase() !== key` answers this too, but pays a runtime call plus a string compare
|
|
135
|
+
// for EVERY key on EVERY request, and the answer is almost always "already lowercase" - the
|
|
136
|
+
// framework, `c.set`, and middleware twins all write the wire spelling. An inline ASCII scan
|
|
137
|
+
// answers the common case without leaving the loop, and only a genuinely mixed-case key (a
|
|
138
|
+
// hand-written `X-Foo`) pays for the lowercase copy and the alias entry.
|
|
139
|
+
if (!RecordHeadersView.#hasUpperAscii(key)) continue
|
|
140
|
+
if (this.#alias === undefined) this.#alias = new Map()
|
|
141
|
+
this.#alias.set(key.toLowerCase(), key)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** True when the name contains an ASCII `A`-`Z`, i.e. is not already the wire spelling. */
|
|
146
|
+
static #hasUpperAscii(name: string): boolean {
|
|
147
|
+
for (let i = 0; i < name.length; i++) {
|
|
148
|
+
const code = name.charCodeAt(i)
|
|
149
|
+
if (code >= 65 && code <= 90) return true
|
|
150
|
+
}
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The wire spelling of a header name, without paying `toLowerCase` when it already is one. */
|
|
155
|
+
static #lower(name: string): string {
|
|
156
|
+
return RecordHeadersView.#hasUpperAscii(name) ? name.toLowerCase() : name
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The stored key for an already-lowercased name. Resolution is the prepared index ONLY - a
|
|
160
|
+
* lowercase own-property check, then the alias map - never a per-operation key scan: a rescan on
|
|
161
|
+
* every miss (and setting a NEW header is always a miss) walked and lowercased the whole record
|
|
162
|
+
* per write, which measured as the single largest cost of a portable hook walk on JSC. The alias
|
|
163
|
+
* index is complete for the record as first touched and for every write made through this view;
|
|
164
|
+
* a raw record write from a custom native twin uses lowercase names (the wire form the record
|
|
165
|
+
* documents), so it stays visible without a scan. */
|
|
166
|
+
#actual(lower: string): string {
|
|
167
|
+
const record = this.#target.headers
|
|
168
|
+
if (record !== undefined && Object.hasOwn(record, lower)) return lower
|
|
169
|
+
const known = this.#alias?.get(lower)
|
|
170
|
+
if (known !== undefined && record !== undefined && Object.hasOwn(record, known)) return known
|
|
171
|
+
return lower
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The backing record if the outcome has one, prepared on first touch; `undefined` otherwise. */
|
|
175
|
+
#readable(): Record<string, string | readonly string[]> | undefined {
|
|
176
|
+
const existing = this.#target.headers
|
|
177
|
+
if (existing === undefined) return undefined
|
|
178
|
+
if (!this.#prepared) this.#prepare(existing)
|
|
179
|
+
return existing
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** As {@link #readable}, but creates the record when the outcome has none. A literal record from
|
|
183
|
+
* the render path is used AS-IS - values are strings/arrays, and assigning those through the
|
|
184
|
+
* inherited `__proto__` setter is a spec-level no-op, so the record cannot be polluted through
|
|
185
|
+
* this view; the one name that setter would swallow is stored via `defineProperty` in
|
|
186
|
+
* {@link set}/{@link append} instead. Copying to a null-prototype object here would demote every
|
|
187
|
+
* hook-carrying request's record to V8's dictionary mode (~2% of route throughput). */
|
|
188
|
+
#writable(): Record<string, string | readonly string[]> {
|
|
189
|
+
const existing = this.#target.headers
|
|
190
|
+
if (existing === undefined) {
|
|
191
|
+
const fresh: Record<string, string | readonly string[]> = {}
|
|
192
|
+
this.#target.headers = fresh
|
|
193
|
+
this.#prepared = true
|
|
194
|
+
return fresh
|
|
195
|
+
}
|
|
196
|
+
if (!this.#prepared) this.#prepare(existing)
|
|
197
|
+
return existing
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Store under an attacker-influenceable name. `__proto__` (any casing is already lowercased)
|
|
201
|
+
* must not go through plain assignment - the inherited setter would silently swallow it - so it
|
|
202
|
+
* is defined as an own data property; every other name takes the fast plain store. */
|
|
203
|
+
static #store(
|
|
204
|
+
record: Record<string, string | readonly string[]>,
|
|
205
|
+
lower: string,
|
|
206
|
+
value: string | readonly string[],
|
|
207
|
+
): void {
|
|
208
|
+
if (lower === "__proto__") {
|
|
209
|
+
Object.defineProperty(record, lower, {
|
|
210
|
+
value,
|
|
211
|
+
writable: true,
|
|
212
|
+
enumerable: true,
|
|
213
|
+
configurable: true,
|
|
214
|
+
})
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
record[lower] = value
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
get(name: string): string | null {
|
|
221
|
+
const headers = this.#readable()
|
|
222
|
+
if (headers === undefined) return null
|
|
223
|
+
const value = headers[this.#actual(RecordHeadersView.#lower(name))]
|
|
224
|
+
if (value === undefined) return null
|
|
225
|
+
return typeof value === "string" ? value : (value.join(", ") ?? null)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
has(name: string): boolean {
|
|
229
|
+
const headers = this.#readable()
|
|
230
|
+
return (
|
|
231
|
+
headers !== undefined && headers[this.#actual(RecordHeadersView.#lower(name))] !== undefined
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
set(name: string, value: string): void {
|
|
236
|
+
const headers = this.#writable()
|
|
237
|
+
const lower = RecordHeadersView.#lower(name)
|
|
238
|
+
// The common response record is already lowercase (framework middleware and c.set use the wire
|
|
239
|
+
// spelling). A set cannot need to replace a differently-cased key in that shape, so skip the
|
|
240
|
+
// own-property/alias lookup that reads and deletes pay for. Mixed-case records were indexed by
|
|
241
|
+
// #prepare and retain the full case-insensitive replacement semantics below.
|
|
242
|
+
if (this.#alias === undefined) {
|
|
243
|
+
RecordHeadersView.#store(headers, lower, value)
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
const actual = this.#actual(lower)
|
|
247
|
+
if (actual !== lower) {
|
|
248
|
+
delete headers[actual]
|
|
249
|
+
this.#alias?.delete(lower)
|
|
250
|
+
}
|
|
251
|
+
RecordHeadersView.#store(headers, lower, value)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
append(name: string, value: string): void {
|
|
255
|
+
const headers = this.#writable()
|
|
256
|
+
const lower = RecordHeadersView.#lower(name)
|
|
257
|
+
if (this.#alias === undefined) {
|
|
258
|
+
const current = Object.hasOwn(headers, lower) ? headers[lower] : undefined
|
|
259
|
+
if (current === undefined) {
|
|
260
|
+
RecordHeadersView.#store(headers, lower, value)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
RecordHeadersView.#store(
|
|
264
|
+
headers,
|
|
265
|
+
lower,
|
|
266
|
+
typeof current === "string" ? [current, value] : [...current, value],
|
|
267
|
+
)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
const actual = this.#actual(lower)
|
|
271
|
+
const current = Object.hasOwn(headers, actual) ? headers[actual] : undefined
|
|
272
|
+
if (current === undefined) {
|
|
273
|
+
RecordHeadersView.#store(headers, lower, value)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
if (actual !== lower) {
|
|
277
|
+
delete headers[actual]
|
|
278
|
+
this.#alias?.delete(lower)
|
|
279
|
+
}
|
|
280
|
+
RecordHeadersView.#store(
|
|
281
|
+
headers,
|
|
282
|
+
lower,
|
|
283
|
+
typeof current === "string" ? [current, value] : [...current, value],
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
delete(name: string): void {
|
|
288
|
+
const headers = this.#readable()
|
|
289
|
+
if (headers === undefined) return
|
|
290
|
+
const lower = RecordHeadersView.#lower(name)
|
|
291
|
+
if (this.#alias === undefined) {
|
|
292
|
+
delete headers[lower]
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
const actual = this.#actual(lower)
|
|
296
|
+
delete headers[actual]
|
|
297
|
+
if (actual !== lower) this.#alias?.delete(lower)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const RECORD_VIEW = Symbol("nifra.record-headers-view")
|
|
302
|
+
|
|
303
|
+
export function recordHeadersView(target: NodeResponseContext): ResponseHeadersView {
|
|
304
|
+
const holder = target as NodeResponseContext & { [RECORD_VIEW]?: ResponseHeadersView }
|
|
305
|
+
let view = holder[RECORD_VIEW]
|
|
306
|
+
if (view === undefined) {
|
|
307
|
+
view = new RecordHeadersView(target)
|
|
308
|
+
holder[RECORD_VIEW] = view
|
|
309
|
+
}
|
|
310
|
+
return view
|
|
311
|
+
}
|
|
4
312
|
|
|
5
313
|
export interface NodeOutcomeRuntime {
|
|
6
314
|
toOutcome(result: unknown, set: CtxSet): NodeServeOutcome
|
|
315
|
+
/** Materialize a buffered outcome for a Web response hook without losing its direct-write marker. */
|
|
316
|
+
toResponse(outcome: NodeServeOutcome): Response
|
|
7
317
|
fromResponse(response: Response): NodeServeOutcome
|
|
8
318
|
timeout(): NodeServeOutcome
|
|
319
|
+
/** The `Content-Type` this runtime's json render writes implicitly. The native hook walk
|
|
320
|
+
* materializes it into the hook-visible header record so a body hook's content-type checks see
|
|
321
|
+
* what will actually ship (the writer's own value - the wire is unchanged). */
|
|
322
|
+
readonly jsonContentType?: string
|
|
9
323
|
}
|
|
@@ -3,9 +3,25 @@
|
|
|
3
3
|
* conversion. Mirrors `respond.ts` but skips the undici `Response` build where the adapter can write a
|
|
4
4
|
* plain-data render straight to the socket. Imports only runtime-core + respond + the spine types.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
appendCookiesToResponse,
|
|
8
|
+
applyStaticResponseHeaders,
|
|
9
|
+
markTaggedResponse,
|
|
10
|
+
normalizeBodylessResponse,
|
|
11
|
+
rememberMutableHeaders,
|
|
12
|
+
taggedResponseBody,
|
|
13
|
+
} from "./respond.ts"
|
|
7
14
|
import { type HandlerResult, isResponseResult } from "./runtime-core.ts"
|
|
8
15
|
import type { CtxSet } from "./server.ts"
|
|
16
|
+
import {
|
|
17
|
+
mergeStaticHeaderRecord,
|
|
18
|
+
type StaticResponseHeaders,
|
|
19
|
+
staticHeaderRecordCopy,
|
|
20
|
+
} from "./static-headers.ts"
|
|
21
|
+
|
|
22
|
+
function isBodylessStatus(status: number): boolean {
|
|
23
|
+
return status === 204 || status === 205 || status === 304
|
|
24
|
+
}
|
|
9
25
|
|
|
10
26
|
/**
|
|
11
27
|
* What {@link Server.resolveNode} returns: either a plain-data render the `@nifrajs/node` adapter writes
|
|
@@ -19,8 +35,8 @@ export type NodeServeOutcome =
|
|
|
19
35
|
| {
|
|
20
36
|
readonly kind: "json"
|
|
21
37
|
readonly status: number
|
|
22
|
-
/**
|
|
23
|
-
readonly headers: Readonly<Record<string, string>> | undefined
|
|
38
|
+
/** Header record after native hooks; repeated values are retained as arrays. */
|
|
39
|
+
readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
|
|
24
40
|
/** Queued `Set-Cookie` lines, or `undefined`; the adapter emits one header line each. */
|
|
25
41
|
readonly cookies: readonly string[] | undefined
|
|
26
42
|
/** The JSON body already stringified, or `null` for an empty (204) response. */
|
|
@@ -48,27 +64,98 @@ export function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutc
|
|
|
48
64
|
kind: "body",
|
|
49
65
|
status: body.status,
|
|
50
66
|
headers: appendCookiesToNodeHeaders(body.headers, set._cookies),
|
|
51
|
-
body: body.body,
|
|
67
|
+
body: isBodylessStatus(body.status) ? new Uint8Array(0) : body.body,
|
|
52
68
|
}
|
|
53
69
|
}
|
|
54
|
-
return nodeOutcomeFromResponse(
|
|
70
|
+
return nodeOutcomeFromResponse(
|
|
71
|
+
appendCookiesToResponse(normalizeBodylessResponse(result.toResponse()), set),
|
|
72
|
+
)
|
|
55
73
|
}
|
|
56
74
|
if (result instanceof Response) {
|
|
57
|
-
return nodeOutcomeFromResponse(appendCookiesToResponse(result, set))
|
|
75
|
+
return nodeOutcomeFromResponse(appendCookiesToResponse(normalizeBodylessResponse(result), set))
|
|
58
76
|
}
|
|
59
77
|
const status = set.status ?? (result === undefined ? 204 : 200)
|
|
78
|
+
const body = result === undefined || isBodylessStatus(status) ? null : JSON.stringify(result)
|
|
60
79
|
return {
|
|
61
80
|
kind: "json",
|
|
62
81
|
status,
|
|
63
82
|
headers: set._headers,
|
|
64
83
|
cookies: set._cookies,
|
|
65
|
-
body
|
|
84
|
+
body,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Materialize a buffered node outcome only when a Web `onResponse` hook needs to see a real
|
|
90
|
+
* `Response`. The marker lets an in-place hook (`response.headers.set(...); return response`) go back
|
|
91
|
+
* to the direct socket writer without draining the body through a Web stream. A hook that replaces,
|
|
92
|
+
* consumes, or otherwise changes the response naturally loses the marker and stays on the portable
|
|
93
|
+
* response path.
|
|
94
|
+
*/
|
|
95
|
+
export function nodeOutcomeToResponse(outcome: NodeServeOutcome): Response {
|
|
96
|
+
if (outcome.kind === "response") return outcome.response
|
|
97
|
+
// A prebuilt `Headers` on purpose: undici's Response constructor takes a fast clone path for a
|
|
98
|
+
// `Headers` instance, which measured cheaper than handing it a pairs list to fill (pairs pay a
|
|
99
|
+
// webidl sequence conversion per entry).
|
|
100
|
+
const headers = new Headers()
|
|
101
|
+
if (outcome.headers !== undefined) {
|
|
102
|
+
for (const [name, value] of Object.entries(outcome.headers)) {
|
|
103
|
+
if (typeof value !== "string") {
|
|
104
|
+
for (const item of value) headers.append(name, item)
|
|
105
|
+
} else {
|
|
106
|
+
headers.set(name, value as string)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
66
109
|
}
|
|
110
|
+
if (outcome.kind === "json") {
|
|
111
|
+
if (outcome.cookies !== undefined) {
|
|
112
|
+
for (const cookie of outcome.cookies) headers.append("set-cookie", cookie)
|
|
113
|
+
}
|
|
114
|
+
if (outcome.body !== null && headers.get("content-type") === null) {
|
|
115
|
+
headers.set("content-type", "application/json;charset=utf-8")
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (isBodylessStatus(outcome.status)) headers.delete("content-length")
|
|
119
|
+
const body = isBodylessStatus(outcome.status) ? null : outcome.body
|
|
120
|
+
// `Uint8Array<ArrayBufferLike>` vs the lib's body-init generic - runtime-accepted everywhere,
|
|
121
|
+
// only the type narrows wrong under the DOM-free lib set (same idiom as the Headers cast in
|
|
122
|
+
// transport-codec.ts).
|
|
123
|
+
const response = new Response(body as ConstructorParameters<typeof Response>[0], {
|
|
124
|
+
status: outcome.status,
|
|
125
|
+
headers,
|
|
126
|
+
})
|
|
127
|
+
if (body !== null) markTaggedResponse(response, body)
|
|
128
|
+
rememberMutableHeaders(response.headers)
|
|
129
|
+
return response
|
|
67
130
|
}
|
|
68
131
|
|
|
69
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Fold declared static headers into a resolved outcome, ONCE, before any native response hook runs -
|
|
134
|
+
* so a header or body twin sees the declared values through its view exactly as it would see values
|
|
135
|
+
* a hook had written, and so the no-hook direct-writer path (which never calls the finish step) still
|
|
136
|
+
* ships them.
|
|
137
|
+
*
|
|
138
|
+
* The record handed over is always a fresh copy: the Node writers mutate the outcome's record in
|
|
139
|
+
* place (content-type, content-length, cookies), and the static record is shared by every request.
|
|
140
|
+
* Its names are already lowercase, so the writer's all-lowercase fast path still holds.
|
|
141
|
+
*/
|
|
142
|
+
export function withStaticNodeHeaders(
|
|
143
|
+
outcome: NodeServeOutcome,
|
|
144
|
+
statics: StaticResponseHeaders,
|
|
145
|
+
): NodeServeOutcome {
|
|
146
|
+
if (outcome.kind === "response") {
|
|
147
|
+
return { kind: "response", response: applyStaticResponseHeaders(outcome.response, statics) }
|
|
148
|
+
}
|
|
149
|
+
const own = outcome.headers
|
|
150
|
+
const headers =
|
|
151
|
+
own === undefined
|
|
152
|
+
? staticHeaderRecordCopy(statics)
|
|
153
|
+
: mergeStaticHeaderRecord(statics.record, own)
|
|
154
|
+
return { ...outcome, headers }
|
|
155
|
+
}
|
|
70
156
|
|
|
71
157
|
export function nodeOutcomeFromResponse(response: Response): NodeServeOutcome {
|
|
158
|
+
response = normalizeBodylessResponse(response)
|
|
72
159
|
const body = nodeResponseBody(response)
|
|
73
160
|
return body === undefined
|
|
74
161
|
? { kind: "response", response }
|
|
@@ -77,8 +164,7 @@ export function nodeOutcomeFromResponse(response: Response): NodeServeOutcome {
|
|
|
77
164
|
|
|
78
165
|
function nodeResponseBody(response: Response): string | Uint8Array | undefined {
|
|
79
166
|
if (response.bodyUsed) return undefined
|
|
80
|
-
|
|
81
|
-
return typeof body === "string" || body instanceof Uint8Array ? body : undefined
|
|
167
|
+
return taggedResponseBody(response)
|
|
82
168
|
}
|
|
83
169
|
|
|
84
170
|
function responseHeadersForNode(
|
|
@@ -86,12 +172,12 @@ function responseHeadersForNode(
|
|
|
86
172
|
): Readonly<Record<string, string | readonly string[]>> | undefined {
|
|
87
173
|
let headers: Record<string, string | readonly string[]> | undefined
|
|
88
174
|
response.headers.forEach((value, key) => {
|
|
89
|
-
headers ??=
|
|
175
|
+
headers ??= Object.create(null) as Record<string, string | readonly string[]>
|
|
90
176
|
headers[key] = value
|
|
91
177
|
})
|
|
92
178
|
const setCookies = response.headers.getSetCookie?.()
|
|
93
179
|
if (setCookies !== undefined && setCookies.length > 0) {
|
|
94
|
-
headers ??=
|
|
180
|
+
headers ??= Object.create(null) as Record<string, string | readonly string[]>
|
|
95
181
|
headers["set-cookie"] = setCookies
|
|
96
182
|
}
|
|
97
183
|
return headers
|
|
@@ -102,8 +188,8 @@ function appendCookiesToNodeHeaders(
|
|
|
102
188
|
cookies: readonly string[] | undefined,
|
|
103
189
|
): Readonly<Record<string, string | readonly string[]>> | undefined {
|
|
104
190
|
if (cookies === undefined || cookies.length === 0) return headers
|
|
105
|
-
const out
|
|
106
|
-
|
|
191
|
+
const out = Object.create(null) as Record<string, string | readonly string[]>
|
|
192
|
+
if (headers !== undefined) Object.assign(out, headers)
|
|
107
193
|
const existing = out["set-cookie"]
|
|
108
194
|
const setCookies =
|
|
109
195
|
existing === undefined ? [] : typeof existing === "string" ? [existing] : [...existing]
|
package/src/server/query.ts
CHANGED
|
@@ -22,11 +22,36 @@ export function searchOf(url: string): string {
|
|
|
22
22
|
* (an explicit 400 beats silently picking one). */
|
|
23
23
|
export type QueryValue = string | string[]
|
|
24
24
|
|
|
25
|
+
// V8 stores a FRESHLY-SLICED string key on a dictionary-mode (null-prototype) record ~13x slower
|
|
26
|
+
// than an already-internalized one - the store internalizes the fresh string every time (measured
|
|
27
|
+
// ~300ns vs ~22ns for a literal key; it was the single largest slice of query parsing on Node).
|
|
28
|
+
// JSC has no such cliff (~2.5ns either way), so the whole scheme is bypassed there. Interning
|
|
29
|
+
// repeated keys through a small bounded map hands every later request the SAME first-seen (and by
|
|
30
|
+
// then internalized) string, so the store takes the fast path. Query/cookie key sets in a real app
|
|
31
|
+
// are tiny and stable; a hostile high-cardinality caller stops being cached at the cap and an
|
|
32
|
+
// oversized key never enters, so both degrade to exactly today's uncached behavior - the returned
|
|
33
|
+
// key is always value-equal to the input, cached or not.
|
|
34
|
+
const FRESH_KEY_STORE_IS_CHEAP = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
|
|
35
|
+
const PARSE_KEY_INTERN = new Map<string, string>()
|
|
36
|
+
const PARSE_KEY_INTERN_CAP = 2048
|
|
37
|
+
const PARSE_KEY_MAX_LENGTH = 64
|
|
38
|
+
|
|
39
|
+
/** Return a value-equal, likely-internalized copy of a parsed record key. See the note above. */
|
|
40
|
+
export function internParseKey(key: string): string {
|
|
41
|
+
if (FRESH_KEY_STORE_IS_CHEAP) return key
|
|
42
|
+
const hit = PARSE_KEY_INTERN.get(key)
|
|
43
|
+
if (hit !== undefined) return hit
|
|
44
|
+
if (key.length > PARSE_KEY_MAX_LENGTH || PARSE_KEY_INTERN.size >= PARSE_KEY_INTERN_CAP) return key
|
|
45
|
+
PARSE_KEY_INTERN.set(key, key)
|
|
46
|
+
return key
|
|
47
|
+
}
|
|
48
|
+
|
|
25
49
|
/** Accumulate into a NULL-PROTOTYPE record (every call site below creates one): with no inherited
|
|
26
50
|
* `constructor`/`toString`/`__proto__` accessors, a hostile key is just an own data key - the
|
|
27
51
|
* promotion logic can't collide with `Object.prototype` members, and `__proto__` needs no special
|
|
28
52
|
* case (direct assignment on a null-proto object creates an own property). */
|
|
29
|
-
function setQueryValue(out: Record<string, QueryValue>,
|
|
53
|
+
function setQueryValue(out: Record<string, QueryValue>, rawKey: string, value: string): void {
|
|
54
|
+
const key = internParseKey(rawKey)
|
|
30
55
|
const existing = out[key]
|
|
31
56
|
if (existing === undefined) {
|
|
32
57
|
out[key] = value
|
|
@@ -32,6 +32,13 @@ class LazyResponseControls implements CtxSet {
|
|
|
32
32
|
_cookies?: string[]
|
|
33
33
|
|
|
34
34
|
get headers(): Record<string, string> {
|
|
35
|
+
// A plain literal record on purpose: values here are strings, and assigning a STRING through
|
|
36
|
+
// the inherited `__proto__` setter is a silent no-op by spec - it cannot mutate the record's
|
|
37
|
+
// prototype - so a literal object is not pollutable through header writes. The trade is that a
|
|
38
|
+
// header literally named "__proto__" is dropped rather than stored (the sinks that accept
|
|
39
|
+
// attacker-influenced names guard that name explicitly); in exchange the record stays in V8's
|
|
40
|
+
// fast property mode, which a null-prototype object never enters - measured at ~2% of
|
|
41
|
+
// throughput on a realistic middleware route on BOTH V8 and JSC.
|
|
35
42
|
this._headers ??= {}
|
|
36
43
|
return this._headers
|
|
37
44
|
}
|
|
@@ -114,12 +121,23 @@ export class RequestContext implements RawContext {
|
|
|
114
121
|
this.maxBodyBytes = maxBodyBytes as number
|
|
115
122
|
}
|
|
116
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The no-timeout context: `c.signal`/`c.budget` fall back to the never-abort signal and the
|
|
126
|
+
* unbounded budget, which is exactly what the request would have been handed, so the two slots
|
|
127
|
+
* are left unwritten instead of initialized. `search` carries the router's already-split query
|
|
128
|
+
* string when it has one; `undefined` leaves `c.query` to re-scan the URL lazily.
|
|
129
|
+
*/
|
|
117
130
|
static native(
|
|
118
131
|
source: RequestSource,
|
|
119
132
|
params: Record<string, string>,
|
|
133
|
+
search: string | undefined,
|
|
120
134
|
maxBodyBytes: number,
|
|
135
|
+
platform?: Platform,
|
|
121
136
|
): RequestContext {
|
|
122
|
-
|
|
137
|
+
const context = new RequestContext(source, params, maxBodyBytes)
|
|
138
|
+
if (search !== undefined) context.searchValue = search
|
|
139
|
+
if (platform !== undefined) context.platformValue = platform
|
|
140
|
+
return context
|
|
123
141
|
}
|
|
124
142
|
|
|
125
143
|
[CONTEXT_SET](): CtxSet | undefined {
|
|
@@ -166,6 +184,10 @@ export class RequestContext implements RawContext {
|
|
|
166
184
|
return requestOf(this.source)
|
|
167
185
|
}
|
|
168
186
|
|
|
187
|
+
header(name: string): string | null {
|
|
188
|
+
return headerOf(this.source, name)
|
|
189
|
+
}
|
|
190
|
+
|
|
169
191
|
json(body: unknown, init?: ResponseInit | number): Response {
|
|
170
192
|
return Response.json(body, statusInit(init))
|
|
171
193
|
}
|