@nifrajs/core 4.0.0 → 5.0.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/README.md +6 -2
- package/dist/assurance.d.ts +71 -5
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +90 -25
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +6 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +52 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/durable-effect.js +1 -1
- package/dist/durable-effect.js.map +1 -1
- package/dist/durable-saga.d.ts +6 -0
- package/dist/durable-saga.d.ts.map +1 -1
- package/dist/durable-saga.js +83 -10
- package/dist/durable-saga.js.map +1 -1
- package/dist/edge-kit.d.ts +7 -2
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +7 -2
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +16 -5
- package/dist/effect-scope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +2 -0
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +9 -1
- package/dist/evidence.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/durable-shared.d.ts +1 -2
- package/dist/internal/durable-shared.d.ts.map +1 -1
- package/dist/internal/durable-shared.js +25 -44
- package/dist/internal/durable-shared.js.map +1 -1
- package/dist/internal/durable-types.d.ts +3 -0
- package/dist/internal/durable-types.d.ts.map +1 -1
- package/dist/internal/hook-origins.d.ts +67 -0
- package/dist/internal/hook-origins.d.ts.map +1 -0
- package/dist/internal/hook-origins.js +86 -0
- package/dist/internal/hook-origins.js.map +1 -0
- package/dist/internal/route-assurance.d.ts +6 -3
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +29 -14
- package/dist/internal/route-assurance.js.map +1 -1
- package/dist/internal/route-program.d.ts +1 -1
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +59 -16
- package/dist/internal/route-program.js.map +1 -1
- package/dist/reconciliation-worker.d.ts.map +1 -1
- package/dist/reconciliation-worker.js +12 -2
- package/dist/reconciliation-worker.js.map +1 -1
- package/dist/reflection.d.ts +53 -2
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +352 -5
- package/dist/reflection.js.map +1 -1
- package/dist/router/pattern.d.ts +1 -1
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +6 -3
- package/dist/router/pattern.js.map +1 -1
- package/dist/security/baseline.d.ts +3 -0
- package/dist/security/baseline.d.ts.map +1 -1
- package/dist/security/baseline.js +9 -5
- package/dist/security/baseline.js.map +1 -1
- package/dist/server/auth.d.ts +32 -3
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +23 -0
- package/dist/server/auth.js.map +1 -1
- package/dist/server/body.d.ts +7 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +34 -1
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +3 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +2 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -3
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +38 -17
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +2 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +2 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/logger.js +3 -1
- package/dist/server/logger.js.map +1 -1
- package/dist/server/methods.d.ts +1 -0
- package/dist/server/methods.d.ts.map +1 -1
- package/dist/server/methods.js +1 -0
- package/dist/server/methods.js.map +1 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +29 -11
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/platform-adapters.d.ts.map +1 -1
- package/dist/server/platform-adapters.js +3 -2
- package/dist/server/platform-adapters.js.map +1 -1
- package/dist/server/request-context.d.ts +2 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +4 -1
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +44 -11
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +192 -79
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -41
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +2 -0
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +29 -5
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +44 -31
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +444 -321
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +11 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -3
- package/dist/server/sse.js.map +1 -1
- package/dist/server/transport-lane.d.ts +7 -1
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/webhook.d.ts +5 -2
- package/dist/server/webhook.d.ts.map +1 -1
- package/dist/server/webhook.js +17 -1
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +22 -14
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +21 -0
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-hook.d.ts +7 -1
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +3 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts +6 -0
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +20 -1
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +71 -40
- package/dist/tool-contract.js.map +1 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +3 -1
- package/dist/ws.js.map +1 -1
- package/package.json +1 -1
- package/src/assurance.ts +180 -31
- package/src/capabilities.ts +59 -0
- package/src/durable-effect.ts +1 -1
- package/src/durable-saga.ts +94 -10
- package/src/edge-kit.ts +7 -2
- package/src/effect-scope.ts +15 -5
- package/src/errors.ts +1 -0
- package/src/evidence.ts +12 -3
- package/src/index.ts +6 -1
- package/src/internal/durable-shared.ts +25 -37
- package/src/internal/durable-types.ts +3 -0
- package/src/internal/hook-origins.ts +153 -0
- package/src/internal/route-assurance.ts +32 -18
- package/src/internal/route-program.ts +98 -44
- package/src/reconciliation-worker.ts +10 -2
- package/src/reflection.ts +455 -4
- package/src/router/pattern.ts +6 -3
- package/src/security/baseline.ts +10 -5
- package/src/server/auth.ts +45 -3
- package/src/server/body.ts +34 -1
- package/src/server/client-ip.ts +3 -0
- package/src/server/context.ts +2 -1
- package/src/server/idempotency-lane.ts +64 -22
- package/src/server/install.ts +3 -0
- package/src/server/logger.ts +3 -1
- package/src/server/methods.ts +1 -0
- package/src/server/node-outcome.ts +34 -12
- package/src/server/platform-adapters.ts +3 -2
- package/src/server/request-context.ts +5 -0
- package/src/server/respond.ts +222 -69
- package/src/server/response-observer-runtime.ts +16 -38
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +29 -5
- package/src/server/server.ts +659 -530
- package/src/server/sse.ts +84 -55
- package/src/server/transport-lane.ts +7 -1
- package/src/server/webhook.ts +22 -3
- package/src/server/websocket.ts +44 -5
- package/src/server/ws-hook.ts +5 -0
- package/src/server.ts +5 -0
- package/src/single-copy.ts +20 -1
- package/src/tool-contract.ts +67 -39
- package/src/ws.ts +4 -0
package/src/server/respond.ts
CHANGED
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { NODE_BRIDGE_MARKER_KEYS } from "./bridge-markers.ts"
|
|
8
|
+
import { plainError } from "./http.ts"
|
|
8
9
|
import {
|
|
10
|
+
CONTEXT_REQUEST,
|
|
9
11
|
CONTEXT_SET,
|
|
10
12
|
EMPTY_RESPONSE_CONTROLS,
|
|
11
13
|
type HandlerResult,
|
|
12
14
|
isResponseResult,
|
|
13
15
|
type PlainRender,
|
|
14
16
|
plainRenderHeaders,
|
|
17
|
+
type ResponseResult,
|
|
15
18
|
} from "./runtime-core.ts"
|
|
16
19
|
import type { CtxSet, RawContext } from "./server.ts"
|
|
17
20
|
import { mergeStaticHeaderRecord, type StaticResponseHeaders } from "./static-headers.ts"
|
|
@@ -76,28 +79,26 @@ export function buildStaticResponseHeaders(
|
|
|
76
79
|
/**
|
|
77
80
|
* Apply the static defaults to an ALREADY-BUILT response - the framework's error/404/timeout renders
|
|
78
81
|
* and a handler-returned `Response`, none of which go through the header init below. Defaults, so a
|
|
79
|
-
* name the response already carries is left alone
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
+
* name the response already carries is left alone, and a response needing none is returned untouched.
|
|
83
|
+
* The writes land on {@link writableResponse}'s answer, never on an object other requests may share;
|
|
84
|
+
* `request` is the request being answered.
|
|
82
85
|
*/
|
|
83
86
|
export function applyStaticResponseHeaders(
|
|
84
87
|
response: Response,
|
|
85
88
|
statics: StaticResponseHeaders,
|
|
89
|
+
request?: Request,
|
|
86
90
|
): Response {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
applyStaticDefaults(clone.headers, statics)
|
|
97
|
-
return stamped(clone)
|
|
91
|
+
let target = response
|
|
92
|
+
const entries = statics.entries
|
|
93
|
+
for (let i = 0; i < entries.length; i++) {
|
|
94
|
+
const entry = entries[i]!
|
|
95
|
+
const current = response.headers.get(entry[0])
|
|
96
|
+
// Set by the request, or already the declared value: nothing to write.
|
|
97
|
+
if (current === entry[1] || current !== (statics.inherited?.[entry[0]] ?? null)) continue
|
|
98
|
+
if (target === response) target = writableResponse(response, request)
|
|
99
|
+
target.headers.set(entry[0], entry[1])
|
|
98
100
|
}
|
|
99
|
-
|
|
100
|
-
return response
|
|
101
|
+
return target
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
/**
|
|
@@ -129,8 +130,12 @@ function staticJsonResponse(
|
|
|
129
130
|
return new Response(body, init)
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
function withStatics(
|
|
133
|
-
|
|
133
|
+
function withStatics(
|
|
134
|
+
response: Response,
|
|
135
|
+
statics: StaticResponseHeaders | undefined,
|
|
136
|
+
request: Request | undefined,
|
|
137
|
+
): Response {
|
|
138
|
+
return statics === undefined ? response : applyStaticResponseHeaders(response, statics, request)
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
function applyStaticDefaults(headers: Headers, statics: StaticResponseHeaders): void {
|
|
@@ -212,7 +217,7 @@ function jsonResponseWithOwnHeaders(
|
|
|
212
217
|
): Response {
|
|
213
218
|
const response = status === 200 ? Response.json(result) : Response.json(result, { status })
|
|
214
219
|
for (const name of Object.keys(headers)) response.headers.set(name, headers[name]!)
|
|
215
|
-
return
|
|
220
|
+
return withBody(response)
|
|
216
221
|
}
|
|
217
222
|
|
|
218
223
|
// The framework-buffered-body marker shared with the Node adapter: a Response carrying it exposes
|
|
@@ -221,44 +226,118 @@ function jsonResponseWithOwnHeaders(
|
|
|
221
226
|
const RESPONSE_BODY = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseBody)
|
|
222
227
|
|
|
223
228
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* whose headers are guarded immutable) reaches the probe.
|
|
229
|
+
* Responses known to carry a body and take header writes: framework renders built with one, and
|
|
230
|
+
* foreign ones `writableResponse` has already checked. A body is sent once, so such a response belongs
|
|
231
|
+
* to whichever request holds it and is written in place without reading `.body` again - on Bun that
|
|
232
|
+
* read turns a string body into a stream, measured at about 6% of a request.
|
|
229
233
|
*/
|
|
230
|
-
const
|
|
234
|
+
const WRITABLE_BODY_RESPONSES = new WeakSet<Response>()
|
|
231
235
|
|
|
232
|
-
|
|
233
|
-
|
|
236
|
+
/**
|
|
237
|
+
* Body-less responses nifra built for one request - `writableResponse` copies and the framework's own
|
|
238
|
+
* body-less renders - by that request. Only objects nifra constructs are entered, never one a handler
|
|
239
|
+
* or hook hands over, so the request's later writers write them in place and any other request that
|
|
240
|
+
* is handed one copies it.
|
|
241
|
+
*/
|
|
242
|
+
const REQUEST_OWNED = new WeakMap<Response, Request>()
|
|
243
|
+
|
|
244
|
+
function ownedBy(response: Response, request: Request | undefined): Response {
|
|
245
|
+
if (request !== undefined) REQUEST_OWNED.set(response, request)
|
|
246
|
+
return response
|
|
234
247
|
}
|
|
235
248
|
|
|
236
|
-
|
|
237
|
-
|
|
249
|
+
/** Record a Response the framework constructed: one built with a body is single-use and writable. */
|
|
250
|
+
export function rememberRenderedResponse(response: Response, hasBody: boolean): Response {
|
|
251
|
+
if (hasBody) WRITABLE_BODY_RESPONSES.add(response)
|
|
252
|
+
return response
|
|
238
253
|
}
|
|
239
254
|
|
|
240
|
-
/**
|
|
241
|
-
function
|
|
242
|
-
|
|
255
|
+
/** `rememberRenderedResponse` for a Response the framework constructed with a body. */
|
|
256
|
+
function withBody(response: Response): Response {
|
|
257
|
+
WRITABLE_BODY_RESPONSES.add(response)
|
|
243
258
|
return response
|
|
244
259
|
}
|
|
245
260
|
|
|
261
|
+
const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
|
|
262
|
+
|
|
263
|
+
/** Whether `headers` takes writes. A guarded `Headers` rejects the first one, so the probe changes
|
|
264
|
+
* nothing either way. */
|
|
265
|
+
function headersTakeWrites(headers: Headers): boolean {
|
|
266
|
+
try {
|
|
267
|
+
const previous = headers.get(HEADER_MUTABILITY_PROBE)
|
|
268
|
+
headers.set(HEADER_MUTABILITY_PROBE, "1")
|
|
269
|
+
if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
|
|
270
|
+
else headers.set(HEADER_MUTABILITY_PROBE, previous)
|
|
271
|
+
} catch {
|
|
272
|
+
return false
|
|
273
|
+
}
|
|
274
|
+
return true
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The response to write headers on for the request being answered: `response` itself, or a copy.
|
|
279
|
+
*
|
|
280
|
+
* A Response without a body can be sent any number of times, so the object a handler, hook or cache
|
|
281
|
+
* hands over may be answering other requests too - a module-level `new Response(null, { status: 204 })`,
|
|
282
|
+
* a kept redirect, even one nifra rendered earlier. A cookie or per-request header written onto it would
|
|
283
|
+
* ship with every later request it answers, so it is always copied, whoever built it. One with a body
|
|
284
|
+
* can be sent only once, so it already belongs to this request and is written in place; a copy would
|
|
285
|
+
* cost Bun's file responses their sendfile path and buy nothing. Guarded `Headers`
|
|
286
|
+
* (`Response.redirect()`, a raw `fetch()` result) reject writes, so those are copied as well.
|
|
287
|
+
*
|
|
288
|
+
* Pass the `request` being answered and a copy made for it is written in place by its later writers;
|
|
289
|
+
* handed back on any other request, that copy is copied again.
|
|
290
|
+
*/
|
|
291
|
+
export function writableResponse(response: Response, request?: Request): Response {
|
|
292
|
+
if (WRITABLE_BODY_RESPONSES.has(response)) return response
|
|
293
|
+
if (response.body === null) {
|
|
294
|
+
// A 101 upgrade and the status-0 `Response.error()` cannot be constructed, and a runtime makes
|
|
295
|
+
// them per request.
|
|
296
|
+
if (response.status < 200) return response
|
|
297
|
+
if (request !== undefined && REQUEST_OWNED.get(response) === request) return response
|
|
298
|
+
const copy = copiedResponse(
|
|
299
|
+
response,
|
|
300
|
+
new Response(null, {
|
|
301
|
+
status: response.status,
|
|
302
|
+
statusText: response.statusText,
|
|
303
|
+
headers: response.headers,
|
|
304
|
+
}),
|
|
305
|
+
false,
|
|
306
|
+
)
|
|
307
|
+
return ownedBy(copy, request)
|
|
308
|
+
}
|
|
309
|
+
if (headersTakeWrites(response.headers)) {
|
|
310
|
+
WRITABLE_BODY_RESPONSES.add(response)
|
|
311
|
+
return response
|
|
312
|
+
}
|
|
313
|
+
return copiedResponse(response, new Response(response.body, response), true)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** `copy`, recorded and carrying `original`'s buffered-body marker: the Node writer still sends the
|
|
317
|
+
* serialized bytes directly, and a marked body-less Response keeps the body only the marker holds. */
|
|
318
|
+
function copiedResponse(original: Response, copy: Response, hasBody: boolean): Response {
|
|
319
|
+
const marker: unknown = Reflect.get(original, RESPONSE_BODY)
|
|
320
|
+
if (marker !== undefined) Object.defineProperty(copy, RESPONSE_BODY, { value: marker })
|
|
321
|
+
return rememberRenderedResponse(copy, hasBody)
|
|
322
|
+
}
|
|
323
|
+
|
|
246
324
|
function isBodylessStatus(status: number): boolean {
|
|
247
325
|
return status === 204 || status === 205 || status === 304
|
|
248
326
|
}
|
|
249
327
|
|
|
250
328
|
/** Normalize handler-produced bodyless responses before response middleware sees them. */
|
|
251
|
-
export function normalizeBodylessResponse(response: Response): Response {
|
|
329
|
+
export function normalizeBodylessResponse(response: Response, request?: Request): Response {
|
|
252
330
|
if (!isBodylessStatus(response.status)) return response
|
|
253
331
|
if (response.body === null && !response.headers.has("content-length")) return response
|
|
254
332
|
const headers = new Headers(response.headers)
|
|
255
333
|
headers.delete("content-length")
|
|
256
|
-
return
|
|
334
|
+
return ownedBy(
|
|
257
335
|
new Response(null, {
|
|
258
336
|
status: response.status,
|
|
259
337
|
statusText: response.statusText,
|
|
260
338
|
headers,
|
|
261
339
|
}),
|
|
340
|
+
request,
|
|
262
341
|
)
|
|
263
342
|
}
|
|
264
343
|
|
|
@@ -321,7 +400,7 @@ function tagged(
|
|
|
321
400
|
body: string,
|
|
322
401
|
tagResponseBody: ResponseBodyTagOption,
|
|
323
402
|
): Response {
|
|
324
|
-
|
|
403
|
+
withBody(response)
|
|
325
404
|
return tagResponseBody === false || tagResponseBody === undefined
|
|
326
405
|
? response
|
|
327
406
|
: markTaggedResponse(
|
|
@@ -339,14 +418,15 @@ export function fusedRespondNoSet(
|
|
|
339
418
|
tagResponseBody: ResponseBodyTagOption = false,
|
|
340
419
|
statics?: StaticResponseHeaders,
|
|
341
420
|
unstamped = false,
|
|
421
|
+
request?: Request,
|
|
342
422
|
): Response {
|
|
343
|
-
// With no response hook registered, no code path ever asks whether
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
423
|
+
// With no response hook registered, no code path ever asks whether this response takes writes in
|
|
424
|
+
// place, so recording it in the body WeakSet is dead work - `Response.json` alone is cheaper on Bun
|
|
425
|
+
// than the same construction plus the WeakSet write. `unstamped` is the caller's assertion that the
|
|
426
|
+
// hook walk is empty; an app that registers ANY response hook keeps the record, because the
|
|
427
|
+
// alternative is `writableResponse` reading `.body` and probing the headers of every response (each
|
|
428
|
+
// one is fresh, so nothing amortizes) - the probe alone measured 19% slower than the WeakSet write.
|
|
429
|
+
// Default false: an unknown caller pays one WeakSet write rather than a probe per response.
|
|
350
430
|
if (
|
|
351
431
|
unstamped &&
|
|
352
432
|
(tagResponseBody === false || tagResponseBody === undefined) &&
|
|
@@ -379,7 +459,7 @@ export function fusedRespondNoSet(
|
|
|
379
459
|
const entry = statics.entries[i]!
|
|
380
460
|
response.headers.set(entry[0], entry[1])
|
|
381
461
|
}
|
|
382
|
-
return
|
|
462
|
+
return withBody(response)
|
|
383
463
|
}
|
|
384
464
|
// With declared static headers the prebuilt init already carries them plus the JSON
|
|
385
465
|
// content-type, so this lane still constructs the response in one call - the whole point of
|
|
@@ -406,13 +486,19 @@ export function fusedRespondNoSet(
|
|
|
406
486
|
tagResponseBody,
|
|
407
487
|
)
|
|
408
488
|
}
|
|
409
|
-
return
|
|
489
|
+
return withBody(
|
|
410
490
|
statics === undefined
|
|
411
491
|
? Response.json(result)
|
|
412
492
|
: Response.json(result, statics.responseJsonInit200()),
|
|
413
493
|
)
|
|
414
494
|
}
|
|
415
|
-
return toResponse(
|
|
495
|
+
return toResponse(
|
|
496
|
+
result as HandlerResult,
|
|
497
|
+
EMPTY_RESPONSE_CONTROLS,
|
|
498
|
+
tagResponseBody,
|
|
499
|
+
statics,
|
|
500
|
+
request,
|
|
501
|
+
)
|
|
416
502
|
}
|
|
417
503
|
|
|
418
504
|
/** Fused-lane respond with a context: read `c.set` once; untouched (the common case) -> the fast
|
|
@@ -425,15 +511,23 @@ export function fusedRespond(
|
|
|
425
511
|
unstamped = false,
|
|
426
512
|
): Response {
|
|
427
513
|
const set = ctx[CONTEXT_SET]()
|
|
428
|
-
|
|
429
|
-
|
|
514
|
+
const request = unstamped ? undefined : ctx[CONTEXT_REQUEST]
|
|
515
|
+
if (set === undefined)
|
|
516
|
+
return fusedRespondNoSet(result, tagResponseBody, statics, unstamped, request)
|
|
517
|
+
return toResponse(result as HandlerResult, set, tagResponseBody, statics, request)
|
|
430
518
|
}
|
|
431
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Render a handler's result. `request` is the request being answered, given when response hooks will
|
|
522
|
+
* write next: a copy or body-less response built here is then recorded as that request's own, so the
|
|
523
|
+
* hooks write it in place instead of copying it again.
|
|
524
|
+
*/
|
|
432
525
|
export function toResponse(
|
|
433
526
|
result: HandlerResult,
|
|
434
527
|
set: CtxSet,
|
|
435
528
|
tagResponseBody: ResponseBodyTagOption = false,
|
|
436
529
|
statics?: StaticResponseHeaders,
|
|
530
|
+
request?: Request,
|
|
437
531
|
): Response {
|
|
438
532
|
if (isResponseResult(result)) {
|
|
439
533
|
const plain = result.plain
|
|
@@ -446,16 +540,56 @@ export function toResponse(
|
|
|
446
540
|
plainRenderSet(plain, set),
|
|
447
541
|
tagResponseBody,
|
|
448
542
|
statics,
|
|
543
|
+
request,
|
|
449
544
|
)
|
|
450
545
|
}
|
|
451
546
|
return withStatics(
|
|
452
|
-
|
|
547
|
+
applySetToResponse(normalizeBodylessResponse(result.toResponse(), request), set, request),
|
|
453
548
|
statics,
|
|
549
|
+
request,
|
|
454
550
|
)
|
|
455
551
|
}
|
|
456
552
|
if (result instanceof Response) {
|
|
457
|
-
|
|
553
|
+
if (result.bodyUsed) {
|
|
554
|
+
return toResponse(SENT_BODY, EMPTY_RESPONSE_CONTROLS, false, statics, request)
|
|
555
|
+
}
|
|
556
|
+
return withStatics(
|
|
557
|
+
applySetToResponse(normalizeBodylessResponse(result, request), set, request),
|
|
558
|
+
statics,
|
|
559
|
+
request,
|
|
560
|
+
)
|
|
458
561
|
}
|
|
562
|
+
return renderValue(result, set, tagResponseBody, statics, request)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Render a framework {@link ResponseResult} - a rejection such as a `413`, `415` or `422` - with no
|
|
567
|
+
* request controls, byte-identical to `toResponse(result, EMPTY_RESPONSE_CONTROLS)`. It never takes a
|
|
568
|
+
* handler's `Response`, so a compact server rendering only its own rejections ships none of the code
|
|
569
|
+
* that adopts one.
|
|
570
|
+
*/
|
|
571
|
+
export function renderResult(result: ResponseResult): Response {
|
|
572
|
+
const plain = result.plain
|
|
573
|
+
return plain === undefined
|
|
574
|
+
? normalizeBodylessResponse(result.toResponse())
|
|
575
|
+
: renderValue(
|
|
576
|
+
plain.body,
|
|
577
|
+
plainRenderSet(plain, EMPTY_RESPONSE_CONTROLS),
|
|
578
|
+
false,
|
|
579
|
+
undefined,
|
|
580
|
+
undefined,
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** The JSON lane: a value that is neither a `Response` nor a response result, rendered with the
|
|
585
|
+
* request's status, headers and cookies. */
|
|
586
|
+
function renderValue(
|
|
587
|
+
result: unknown,
|
|
588
|
+
set: CtxSet,
|
|
589
|
+
tagResponseBody: ResponseBodyTagOption,
|
|
590
|
+
statics: StaticResponseHeaders | undefined,
|
|
591
|
+
request: Request | undefined,
|
|
592
|
+
): Response {
|
|
459
593
|
// The static tier only changes WHICH prebuilt header shape the branches below reach for: the
|
|
460
594
|
// request's own headers are folded on top of the declared defaults once, here.
|
|
461
595
|
const own = headersInit(set)
|
|
@@ -463,7 +597,10 @@ export function toResponse(
|
|
|
463
597
|
const jsonCtHeaders = statics === undefined ? JSON_CT_HEADERS : statics.jsonHeaders
|
|
464
598
|
const status = set.status ?? (result === undefined ? 204 : 200)
|
|
465
599
|
if (status === 204 || status === 205 || status === 304) {
|
|
466
|
-
return
|
|
600
|
+
return ownedBy(
|
|
601
|
+
new Response(null, headers === undefined ? { status } : { status, headers }),
|
|
602
|
+
request,
|
|
603
|
+
)
|
|
467
604
|
}
|
|
468
605
|
// Bun and Deno both make a JSON response first and then accept a few header mutations more cheaply
|
|
469
606
|
// than ingesting the same plain record in the constructor. This is the common dynamic-header
|
|
@@ -505,7 +642,7 @@ export function toResponse(
|
|
|
505
642
|
}
|
|
506
643
|
}
|
|
507
644
|
const init: ResponseInit = headers === undefined ? { status } : { status, headers }
|
|
508
|
-
if (result === undefined) return
|
|
645
|
+
if (result === undefined) return ownedBy(new Response(null, init), request)
|
|
509
646
|
if (tagResponseBody) {
|
|
510
647
|
const body = JSON.stringify(result) as string | undefined
|
|
511
648
|
if (body !== undefined) {
|
|
@@ -528,9 +665,9 @@ export function toResponse(
|
|
|
528
665
|
}
|
|
529
666
|
if (IS_DENO && headers !== undefined && !(headers instanceof Headers)) {
|
|
530
667
|
const body = JSON.stringify(result) as string | undefined
|
|
531
|
-
if (body !== undefined) return
|
|
668
|
+
if (body !== undefined) return withBody(denoResponseWithJsonBody(body, status, headers))
|
|
532
669
|
}
|
|
533
|
-
return
|
|
670
|
+
return withBody(Response.json(result, init))
|
|
534
671
|
}
|
|
535
672
|
|
|
536
673
|
// Add a JSON content-type without pre-building a `Headers` instance just to check-and-set one key:
|
|
@@ -553,21 +690,37 @@ function withJsonContentType(
|
|
|
553
690
|
: { ...headers, "content-type": "application/json;charset=utf-8" }
|
|
554
691
|
}
|
|
555
692
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
693
|
+
/** Headers that describe a Response's body or its framing: a `c.set` value for one describes a body
|
|
694
|
+
* the handler did not return. */
|
|
695
|
+
export const RESPONSE_OWNED_HEADERS: ReadonlySet<string> = new Set([
|
|
696
|
+
"content-type",
|
|
697
|
+
"content-length",
|
|
698
|
+
"content-encoding",
|
|
699
|
+
"content-range",
|
|
700
|
+
"transfer-encoding",
|
|
701
|
+
])
|
|
702
|
+
|
|
703
|
+
export function applySetToResponse(response: Response, set: CtxSet, request?: Request): Response {
|
|
704
|
+
// A guard's `c.set.headers` (a `cache-control: no-store` on private data) and queued cookies belong
|
|
705
|
+
// to this request whatever the handler returned, so they go on `writableResponse`'s answer, never on
|
|
706
|
+
// an object another request may be served from. The Response's own headers win, and the ones that
|
|
707
|
+
// describe its body or framing come from it alone. A 101 or `Response.error()` takes no headers.
|
|
708
|
+
let target = response
|
|
709
|
+
const headers = set._headers
|
|
710
|
+
if (headers !== undefined && response.status >= 200) {
|
|
711
|
+
for (const name in headers) {
|
|
712
|
+
if (RESPONSE_OWNED_HEADERS.has(name.toLowerCase()) || target.headers.has(name)) continue
|
|
713
|
+
if (target === response) target = writableResponse(response, request)
|
|
714
|
+
target.headers.set(name, headers[name]!)
|
|
715
|
+
}
|
|
572
716
|
}
|
|
717
|
+
const cookies = set._cookies
|
|
718
|
+
if (cookies === undefined || cookies.length === 0) return target
|
|
719
|
+
if (target === response) target = writableResponse(response, request)
|
|
720
|
+
for (const cookie of cookies) target.headers.append("set-cookie", cookie)
|
|
721
|
+
return target
|
|
573
722
|
}
|
|
723
|
+
|
|
724
|
+
/** What a handler's Response renders as when its body was already sent: a runtime refuses to send it
|
|
725
|
+
* again, and the headers it carries were written for the request it already answered. */
|
|
726
|
+
export const SENT_BODY = plainError(500, "internal_error")
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { tagSource } from "../internal/hook-origins.ts"
|
|
1
2
|
import type {
|
|
2
3
|
NodeRequestContext,
|
|
3
4
|
NodeResponseBodyHook,
|
|
@@ -7,7 +8,7 @@ import type {
|
|
|
7
8
|
ResponseHeadersView,
|
|
8
9
|
} from "./node-outcome-hook.ts"
|
|
9
10
|
import { recordHeadersView } from "./node-outcome-hook.ts"
|
|
10
|
-
import {
|
|
11
|
+
import { taggedResponseBody, writableResponse } from "./respond.ts"
|
|
11
12
|
import { applyBodyReplacement, withReplacedBody } from "./response-hooks.ts"
|
|
12
13
|
|
|
13
14
|
type MaybePromise<T> = T | Promise<T>
|
|
@@ -19,6 +20,9 @@ export type {
|
|
|
19
20
|
ResponseHeadersHook,
|
|
20
21
|
} from "./node-outcome-hook.ts"
|
|
21
22
|
|
|
23
|
+
// Deno reports `process.env` reads as `NotCapable` without --allow-env; read it only off Deno.
|
|
24
|
+
declare const Deno: unknown
|
|
25
|
+
|
|
22
26
|
export interface ResponseObserverHost {
|
|
23
27
|
assertConfigurable(operation: string): void
|
|
24
28
|
addResponseHook(
|
|
@@ -44,32 +48,6 @@ export interface ResponseObserverRuntime {
|
|
|
44
48
|
install(host: ResponseObserverHost): ResponseObserverMethods
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
|
|
48
|
-
const GUARDED_RESPONSE_HEADERS = new WeakSet<Headers>()
|
|
49
|
-
|
|
50
|
-
function hasMutableResponseHeaders(headers: Headers): boolean {
|
|
51
|
-
if (knownMutableHeaders(headers)) return true
|
|
52
|
-
if (GUARDED_RESPONSE_HEADERS.has(headers)) return false
|
|
53
|
-
let previous: string | null = null
|
|
54
|
-
try {
|
|
55
|
-
previous = headers.get(HEADER_MUTABILITY_PROBE)
|
|
56
|
-
headers.set(HEADER_MUTABILITY_PROBE, "1")
|
|
57
|
-
if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
|
|
58
|
-
else headers.set(HEADER_MUTABILITY_PROBE, previous)
|
|
59
|
-
rememberMutableHeaders(headers)
|
|
60
|
-
return true
|
|
61
|
-
} catch {
|
|
62
|
-
try {
|
|
63
|
-
if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
|
|
64
|
-
else headers.set(HEADER_MUTABILITY_PROBE, previous)
|
|
65
|
-
} catch {
|
|
66
|
-
// A guarded response rejected the probe and the clone path is authoritative.
|
|
67
|
-
}
|
|
68
|
-
GUARDED_RESPONSE_HEADERS.add(headers)
|
|
69
|
-
return false
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
51
|
function webRequestView(req: Request): NodeRequestContext {
|
|
74
52
|
return { method: req.method, url: req.url, header: (name) => req.headers.get(name) }
|
|
75
53
|
}
|
|
@@ -82,12 +60,8 @@ function webHeadersView(
|
|
|
82
60
|
readonly headers: ResponseHeadersView
|
|
83
61
|
readonly request: NodeRequestContext
|
|
84
62
|
} {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
const clone = new Response(response.body, response)
|
|
88
|
-
return { response: clone, headers: clone.headers, request }
|
|
89
|
-
}
|
|
90
|
-
return { response, headers: response.headers, request }
|
|
63
|
+
const writable = writableResponse(response, req)
|
|
64
|
+
return { response: writable, headers: writable.headers, request: webRequestView(req) }
|
|
91
65
|
}
|
|
92
66
|
|
|
93
67
|
export function createResponseObserverRuntime(): ResponseObserverRuntime {
|
|
@@ -100,12 +74,16 @@ export function createResponseObserverRuntime(): ResponseObserverRuntime {
|
|
|
100
74
|
node?: NodeResponseHook,
|
|
101
75
|
): ResponseObserverMethods {
|
|
102
76
|
host.assertConfigurable("onResponseHeaders()")
|
|
77
|
+
const web: Parameters<typeof host.addResponseHook>[0] = (response, req) => {
|
|
78
|
+
const view = webHeadersView(response, req)
|
|
79
|
+
const out = fn(view.headers, view.request, response.status)
|
|
80
|
+
return out instanceof Promise ? out.then(() => view.response) : view.response
|
|
81
|
+
}
|
|
82
|
+
// Development-only provenance for `reflectHooks`; a production define folds it away.
|
|
83
|
+
if (typeof Deno === "undefined" && process.env.NODE_ENV !== "production")
|
|
84
|
+
tagSource(web, fn)
|
|
103
85
|
host.addResponseHook(
|
|
104
|
-
|
|
105
|
-
const view = webHeadersView(response, req)
|
|
106
|
-
const out = fn(view.headers, view.request, response.status)
|
|
107
|
-
return out instanceof Promise ? out.then(() => view.response) : view.response
|
|
108
|
-
},
|
|
86
|
+
web,
|
|
109
87
|
node ?? ((response, req) => fn(recordHeadersView(response), req, response.status)),
|
|
110
88
|
true,
|
|
111
89
|
)
|
|
@@ -14,6 +14,8 @@ export type HandlerResult = Response | unknown
|
|
|
14
14
|
export const RESPONSE_RESULT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseResult)
|
|
15
15
|
export const CONTEXT_SET = Symbol("nifra.context.set")
|
|
16
16
|
export const CONTEXT_SEARCH = Symbol("nifra.context.search")
|
|
17
|
+
/** The request a context answers, read without the transport cap `req` applies for user code. */
|
|
18
|
+
export const CONTEXT_REQUEST = Symbol("nifra.context.request")
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* A transport codec (or other pre-parsing hook) that already decoded the body stashes the value
|
|
@@ -109,8 +109,12 @@ export interface ServerOptions {
|
|
|
109
109
|
* Default 30_000; applies only when `acceptInboundDeadlines` is enabled and the header is present.
|
|
110
110
|
*/
|
|
111
111
|
readonly maxInboundDeadlineMs?: number
|
|
112
|
-
/**
|
|
113
|
-
|
|
112
|
+
/**
|
|
113
|
+
* When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false.
|
|
114
|
+
* `{ drainMs }` sets how long in-flight requests get to finish before the stop forces them closed
|
|
115
|
+
* (default 10_000) - keep it under the platform's kill grace period (`docker stop` waits 10 s).
|
|
116
|
+
*/
|
|
117
|
+
readonly gracefulSignals?: boolean | { readonly drainMs?: number }
|
|
114
118
|
/**
|
|
115
119
|
* Capacity-admission gate (see {@link AdmissionController}). Bounds concurrent in-flight work so a
|
|
116
120
|
* healthy instance sheds load (`429` + `Retry-After`) instead of accepting more than it can finish.
|
|
@@ -196,9 +200,29 @@ export interface MountOptions<Env = unknown> {
|
|
|
196
200
|
* Why this child is not analyzed, for a handler whose effects nifra cannot follow (a closure from
|
|
197
201
|
* another package). Capability assurance lists a mount with a reason as a known gap; a mount
|
|
198
202
|
* without one fails it, since its routes are invisible. A nifra `server()` belongs in `merge()`,
|
|
199
|
-
* where its routes are analyzed.
|
|
203
|
+
* where its routes are analyzed. Pass an {@link OpaqueMountDeclaration} to also state the routes
|
|
204
|
+
* the child serves.
|
|
200
205
|
*/
|
|
201
|
-
readonly opaque?: string
|
|
206
|
+
readonly opaque?: string | OpaqueMountDeclaration
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* An opaque mount's reason plus the routes it serves. Assurance lists those routes as declared, not
|
|
211
|
+
* analyzed: their capabilities are the mount's word, checked against the policy but never proven.
|
|
212
|
+
*/
|
|
213
|
+
export interface OpaqueMountDeclaration {
|
|
214
|
+
/** Why the child is not analyzed. A blank reason leaves the mount undeclared. */
|
|
215
|
+
readonly reason: string
|
|
216
|
+
readonly routes?: readonly DeclaredMountRoute[]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** One route an opaque mount serves, as the app states it. */
|
|
220
|
+
export interface DeclaredMountRoute {
|
|
221
|
+
readonly method: string
|
|
222
|
+
/** The request path, from the app root, under the mount's prefix (`/api/auth/sign-in`). */
|
|
223
|
+
readonly path: string
|
|
224
|
+
/** Capability ids the route exercises. */
|
|
225
|
+
readonly capabilities?: readonly string[]
|
|
202
226
|
}
|
|
203
227
|
|
|
204
228
|
/** Options for a legacy fetch-handler mount. */
|
|
@@ -210,7 +234,7 @@ export interface MountFetchOptions {
|
|
|
210
234
|
/** For safe replayable methods only, try the next matching legacy mount when it returns 404. */
|
|
211
235
|
readonly fallbackOn?: 404
|
|
212
236
|
/** Why this handler is not analyzed - see {@link MountOptions.opaque}. */
|
|
213
|
-
readonly opaque?: string
|
|
237
|
+
readonly opaque?: string | OpaqueMountDeclaration
|
|
214
238
|
}
|
|
215
239
|
|
|
216
240
|
/** A callback awaited after the Bun server has drained and stopped. */
|