@nifrajs/core 5.2.0 → 5.3.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 +25 -0
- package/dist/edge-kit.d.ts +1 -1
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +1 -1
- package/dist/edge-kit.js.map +1 -1
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +2 -0
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mount.d.ts +12 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +20 -0
- package/dist/mount.js.map +1 -1
- package/dist/server/core-copies.js +1 -1
- package/dist/server/registry.d.ts +8 -10
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +4 -3
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-target.d.ts +9 -7
- package/dist/server/request-target.d.ts.map +1 -1
- package/dist/server/request-target.js +21 -12
- package/dist/server/request-target.js.map +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +10 -7
- package/dist/server/respond.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +33 -10
- package/dist/server/server.js.map +1 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +4 -0
- package/dist/server/websocket.js.map +1 -1
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +8 -4
- package/dist/tool-contract.js.map +1 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +4 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +1 -1
- package/src/edge-kit.ts +1 -1
- package/src/idempotency.ts +2 -0
- package/src/index.ts +1 -1
- package/src/mount.ts +25 -0
- package/src/server/core-copies.ts +1 -1
- package/src/server/registry.ts +18 -19
- package/src/server/request-context.ts +6 -3
- package/src/server/request-target.ts +21 -12
- package/src/server/respond.ts +10 -7
- package/src/server/server.ts +33 -8
- package/src/server/websocket.ts +3 -0
- package/src/tool-contract.ts +7 -4
- package/src/transport-codec.ts +4 -1
package/src/server/respond.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* spine types, so the wire-response format lives in one module distinct from the request engine.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { hasResidentBody, markResidentBody, residentBodiesRecorded } from "../mount.ts"
|
|
7
8
|
import { NODE_BRIDGE_MARKER_KEYS } from "./bridge-markers.ts"
|
|
8
9
|
import { plainError } from "./http.ts"
|
|
9
10
|
import {
|
|
@@ -227,7 +228,8 @@ const RESPONSE_BODY = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseBody)
|
|
|
227
228
|
|
|
228
229
|
/**
|
|
229
230
|
* 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
|
|
231
|
+
* foreign ones `writableResponse` has already checked. A body the framework serialized itself is
|
|
232
|
+
* recorded by `markResidentBody` instead, and counts the same. A body is sent once, so such a response belongs
|
|
231
233
|
* to whichever request holds it and is written in place without reading `.body` again - on Bun that
|
|
232
234
|
* read turns a string body into a stream, measured at about 6% of a request.
|
|
233
235
|
*/
|
|
@@ -252,10 +254,9 @@ export function rememberRenderedResponse(response: Response, hasBody: boolean):
|
|
|
252
254
|
return response
|
|
253
255
|
}
|
|
254
256
|
|
|
255
|
-
/** `rememberRenderedResponse` for a Response the framework
|
|
257
|
+
/** `rememberRenderedResponse` for a Response the framework built around a body it serialized. */
|
|
256
258
|
function withBody(response: Response): Response {
|
|
257
|
-
|
|
258
|
-
return response
|
|
259
|
+
return markResidentBody(response)
|
|
259
260
|
}
|
|
260
261
|
|
|
261
262
|
const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
|
|
@@ -289,7 +290,7 @@ function headersTakeWrites(headers: Headers): boolean {
|
|
|
289
290
|
* handed back on any other request, that copy is copied again.
|
|
290
291
|
*/
|
|
291
292
|
export function writableResponse(response: Response, request?: Request): Response {
|
|
292
|
-
if (WRITABLE_BODY_RESPONSES.has(response)) return response
|
|
293
|
+
if (hasResidentBody(response) || WRITABLE_BODY_RESPONSES.has(response)) return response
|
|
293
294
|
if (response.body === null) {
|
|
294
295
|
// A 101 upgrade and the status-0 `Response.error()` cannot be constructed, and a runtime makes
|
|
295
296
|
// them per request.
|
|
@@ -426,7 +427,8 @@ export function fusedRespondNoSet(
|
|
|
426
427
|
// hook walk is empty; an app that registers ANY response hook keeps the record, because the
|
|
427
428
|
// alternative is `writableResponse` reading `.body` and probing the headers of every response (each
|
|
428
429
|
// 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.
|
|
430
|
+
// Default false: an unknown caller pays one WeakSet write rather than a probe per response. Once an
|
|
431
|
+
// in-process client exists the write is no longer dead: it reads a recorded body whole.
|
|
430
432
|
if (
|
|
431
433
|
unstamped &&
|
|
432
434
|
(tagResponseBody === false || tagResponseBody === undefined) &&
|
|
@@ -437,7 +439,8 @@ export function fusedRespondNoSet(
|
|
|
437
439
|
!(result instanceof Response) &&
|
|
438
440
|
!isResponseResult(result)
|
|
439
441
|
) {
|
|
440
|
-
|
|
442
|
+
const response = Response.json(result)
|
|
443
|
+
return residentBodiesRecorded ? markResidentBody(response) : response
|
|
441
444
|
}
|
|
442
445
|
if (
|
|
443
446
|
result !== undefined &&
|
package/src/server/server.ts
CHANGED
|
@@ -113,7 +113,7 @@ import {
|
|
|
113
113
|
readBodyFramed,
|
|
114
114
|
} from "./request-context.ts"
|
|
115
115
|
import { recordRequestReplacement } from "./request-lineage.ts"
|
|
116
|
-
import {
|
|
116
|
+
import { needsResolving, resolveDotSegments } from "./request-target.ts"
|
|
117
117
|
import {
|
|
118
118
|
applyStaticResponseHeaders,
|
|
119
119
|
buildStaticResponseHeaders,
|
|
@@ -583,14 +583,21 @@ export { plainValidationError } from "./validation.ts"
|
|
|
583
583
|
// importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
|
|
584
584
|
export { type QueryValue, queryObjectOf, searchOf }
|
|
585
585
|
|
|
586
|
+
// Outside the printable ASCII a parsed path keeps as written: a control, space, `"`, `#`, `<`, `>`,
|
|
587
|
+
// `\`, `^`, `` ` ``, `{`, `}`, or past ASCII (U+FFFD, an invalid escape, included). Bun's table reads
|
|
588
|
+
// a raw `#` and what follows as path, where the parsed URL ends the path there.
|
|
589
|
+
const UNROUTED_CHAR = /[^!$%&'()*+,\-./0-9:;=?@A-Z[\]_a-z|~]/
|
|
590
|
+
|
|
586
591
|
/**
|
|
587
592
|
* A param value Bun's native route table matched but the portable router must decide: an invalid
|
|
588
593
|
* escape, or a piece of path Bun matched raw while its own parsed URL says otherwise - a `.` / `..`
|
|
589
|
-
* segment (Bun hands `%2e` over decoded),
|
|
594
|
+
* segment (Bun hands `%2e` over decoded), a backslash, which the parsed URL reads as `/`, a `#`,
|
|
595
|
+
* where the parsed URL's path ends (`/a/..#/b` is `/`), or a character the parsed URL
|
|
596
|
+
* percent-encodes (`/t/<b>` is `/t/%3Cb%3E`, which a static route for that text matches). The value
|
|
597
|
+
* is decoded, so a raw `<` and a sent `%3C` look alike; both are re-routed.
|
|
590
598
|
*/
|
|
591
599
|
const unroutedParam = (value: string | undefined): boolean =>
|
|
592
|
-
value !== undefined &&
|
|
593
|
-
(value.includes("\uFFFD") || value.includes("\\") || value === "." || value === "..")
|
|
600
|
+
value !== undefined && (value === "." || value === ".." || UNROUTED_CHAR.test(value))
|
|
594
601
|
|
|
595
602
|
function hasUnroutedParam(params: Record<string, string>): boolean {
|
|
596
603
|
for (const key in params) {
|
|
@@ -605,9 +612,19 @@ function hasUnroutedParam(params: Record<string, string>): boolean {
|
|
|
605
612
|
*/
|
|
606
613
|
const rawRequestTargets = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
|
|
607
614
|
|
|
608
|
-
/** `req` as Bun or workerd would have delivered it: the same request, its URL
|
|
615
|
+
/** `req` as Bun or workerd would have delivered it: the same request, its URL resolved as they parse it. */
|
|
609
616
|
const withResolvedTarget = (req: Request): Request => new Request(resolveDotSegments(req.url), req)
|
|
610
617
|
|
|
618
|
+
// A Host as RFC 9110 allows it: a reg-name or an IP literal, then an optional port. Deno builds
|
|
619
|
+
// `Request.url` by appending the target to the Host as received, so `Host: x/admin#` turned a
|
|
620
|
+
// request for `/public` into one for `/admin`. Node refuses such a Host before routing; so does this.
|
|
621
|
+
const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::\d{1,5})?$/i
|
|
622
|
+
|
|
623
|
+
const invalidHost = (req: Request): boolean => {
|
|
624
|
+
const host = req.headers.get("host")
|
|
625
|
+
return host !== null && !HOST_AUTHORITY.test(host)
|
|
626
|
+
}
|
|
627
|
+
|
|
611
628
|
function normalizeMountPrefix(path: string): string {
|
|
612
629
|
if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
|
|
613
630
|
throw new TypeError("mountFetch path must be an absolute pathname without a query or hash")
|
|
@@ -3159,7 +3176,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3159
3176
|
fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
|
|
3160
3177
|
// Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
|
|
3161
3178
|
// Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
|
|
3162
|
-
if (rawRequestTargets
|
|
3179
|
+
if (rawRequestTargets) {
|
|
3180
|
+
if (invalidHost(req)) return jsonError(400, "invalid_host")
|
|
3181
|
+
if (needsResolving(req.url)) req = withResolvedTarget(req)
|
|
3182
|
+
}
|
|
3163
3183
|
// An edge deployment whose only ingress is this method declares its requests runtime-framed at
|
|
3164
3184
|
// construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
|
|
3165
3185
|
if (this.trustBodyFraming) markTrustedBodyFraming(req)
|
|
@@ -3654,8 +3674,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3654
3674
|
if (req.method !== "GET") return WS_PASS
|
|
3655
3675
|
// The adapter answers a pass by handing this same object to `fetch`.
|
|
3656
3676
|
const received = req
|
|
3657
|
-
// The handshake routes the path `fetch` would: dot segments resolved (see `fetch`).
|
|
3658
|
-
if (rawRequestTargets
|
|
3677
|
+
// The handshake routes the path `fetch` would: Host checked, dot segments resolved (see `fetch`).
|
|
3678
|
+
if (rawRequestTargets) {
|
|
3679
|
+
if (invalidHost(req)) {
|
|
3680
|
+
return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) }
|
|
3681
|
+
}
|
|
3682
|
+
if (needsResolving(req.url)) req = withResolvedTarget(req)
|
|
3683
|
+
}
|
|
3659
3684
|
// The handshake's caller is the one `fetch` derives: hooks and `upgrade()` see the trusted IP.
|
|
3660
3685
|
platform = this.trustedPlatform(req, platform)
|
|
3661
3686
|
|
package/src/server/websocket.ts
CHANGED
|
@@ -60,6 +60,9 @@ export class TopicRegistry {
|
|
|
60
60
|
private readonly memberships = new WeakMap<NifraWebSocket, Set<string>>()
|
|
61
61
|
|
|
62
62
|
subscribe(topic: string, ws: NifraWebSocket): void {
|
|
63
|
+
// CLOSING or CLOSED: close has run, or is about to run, `unsubscribeAll`, so an async handler's
|
|
64
|
+
// late subscribe would hold the dead socket for good.
|
|
65
|
+
if (ws.readyState >= 2) return
|
|
63
66
|
let subs = this.topics.get(topic)
|
|
64
67
|
if (subs === undefined) {
|
|
65
68
|
subs = new Set()
|
package/src/tool-contract.ts
CHANGED
|
@@ -469,8 +469,9 @@ export async function executeTool<Input, Output>(
|
|
|
469
469
|
await options.idempotency?.abandon(reservationInput(reservation))
|
|
470
470
|
} catch {}
|
|
471
471
|
}
|
|
472
|
-
// Once the executor has
|
|
473
|
-
// that may have committed. A store that throws on completion counts as
|
|
472
|
+
// Once the executor has started, the reservation is never released, even when it throws: a retry
|
|
473
|
+
// would repeat a side effect that may have committed. A store that throws on completion counts as
|
|
474
|
+
// one that refused it.
|
|
474
475
|
let executed = false
|
|
475
476
|
const complete = async (): Promise<boolean> => {
|
|
476
477
|
if (reservation === undefined) return true
|
|
@@ -660,6 +661,7 @@ export async function executeTool<Input, Output>(
|
|
|
660
661
|
record("execution", "dry-run")
|
|
661
662
|
return finish({ ok: true, dryRun })
|
|
662
663
|
}
|
|
664
|
+
executed = true
|
|
663
665
|
const output = await tool.execute(validatedInput.value, {
|
|
664
666
|
effectId,
|
|
665
667
|
signal,
|
|
@@ -667,7 +669,6 @@ export async function executeTool<Input, Output>(
|
|
|
667
669
|
dryRun,
|
|
668
670
|
...(tool.policy === undefined ? {} : { policy: tool.policy }),
|
|
669
671
|
})
|
|
670
|
-
executed = true
|
|
671
672
|
record("execution", "committed")
|
|
672
673
|
const validatedOutput = await validateStandard(tool.output, output)
|
|
673
674
|
if (!validatedOutput.ok) {
|
|
@@ -699,7 +700,9 @@ export async function executeTool<Input, Output>(
|
|
|
699
700
|
}
|
|
700
701
|
return finish({ ok: true, dryRun, output: validatedOutput.value })
|
|
701
702
|
} catch (error) {
|
|
702
|
-
|
|
703
|
+
// A completion the store refuses still leaves the key pending until its lease lapses.
|
|
704
|
+
if (executed) await complete()
|
|
705
|
+
else await abandon()
|
|
703
706
|
const code = signal.aborted || isAbortError(error) ? "cancelled" : "execution_failed"
|
|
704
707
|
record("execution", "failed", code)
|
|
705
708
|
return finish({ ok: false, dryRun, error: { code, stage: "execution" } })
|
package/src/transport-codec.ts
CHANGED
|
@@ -228,8 +228,11 @@ export async function readBoundedBytes(
|
|
|
228
228
|
): Promise<Uint8Array> {
|
|
229
229
|
const maxBytes = maxBytesOf(options)
|
|
230
230
|
const declared = response.headers.get("content-length")
|
|
231
|
-
if (declared !== null && /^\d+$/u.test(declared) && Number(declared) > maxBytes)
|
|
231
|
+
if (declared !== null && /^\d+$/u.test(declared) && Number(declared) > maxBytes) {
|
|
232
|
+
// Refused unread, the body would otherwise hold its connection open until the sender gives up.
|
|
233
|
+
response.body?.cancel().catch(() => {})
|
|
232
234
|
throw new TransportCodecError("transport payload exceeds maxBytes")
|
|
235
|
+
}
|
|
233
236
|
if (response.body === null) return new Uint8Array(0)
|
|
234
237
|
const reader = response.body.getReader()
|
|
235
238
|
const chunks: Uint8Array[] = []
|