@nifrajs/core 5.7.0 → 5.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +105 -0
- package/README.md +9 -4
- package/dist/assurance.d.ts +26 -1
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +43 -11
- package/dist/assurance.js.map +1 -1
- package/dist/canonical-json.d.ts +11 -0
- package/dist/canonical-json.d.ts.map +1 -0
- package/dist/canonical-json.js +46 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +1 -8
- package/dist/capabilities.js.map +1 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +1 -8
- package/dist/data.js.map +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +2 -12
- package/dist/diff.js.map +1 -1
- package/dist/durable-adapters.d.ts +2 -0
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +41 -49
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-saga.d.ts.map +1 -1
- package/dist/durable-saga.js +14 -20
- package/dist/durable-saga.js.map +1 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +14 -68
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +60 -23
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/control-character.d.ts +3 -0
- package/dist/internal/control-character.d.ts.map +1 -0
- package/dist/internal/control-character.js +10 -0
- package/dist/internal/control-character.js.map +1 -0
- package/dist/internal/durable-shared.d.ts.map +1 -1
- package/dist/internal/durable-shared.js +4 -1
- package/dist/internal/durable-shared.js.map +1 -1
- package/dist/internal/hex.d.ts +5 -0
- package/dist/internal/hex.d.ts.map +1 -0
- package/dist/internal/hex.js +14 -0
- package/dist/internal/hex.js.map +1 -0
- package/dist/internal/record.d.ts +3 -0
- package/dist/internal/record.d.ts.map +1 -0
- package/dist/internal/record.js +5 -0
- package/dist/internal/record.js.map +1 -0
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +2 -2
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-execution.d.ts +4 -4
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +4 -0
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +4 -3
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +16 -33
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/schema-parts.d.ts +13 -0
- package/dist/internal/schema-parts.d.ts.map +1 -0
- package/dist/internal/schema-parts.js +15 -0
- package/dist/internal/schema-parts.js.map +1 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +2 -14
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +10 -37
- package/dist/manifest.js.map +1 -1
- package/dist/open-resolved.d.ts +18 -0
- package/dist/open-resolved.d.ts.map +1 -0
- package/dist/open-resolved.js +62 -0
- package/dist/open-resolved.js.map +1 -0
- package/dist/range.js +1 -1
- package/dist/range.js.map +1 -1
- package/dist/serve-static.d.ts +22 -0
- package/dist/serve-static.d.ts.map +1 -0
- package/dist/serve-static.js +187 -0
- package/dist/serve-static.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +2 -1
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +12 -3
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/base64-answers.d.ts.map +1 -1
- package/dist/server/base64-answers.js +1 -4
- package/dist/server/base64-answers.js.map +1 -1
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +43 -38
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +4 -1
- package/dist/server/body.js.map +1 -1
- package/dist/server/bridge-markers.d.ts +1 -1
- package/dist/server/bridge-markers.js +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/core-copies.js +1 -1
- package/dist/server/http.d.ts +5 -0
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +13 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +2 -0
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/idle-timeout.d.ts +39 -0
- package/dist/server/idle-timeout.d.ts.map +1 -0
- package/dist/server/idle-timeout.js +95 -0
- package/dist/server/idle-timeout.js.map +1 -0
- package/dist/server/json-shared.d.ts +23 -0
- package/dist/server/json-shared.d.ts.map +1 -0
- package/dist/server/json-shared.js +33 -0
- package/dist/server/json-shared.js.map +1 -0
- package/dist/server/json-text.d.ts +1 -1
- package/dist/server/json-text.js +1 -1
- package/dist/server/node-outcome.js +1 -1
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/request-context.d.ts +22 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +44 -4
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-local.d.ts +44 -0
- package/dist/server/request-local.d.ts.map +1 -0
- package/dist/server/request-local.js +91 -0
- package/dist/server/request-local.js.map +1 -0
- package/dist/server/respond.js +1 -1
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +10 -2
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +93 -64
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/runtime-core.d.ts +7 -4
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +9 -7
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +27 -0
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +59 -59
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +869 -512
- package/dist/server/server.js.map +1 -1
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +2 -0
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/webhook.js +1 -1
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +3 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +14 -1
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-adapter-view.d.ts +54 -0
- package/dist/server/ws-adapter-view.d.ts.map +1 -0
- package/dist/server/ws-adapter-view.js +7 -0
- package/dist/server/ws-adapter-view.js.map +1 -0
- package/dist/server.d.ts +2 -1
- 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.map +1 -1
- package/dist/single-copy.js +1 -1
- package/dist/single-copy.js.map +1 -1
- package/dist/static-file.d.ts +156 -4
- package/dist/static-file.d.ts.map +1 -1
- package/dist/static-file.js +198 -4
- package/dist/static-file.js.map +1 -1
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +1 -3
- package/dist/wire.js.map +1 -1
- package/package.json +16 -1
- package/src/assurance.ts +66 -16
- package/src/canonical-json.ts +43 -0
- package/src/capabilities.ts +1 -8
- package/src/data.ts +2 -8
- package/src/diff.ts +2 -12
- package/src/durable-adapters.ts +53 -50
- package/src/durable-saga.ts +30 -23
- package/src/evidence.ts +25 -90
- package/src/idempotency.ts +62 -20
- package/src/index.ts +13 -2
- package/src/internal/control-character.ts +8 -0
- package/src/internal/durable-shared.ts +4 -1
- package/src/internal/hex.ts +14 -0
- package/src/internal/record.ts +4 -0
- package/src/internal/route-compiler.ts +2 -2
- package/src/internal/route-execution.ts +7 -20
- package/src/internal/route-lanes.ts +20 -44
- package/src/internal/schema-parts.ts +19 -0
- package/src/ledger.ts +2 -16
- package/src/manifest.ts +10 -38
- package/src/open-resolved.ts +64 -0
- package/src/range.ts +1 -1
- package/src/serve-static.ts +227 -0
- package/src/server/bare-error-lane.ts +11 -2
- package/src/server/base64-answers.ts +2 -6
- package/src/server/body-lane.ts +58 -51
- package/src/server/body.ts +4 -1
- package/src/server/bridge-markers.ts +1 -1
- package/src/server/cookies.ts +4 -1
- package/src/server/core-copies.ts +1 -1
- package/src/server/http.ts +12 -0
- package/src/server/idempotency-lane.ts +2 -0
- package/src/server/idle-timeout.ts +107 -0
- package/src/server/json-shared.ts +38 -0
- package/src/server/json-text.ts +1 -1
- package/src/server/node-outcome.ts +1 -1
- package/src/server/request-context.ts +62 -3
- package/src/server/request-local.ts +109 -0
- package/src/server/respond.ts +1 -1
- package/src/server/response-contract-lane.ts +112 -65
- package/src/server/runtime-core.ts +10 -8
- package/src/server/server-types.ts +27 -0
- package/src/server/server.ts +1230 -800
- package/src/server/transport-lane.ts +2 -0
- package/src/server/webhook.ts +1 -1
- package/src/server/websocket.ts +18 -1
- package/src/server/ws-adapter-view.ts +63 -0
- package/src/server.ts +12 -1
- package/src/single-copy.ts +1 -3
- package/src/static-file.ts +350 -4
- package/src/wire.ts +2 -4
package/src/server/http.ts
CHANGED
|
@@ -150,3 +150,15 @@ export function pathnameOf(url: string): string {
|
|
|
150
150
|
}
|
|
151
151
|
return url.slice(start, end)
|
|
152
152
|
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A `Vary` header value (`null` when the response has none) that names each of `names` once, matched
|
|
156
|
+
* without regard to case. `Vary: *` already covers every request header, so it comes back unchanged.
|
|
157
|
+
*/
|
|
158
|
+
export function varyOn(value: string | null, names: readonly string[]): string {
|
|
159
|
+
if (value === null || value.trim() === "") return names.join(", ")
|
|
160
|
+
const fields = value.split(",").map((field) => field.trim().toLowerCase())
|
|
161
|
+
if (fields.includes("*")) return value
|
|
162
|
+
const missing = names.filter((name) => !fields.includes(name.toLowerCase()))
|
|
163
|
+
return missing.length === 0 ? value : `${value}, ${missing.join(", ")}`
|
|
164
|
+
}
|
|
@@ -35,6 +35,7 @@ import { markTransportCap, readBoundedBytes, transportCapOf, UNLIMITED_BODY_BYTE
|
|
|
35
35
|
import type { Platform, RouteSchema } from "./context.ts"
|
|
36
36
|
import { jsonError, urlPartsOf } from "./http.ts"
|
|
37
37
|
import { INSTALL_IDEMPOTENCY } from "./install.ts"
|
|
38
|
+
import { carryToCopy } from "./request-local.ts"
|
|
38
39
|
import { CONTEXT_REQUEST, PRE_DECODED_BODY, preDecodedBody, requestOf } from "./runtime-core.ts"
|
|
39
40
|
import type { AnyServer, IdentityPlugin, RawContext, RequestSource } from "./server.ts"
|
|
40
41
|
|
|
@@ -467,6 +468,7 @@ function prepareRequest(source: RequestSource, platform: Platform | undefined):
|
|
|
467
468
|
duplex: "half",
|
|
468
469
|
}
|
|
469
470
|
prepared = new Request(request.url, init)
|
|
471
|
+
carryToCopy(request, prepared)
|
|
470
472
|
const decoded = preDecodedBody(request)
|
|
471
473
|
if (decoded !== undefined) Object.defineProperty(prepared, PRE_DECODED_BODY, { value: decoded })
|
|
472
474
|
} catch (error) {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `withIdleTimeout()`: one request's own idle timeout on a Bun `listen()` server, held while slow
|
|
3
|
+
* work runs and put back before the response goes out.
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from "./context.ts"
|
|
6
|
+
import { CONTEXT_IDLE_TIMEOUT, getNeverAbortSignal } from "./runtime-core.ts"
|
|
7
|
+
|
|
8
|
+
type MaybePromise<T> = T | Promise<T>
|
|
9
|
+
|
|
10
|
+
type IdleTimeoutControl = (this: unknown, seconds: number | undefined) => boolean
|
|
11
|
+
|
|
12
|
+
/** The `seconds` of every scope still running on a context. Deleted when the last one ends or the
|
|
13
|
+
* request is aborted, which also tells a scope ending after an abort to leave the socket alone. */
|
|
14
|
+
const scopesOf = new WeakMap<object, number[]>()
|
|
15
|
+
|
|
16
|
+
const longest = (scopes: readonly number[]): number =>
|
|
17
|
+
scopes.includes(0) ? 0 : Math.max(...scopes)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Run `work` with the idle timeout of the request's connection set to `seconds` (`0` turns it off),
|
|
21
|
+
* then put back the listener's.
|
|
22
|
+
*
|
|
23
|
+
* Bun closes a connection that moves no bytes for `listen()`'s `idleTimeoutSec` seconds (10 by
|
|
24
|
+
* default), and a handler that is still working sends none, so an export or a long upstream call is
|
|
25
|
+
* cut mid-flight. Raising `idleTimeoutSec` raises it for every connection, idle keep-alive ones
|
|
26
|
+
* included; this raises it for one request. The listener's value is back before the response goes
|
|
27
|
+
* out because Bun keeps the last value on the socket for the requests that follow on it, so a value
|
|
28
|
+
* left in place would hold an idle connection open.
|
|
29
|
+
*
|
|
30
|
+
* The request's deadline (`requestTimeoutMs`, an inbound deadline) still bounds the response: when it
|
|
31
|
+
* fires, the 503 is sent with the listener's timeout back and `c.signal` aborts, but `work`
|
|
32
|
+
* keeps running unless it stops on that signal. Scopes nest: while several run on one request the
|
|
33
|
+
* longest applies, and `0` outlasts any number. `seconds` runs from 0 to 255, the longest Bun holds
|
|
34
|
+
* an idle connection; work that may idle longer takes `0`.
|
|
35
|
+
*
|
|
36
|
+
* A streamed response is sent after the handler returns, so call this inside the stream's producer
|
|
37
|
+
* to hold it until the last chunk. Off `listen()` - another adapter, `app.fetch` in a test - it runs
|
|
38
|
+
* `work` unchanged.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { withIdleTimeout } from "@nifrajs/core/idle-timeout"
|
|
43
|
+
*
|
|
44
|
+
* app.post("/exports", (c) => withIdleTimeout(c, 0, () => buildExport(c.body)))
|
|
45
|
+
* // Every route declared after this one:
|
|
46
|
+
* app.around((c, next) => withIdleTimeout(c, 120, next))
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function withIdleTimeout<T>(
|
|
50
|
+
c: Context,
|
|
51
|
+
seconds: number,
|
|
52
|
+
work: () => MaybePromise<T>,
|
|
53
|
+
): MaybePromise<T> {
|
|
54
|
+
if (!Number.isSafeInteger(seconds) || seconds < 0 || seconds > 255) {
|
|
55
|
+
throw new RangeError("withIdleTimeout: seconds must be an integer from 0 to 255")
|
|
56
|
+
}
|
|
57
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: only RequestContext defines this key, with this signature; any other context leaves it undefined.
|
|
58
|
+
const control = (c as Context & { [CONTEXT_IDLE_TIMEOUT]?: IdleTimeoutControl })[
|
|
59
|
+
CONTEXT_IDLE_TIMEOUT
|
|
60
|
+
]
|
|
61
|
+
const signal = c.signal
|
|
62
|
+
if (control === undefined || signal.aborted) return work()
|
|
63
|
+
const set = (value: number | undefined): void => {
|
|
64
|
+
control.call(c, value)
|
|
65
|
+
}
|
|
66
|
+
let scopes = scopesOf.get(c)
|
|
67
|
+
if (scopes === undefined) {
|
|
68
|
+
scopes = []
|
|
69
|
+
scopesOf.set(c, scopes)
|
|
70
|
+
}
|
|
71
|
+
const own = scopes
|
|
72
|
+
own.push(seconds)
|
|
73
|
+
set(longest(own))
|
|
74
|
+
// An abort is the deadline's 503 or a closed client: either way the response is final, so the
|
|
75
|
+
// listener's timeout goes back now rather than when the abandoned work settles.
|
|
76
|
+
const abort = (): void => {
|
|
77
|
+
if (scopesOf.get(c) !== own) return
|
|
78
|
+
scopesOf.delete(c)
|
|
79
|
+
set(undefined)
|
|
80
|
+
}
|
|
81
|
+
// The never-abort signal is one object shared by every request without a deadline.
|
|
82
|
+
const listens = signal !== getNeverAbortSignal()
|
|
83
|
+
if (listens) signal.addEventListener("abort", abort, { once: true })
|
|
84
|
+
const end = (): void => {
|
|
85
|
+
if (listens) signal.removeEventListener("abort", abort)
|
|
86
|
+
if (scopesOf.get(c) !== own) return
|
|
87
|
+
own.splice(own.indexOf(seconds), 1)
|
|
88
|
+
if (own.length > 0) {
|
|
89
|
+
set(longest(own))
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
scopesOf.delete(c)
|
|
93
|
+
set(undefined)
|
|
94
|
+
}
|
|
95
|
+
let result: MaybePromise<T>
|
|
96
|
+
try {
|
|
97
|
+
result = work()
|
|
98
|
+
} catch (err) {
|
|
99
|
+
end()
|
|
100
|
+
throw err
|
|
101
|
+
}
|
|
102
|
+
if (!(result instanceof Promise)) {
|
|
103
|
+
end()
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
106
|
+
return result.finally(end)
|
|
107
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What core's JSON walks share besides the writer: the `Date` hooks the response contract reads, the
|
|
3
|
+
* `__proto__`-safe field setter, and the plain-record tests canonical JSON uses. Apart from
|
|
4
|
+
* `json-text.ts` so the Node adapter's generated copy of that module holds only the writer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const DATE_TO_JSON = Date.prototype.toJSON
|
|
8
|
+
// What `Date.prototype.toJSON` reads a `Date`'s time value through.
|
|
9
|
+
const DATE_TO_PRIMITIVE = Date.prototype[Symbol.toPrimitive]
|
|
10
|
+
const DATE_VALUE_OF = Date.prototype.valueOf
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether `date` reaches the `@@toPrimitive` and `valueOf` that `Date.prototype.toJSON` reads its time
|
|
14
|
+
* value through as they ship, on itself, its class or `Date.prototype`. Then its steps, taken by the
|
|
15
|
+
* caller, give what the call would: `null` for a time value that is not finite, else what its
|
|
16
|
+
* `toISOString` answers, called through it as `toJSON` calls it.
|
|
17
|
+
*/
|
|
18
|
+
export const readsShippedTime = (date: Date): boolean =>
|
|
19
|
+
date[Symbol.toPrimitive] === DATE_TO_PRIMITIVE && date.valueOf === DATE_VALUE_OF
|
|
20
|
+
|
|
21
|
+
/** Sets `key` on `out` as an own field, `__proto__` included, which an assignment takes as the prototype. */
|
|
22
|
+
export function define(out: Record<string, unknown>, key: string, value: unknown): void {
|
|
23
|
+
if (key === "__proto__")
|
|
24
|
+
Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true })
|
|
25
|
+
else out[key] = value
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: the member is optional, so an engine without JSON.rawJSON leaves it undefined.
|
|
29
|
+
export const isRawJson = (JSON as JSON & { readonly isRawJSON?: (value: unknown) => boolean })
|
|
30
|
+
.isRawJSON
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether `proto` is a plain record's prototype: `Object.prototype`, `null`, or any prototype whose own
|
|
34
|
+
* prototype is null, which admits another realm's plain object. A boxed primitive's or a class's is
|
|
35
|
+
* not. A `JSON.rawJSON` value's is `null` too, so a caller tells one apart first.
|
|
36
|
+
*/
|
|
37
|
+
export const isPlainPrototype = (proto: unknown): boolean =>
|
|
38
|
+
proto === null || proto === Object.prototype || Object.getPrototypeOf(proto) === null
|
package/src/server/json-text.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* id answered a 500. Every plain JSON writer goes through this instead: a `bigint` is written as its
|
|
4
4
|
* decimal string, the encoding OpenAPI generators and protobuf's JSON mapping use for 64-bit integers.
|
|
5
5
|
* Only a first attempt that threw is retried, so a value without one costs nothing more. The Node
|
|
6
|
-
* adapter runs a generated copy (scripts/gen-
|
|
6
|
+
* adapter runs a generated copy (scripts/gen-vendored.ts), so this module imports nothing.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const bigintAsText = (_key: string, value: unknown): unknown =>
|
|
@@ -109,7 +109,7 @@ export function plainNodeOutcome(plain: PlainRender, set: CtxSet): NodeServeOutc
|
|
|
109
109
|
return {
|
|
110
110
|
kind: "json",
|
|
111
111
|
status: plain.status,
|
|
112
|
-
headers: plainRenderHeaders(plain, set),
|
|
112
|
+
headers: plainRenderHeaders(plain.headers, set._headers),
|
|
113
113
|
cookies: set._cookies,
|
|
114
114
|
body: plain.body === undefined || isBodylessStatus(plain.status) ? null : jsonText(plain.body),
|
|
115
115
|
}
|
|
@@ -17,7 +17,12 @@ import { jsonText } from "./json-text.ts"
|
|
|
17
17
|
import type { ProtoPoisoning } from "./proto-guard.ts"
|
|
18
18
|
import { searchOf } from "./query.ts"
|
|
19
19
|
import { residentText, responseJsonContentType } from "./respond.ts"
|
|
20
|
+
import type {
|
|
21
|
+
ResponseContractDefinition,
|
|
22
|
+
ResponseContractRuntime,
|
|
23
|
+
} from "./response-contract-lane.ts"
|
|
20
24
|
import {
|
|
25
|
+
CONTEXT_IDLE_TIMEOUT,
|
|
21
26
|
CONTEXT_REQUEST,
|
|
22
27
|
CONTEXT_SEARCH,
|
|
23
28
|
CONTEXT_SET,
|
|
@@ -166,8 +171,40 @@ const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
|
|
|
166
171
|
* handler never reads it costs the adapter neither a lookup nor a platform object of its own. */
|
|
167
172
|
export const PLATFORM_PEER = Symbol()
|
|
168
173
|
|
|
174
|
+
/** Where a Bun listener leaves the idle timeout of a request's connection: called with the request
|
|
175
|
+
* and the seconds to allow, or `undefined` to put back the listener's own. */
|
|
176
|
+
export const PLATFORM_IDLE_TIMEOUT = Symbol()
|
|
177
|
+
|
|
178
|
+
/** The request the adapter received, on a platform made for one request: the routed source may be a
|
|
179
|
+
* rewritten or stripped copy, which Bun does not know. */
|
|
180
|
+
export const PLATFORM_REQUEST = Symbol()
|
|
181
|
+
|
|
169
182
|
export type PeerPlatform = Platform & {
|
|
170
183
|
readonly [PLATFORM_PEER]?: (source: RequestSource) => string | undefined
|
|
184
|
+
readonly [PLATFORM_IDLE_TIMEOUT]?: (source: RequestSource, seconds: number | undefined) => void
|
|
185
|
+
readonly [PLATFORM_REQUEST]?: RequestSource
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Whether merging `decorations` into a context can complete. A decoration named for a read-only
|
|
190
|
+
* accessor of the context (`req`, `set`, `signal`) makes `Object.assign` throw on every request, a
|
|
191
|
+
* failure the general program routes to the route's error hooks and a fused lane would not.
|
|
192
|
+
*/
|
|
193
|
+
export function decorationsAssignable(decorations: Record<PropertyKey, unknown>): boolean {
|
|
194
|
+
for (const key of Reflect.ownKeys(decorations)) {
|
|
195
|
+
for (
|
|
196
|
+
let proto: object | null = RequestContext.prototype;
|
|
197
|
+
proto !== null && proto !== Object.prototype;
|
|
198
|
+
proto = Object.getPrototypeOf(proto)
|
|
199
|
+
) {
|
|
200
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, key)
|
|
201
|
+
if (descriptor === undefined) continue
|
|
202
|
+
if (descriptor.get !== undefined && descriptor.set === undefined) return false
|
|
203
|
+
if (descriptor.writable === false) return false
|
|
204
|
+
break
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return true
|
|
171
208
|
}
|
|
172
209
|
|
|
173
210
|
export class RequestContext implements RawContext {
|
|
@@ -190,6 +227,13 @@ export class RequestContext implements RawContext {
|
|
|
190
227
|
private declare cookiesValue: Readonly<Record<string, string>> | undefined
|
|
191
228
|
declare jsonReply: Response | undefined
|
|
192
229
|
declare jsonBody: unknown
|
|
230
|
+
/** The route's response contract, if it has one, for {@link json} to write by. */
|
|
231
|
+
declare jsonContract:
|
|
232
|
+
| {
|
|
233
|
+
readonly runtime: ResponseContractRuntime
|
|
234
|
+
readonly definition: ResponseContractDefinition
|
|
235
|
+
}
|
|
236
|
+
| undefined
|
|
193
237
|
private declare readonly source: RequestSource
|
|
194
238
|
private declare readonly maxBodyBytes: number
|
|
195
239
|
private declare readonly protoPoisoning: ProtoPoisoning
|
|
@@ -264,6 +308,14 @@ export class RequestContext implements RawContext {
|
|
|
264
308
|
return requestOf(this.source)
|
|
265
309
|
}
|
|
266
310
|
|
|
311
|
+
[CONTEXT_IDLE_TIMEOUT](seconds: number | undefined): boolean {
|
|
312
|
+
const platform = this.platformValue
|
|
313
|
+
const set = platform?.[PLATFORM_IDLE_TIMEOUT]
|
|
314
|
+
if (set === undefined) return false
|
|
315
|
+
set(platform?.[PLATFORM_REQUEST] ?? this.source, seconds)
|
|
316
|
+
return true
|
|
317
|
+
}
|
|
318
|
+
|
|
267
319
|
get [CONTEXT_SEARCH](): string {
|
|
268
320
|
this.searchValue ??= searchOf(this.source.url)
|
|
269
321
|
return this.searchValue
|
|
@@ -336,6 +388,13 @@ export class RequestContext implements RawContext {
|
|
|
336
388
|
*/
|
|
337
389
|
json(body: unknown, init?: ResponseInit | number): Response {
|
|
338
390
|
const i = statusInit(init)
|
|
391
|
+
// A contracted route's value is written here once, as its contract judges it: the bytes and the
|
|
392
|
+
// check share one call of each `toJSON` hook, a `Date`'s included.
|
|
393
|
+
const contract = this.jsonContract
|
|
394
|
+
const value =
|
|
395
|
+
contract?.runtime.write === undefined
|
|
396
|
+
? body
|
|
397
|
+
: contract.runtime.write(contract.definition, body, i?.status)
|
|
339
398
|
let reply: Response | undefined
|
|
340
399
|
if (DEFERS_RESPONSE) {
|
|
341
400
|
const defer = deferredResponderFor()
|
|
@@ -344,17 +403,17 @@ export class RequestContext implements RawContext {
|
|
|
344
403
|
// (`undefined`, a function, a symbol) - exactly the case where `Response.json` throws, so it
|
|
345
404
|
// gets the throw rather than an invented body. The content-type is the runtime's own, read
|
|
346
405
|
// off `Response.json` once, so a deferred response carries the same one byte for byte.
|
|
347
|
-
const text = defer === undefined ? undefined : (jsonText(
|
|
406
|
+
const text = defer === undefined ? undefined : (jsonText(value) as string | undefined)
|
|
348
407
|
const headers =
|
|
349
408
|
text === undefined ? undefined : ownHeaderRecord(i?.headers, responseJsonContentType())
|
|
350
409
|
if (defer !== undefined && text !== undefined && headers !== undefined) {
|
|
351
410
|
reply = defer(text, i?.status ?? 200, headers)
|
|
352
411
|
}
|
|
353
412
|
}
|
|
354
|
-
reply ??= markResidentBody(jsonResponse(
|
|
413
|
+
reply ??= markResidentBody(jsonResponse(value, i))
|
|
355
414
|
// Remembered so a response contract holds the value to the route's schema: this is the
|
|
356
415
|
// framework's JSON helper, not a raw-Response escape hatch.
|
|
357
|
-
this.jsonBody =
|
|
416
|
+
this.jsonBody = value
|
|
358
417
|
this.jsonReply = reply
|
|
359
418
|
return reply
|
|
360
419
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-local state: a value tied to one request that follows it through the copies made of it.
|
|
3
|
+
* A `WeakMap<Request, T>` loses its value at the first `new Request(url, req)`, and nifra itself
|
|
4
|
+
* rebuilds a request when a mount strips its prefix, when a WebSocket pass is reused for HTTP, and
|
|
5
|
+
* when a target is resolved. Every such copy joins the request's scope, so each reads and writes
|
|
6
|
+
* the same values.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One request-local slot from {@link requestLocal}. */
|
|
10
|
+
export interface RequestLocal<T> {
|
|
11
|
+
/** The value set through `request` or any request in its scope, else `undefined`. */
|
|
12
|
+
get(request: Request): T | undefined
|
|
13
|
+
/** Set the value for `request`'s whole scope. */
|
|
14
|
+
set(request: Request, value: T): void
|
|
15
|
+
has(request: Request): boolean
|
|
16
|
+
/** Remove the value from `request`'s scope; `true` when there was one. */
|
|
17
|
+
delete(request: Request): boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Values = Map<object, unknown>
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A copy maps to the request it was made from, and the end of that chain (the anchor) to its
|
|
24
|
+
* scope's values once one is set. Keyed in the global symbol registry so two copies of core keep
|
|
25
|
+
* one table, and weak, so a scope lives exactly as long as a request in it.
|
|
26
|
+
*/
|
|
27
|
+
type Links = WeakMap<Request, Request | Values>
|
|
28
|
+
|
|
29
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion lint/plugin/noChainedTypeAssertions: only this module writes the slot, and only a Links table.
|
|
30
|
+
const shared = globalThis as unknown as Record<symbol, Links | undefined>
|
|
31
|
+
const LINKS = Symbol.for("nifra.requestLocals")
|
|
32
|
+
|
|
33
|
+
const linksOf = (): Links => (shared[LINKS] ??= new WeakMap())
|
|
34
|
+
|
|
35
|
+
/** The request at the end of `request`'s chain of copies. */
|
|
36
|
+
function anchorOf(links: Links, request: Request): Request {
|
|
37
|
+
let anchor = request
|
|
38
|
+
let link = links.get(anchor)
|
|
39
|
+
while (link !== undefined && !(link instanceof Map)) {
|
|
40
|
+
anchor = link
|
|
41
|
+
link = links.get(anchor)
|
|
42
|
+
}
|
|
43
|
+
return anchor
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `to` was just built from `from`, with nothing set on it: it joins `from`'s scope. Until a first
|
|
47
|
+
* slot makes the table there is no scope to join, and a copy records nothing. */
|
|
48
|
+
export function carryToCopy(from: Request, to: Request): void {
|
|
49
|
+
shared[LINKS]?.set(to, from)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Join `to` to `from`'s request-local scope: each then reads every value set through either, now
|
|
54
|
+
* and later. nifra does this wherever it rebuilds a request itself; call it where a wrapper or an
|
|
55
|
+
* `onRequest` hook rebuilds one, right after `new Request(url, req)`. A value `to` already holds is
|
|
56
|
+
* kept where `from` has none.
|
|
57
|
+
*/
|
|
58
|
+
export function carryRequestLocals(from: Request, to: Request): void {
|
|
59
|
+
const links = shared[LINKS]
|
|
60
|
+
if (links === undefined) return
|
|
61
|
+
const target = anchorOf(links, from)
|
|
62
|
+
const joining = anchorOf(links, to)
|
|
63
|
+
if (target === joining) return
|
|
64
|
+
const own = links.get(joining)
|
|
65
|
+
links.set(joining, target)
|
|
66
|
+
if (!(own instanceof Map)) return
|
|
67
|
+
const held = links.get(target)
|
|
68
|
+
if (!(held instanceof Map)) links.set(target, own)
|
|
69
|
+
else for (const [key, value] of own) if (!held.has(key)) held.set(key, value)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function valuesOf(request: Request, create: boolean): Values | undefined {
|
|
73
|
+
const links = linksOf()
|
|
74
|
+
const anchor = anchorOf(links, request)
|
|
75
|
+
const held = links.get(anchor)
|
|
76
|
+
if (held instanceof Map || !create) return held instanceof Map ? held : undefined
|
|
77
|
+
const fresh: Values = new Map()
|
|
78
|
+
links.set(anchor, fresh)
|
|
79
|
+
return fresh
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A slot for one per-request value - a verified client IP, a request id, an auth decision - that
|
|
84
|
+
* survives the request copies nifra makes (a mount's prefix strip, a WebSocket pass reused for HTTP,
|
|
85
|
+
* target resolution) and the ones a wrapper makes with {@link carryRequestLocals}. A copy made any
|
|
86
|
+
* other way starts empty. Values are held weakly and never cross requests. Make slots at startup, as
|
|
87
|
+
* below: copies are linked only once a first slot exists, so a request copied before then starts
|
|
88
|
+
* empty.
|
|
89
|
+
*
|
|
90
|
+
* ```ts
|
|
91
|
+
* const clientIp = requestLocal<string>()
|
|
92
|
+
* app.onRequest((req, platform) => { clientIp.set(req, platform?.clientIp ?? "") })
|
|
93
|
+
* // ... in a mounted child app, after the prefix strip:
|
|
94
|
+
* clientIp.get(c.req)
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export function requestLocal<T>(): RequestLocal<T> {
|
|
98
|
+
linksOf()
|
|
99
|
+
const key = {}
|
|
100
|
+
return {
|
|
101
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: only `set` below writes `key`, and only a T.
|
|
102
|
+
get: (request) => valuesOf(request, false)?.get(key) as T | undefined,
|
|
103
|
+
set: (request, value) => {
|
|
104
|
+
valuesOf(request, true)?.set(key, value)
|
|
105
|
+
},
|
|
106
|
+
has: (request) => valuesOf(request, false)?.has(key) === true,
|
|
107
|
+
delete: (request) => valuesOf(request, false)?.delete(key) === true,
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/server/respond.ts
CHANGED
|
@@ -39,7 +39,7 @@ function headersInit(set: CtxSet): Record<string, string> | Headers | undefined
|
|
|
39
39
|
function plainRenderSet(plain: PlainRender, set: CtxSet): CtxSet {
|
|
40
40
|
return {
|
|
41
41
|
status: plain.status,
|
|
42
|
-
_headers: plainRenderHeaders(plain, set),
|
|
42
|
+
_headers: plainRenderHeaders(plain.headers, set._headers),
|
|
43
43
|
_cookies: set._cookies,
|
|
44
44
|
} as CtxSet
|
|
45
45
|
}
|